diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index e516c02be..17f51857e 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -75,6 +75,9 @@ bool _ignoreDevicePixelRatio = true; int windowsBuildNumber = 0; DesktopType? desktopType; +// Tolerance used for floating-point position comparisons to avoid precision errors. +const double _kPositionEpsilon = 1e-6; + bool get isMainDesktopWindow => desktopType == DesktopType.main || desktopType == DesktopType.cm; @@ -106,6 +109,10 @@ enum DesktopType { portForward, } +bool isDoubleEqual(double a, double b) { + return (a - b).abs() < _kPositionEpsilon; +} + class IconFont { static const _family1 = 'Tabbar'; static const _family2 = 'PeerSearchbar'; @@ -1852,6 +1859,8 @@ Future _adjustRestoreMainWindowSize(double? width, double? height) async { return Size(restoreWidth, restoreHeight); } +// Consider using Rect.contains() instead, +// though the implementation is not exactly the same. bool isPointInRect(Offset point, Rect rect) { return point.dx >= rect.left && point.dx <= rect.right && diff --git a/flutter/lib/common/widgets/gestures.dart b/flutter/lib/common/widgets/gestures.dart index b3cfeae6e..74b1642b7 100644 --- a/flutter/lib/common/widgets/gestures.dart +++ b/flutter/lib/common/widgets/gestures.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_hbb/common/widgets/remote_input.dart'; enum GestureState { none, @@ -96,6 +97,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer { if (onTwoFingerScaleEnd != null) { onTwoFingerScaleEnd!(d); } + if (isSpecialHoldDragActive) { + // If we are in special drag mode, we need to reset the state. + // Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`. + _currentState = GestureState.none; + return; + } break; case GestureState.threeFingerVerticalDrag: debugPrint("ThreeFingerState.vertical onEnd"); diff --git a/flutter/lib/common/widgets/remote_input.dart b/flutter/lib/common/widgets/remote_input.dart index 8eb0ecbc3..f75e0027b 100644 --- a/flutter/lib/common/widgets/remote_input.dart +++ b/flutter/lib/common/widgets/remote_input.dart @@ -51,6 +51,13 @@ class RawKeyFocusScope extends StatelessWidget { } } +// For virtual mouse when using the mouse mode on mobile. +// Special hold-drag mode: one finger holds a button (left/right button), another finger pans. +// This flag is to override the scale gesture to a pan gesture. +bool isSpecialHoldDragActive = false; +// Cache the last focal point to calculate deltas in special hold-drag mode. +Offset _lastSpecialHoldDragFocalPoint = Offset.zero; + class RawTouchGestureDetectorRegion extends StatefulWidget { final Widget child; final FFI ffi; @@ -97,6 +104,10 @@ class _RawTouchGestureDetectorRegionState bool _touchModePanStarted = false; Offset _doubleFinerTapPosition = Offset.zero; + // For mouse mode, we need to block the events when the cursor is in a blocked area. + // So we need to cache the last tap down position. + Offset? _lastTapDownPositionForMouseMode; + FFI get ffi => widget.ffi; FfiModel get ffiModel => widget.ffiModel; InputModel get inputModel => widget.inputModel; @@ -112,7 +123,15 @@ class _RawTouchGestureDetectorRegionState } bool isNotTouchBasedDevice() { - return !kTouchBasedDeviceKinds.contains(lastDeviceKind); + return !kTouchBasedDeviceKinds.contains(lastDeviceKind); + } + + // Mobile, mouse mode. + // Check if should block the mouse tap event (`_lastTapDownPositionForMouseMode`). + bool shouldBlockMouseModeEvent() { + return _lastTapDownPositionForMouseMode != null && + ffi.cursorModel.shouldBlock(_lastTapDownPositionForMouseMode!.dx, + _lastTapDownPositionForMouseMode!.dy); } onTapDown(TapDownDetails d) async { @@ -124,6 +143,8 @@ class _RawTouchGestureDetectorRegionState _lastPosOfDoubleTapDown = d.localPosition; // Desktop or mobile "Touch mode" _lastTapDownDetails = d; + } else { + _lastTapDownPositionForMouseMode = d.localPosition; } } @@ -150,6 +171,11 @@ class _RawTouchGestureDetectorRegionState return; } if (!handleTouch) { + // Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details. + // Using `_lastTapDownPositionForMouseMode` instead. + if (shouldBlockMouseModeEvent()) { + return; + } // Mobile, "Mouse mode" await inputModel.tap(MouseButtons.left); } @@ -163,6 +189,8 @@ class _RawTouchGestureDetectorRegionState if (handleTouch) { _lastPosOfDoubleTapDown = d.localPosition; await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + } else { + _lastTapDownPositionForMouseMode = d.localPosition; } } @@ -177,6 +205,12 @@ class _RawTouchGestureDetectorRegionState !ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) { return; } + // Check if the position is in a blocked area when using the mouse mode. + if (!handleTouch) { + if (shouldBlockMouseModeEvent()) { + return; + } + } await inputModel.tap(MouseButtons.left); await inputModel.tap(MouseButtons.left); } @@ -198,6 +232,8 @@ class _RawTouchGestureDetectorRegionState .move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy); await inputModel.tapDown(MouseButtons.left); } + } else { + _lastTapDownPositionForMouseMode = d.localPosition; } } @@ -222,6 +258,10 @@ class _RawTouchGestureDetectorRegionState if (!isMoved) { return; } + } else { + if (shouldBlockMouseModeEvent()) { + return; + } } await inputModel.tap(MouseButtons.right); } else { @@ -274,6 +314,7 @@ class _RawTouchGestureDetectorRegionState return; } if (!handleTouch) { + if (isSpecialHoldDragActive) return; await inputModel.sendMouse('down', MouseButtons.left); } } @@ -283,6 +324,7 @@ class _RawTouchGestureDetectorRegionState return; } if (!handleTouch) { + if (isSpecialHoldDragActive) return; await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); } } @@ -377,12 +419,26 @@ class _RawTouchGestureDetectorRegionState if (isNotTouchBasedDevice()) { return; } + if (isSpecialHoldDragActive) { + // Initialize the last focal point to calculate deltas manually. + _lastSpecialHoldDragFocalPoint = d.focalPoint; + } } onTwoFingerScaleUpdate(ScaleUpdateDetails d) async { if (isNotTouchBasedDevice()) { return; } + + // If in special drag mode, perform a pan instead of a scale. + if (isSpecialHoldDragActive) { + // Calculate delta manually to avoid the jumpy behavior. + final delta = d.focalPoint - _lastSpecialHoldDragFocalPoint; + _lastSpecialHoldDragFocalPoint = d.focalPoint; + await ffi.cursorModel.updatePan(delta * 2.0, d.focalPoint, handleTouch); + return; + } + if ((isDesktop || isWebDesktop)) { final scale = ((d.scale - _scale) * 1000).toInt(); _scale = d.scale; @@ -420,7 +476,9 @@ class _RawTouchGestureDetectorRegionState // No idea why we need to set the view style to "" here. // bind.sessionSetViewStyle(sessionId: sessionId, value: ""); } - await inputModel.sendMouse('up', MouseButtons.left); + if (!isSpecialHoldDragActive) { + await inputModel.sendMouse('up', MouseButtons.left); + } } get onHoldDragCancel => null; diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index a7d8b158f..19c24a109 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -155,6 +155,9 @@ const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification"; const String kOptionEnableUdpPunch = "enable-udp-punch"; const String kOptionEnableIpv6Punch = "enable-ipv6-punch"; const String kOptionEnableTrustedDevices = "enable-trusted-devices"; +const String kOptionShowVirtualMouse = "show-virtual-mouse"; +const String kOptionVirtualMouseScale = "virtual-mouse-scale"; +const String kOptionShowVirtualJoystick = "show-virtual-joystick"; // network options const String kOptionAllowWebSocket = "allow-websocket"; diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 4c8081465..05de2f60c 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -6,6 +6,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/mobile/widgets/floating_mouse.dart'; +import 'package:flutter_hbb/mobile/widgets/floating_mouse_widgets.dart'; import 'package:flutter_hbb/mobile/widgets/gesture_help.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; @@ -617,6 +619,15 @@ class _RemotePageState extends State with WidgetsBindingObserver { if (showCursorPaint) { paints.add(CursorPaint(widget.id)); } + if (gFFI.ffiModel.touchMode) { + paints.add(FloatingMouse( + ffi: gFFI, + )); + } else { + paints.add(FloatingMouseWidgets( + ffi: gFFI, + )); + } return paints; }())); } @@ -789,13 +800,15 @@ class _RemotePageState extends State with WidgetsBindingObserver { controller: ScrollController(), padding: EdgeInsets.symmetric(vertical: 10), child: GestureHelp( - touchMode: gFFI.ffiModel.touchMode, - onTouchModeChange: (t) { - gFFI.ffiModel.toggleTouchMode(); - final v = gFFI.ffiModel.touchMode ? 'Y' : ''; - bind.sessionPeerOption( - sessionId: sessionId, name: kOptionTouchMode, value: v); - }))); + touchMode: gFFI.ffiModel.touchMode, + onTouchModeChange: (t) { + gFFI.ffiModel.toggleTouchMode(); + final v = gFFI.ffiModel.touchMode ? 'Y' : ''; + bind.sessionPeerOption( + sessionId: sessionId, name: kOptionTouchMode, value: v); + }, + virtualMouseMode: gFFI.ffiModel.virtualMouseMode, + ))); } // * Currently mobile does not enable map mode diff --git a/flutter/lib/mobile/widgets/floating_mouse.dart b/flutter/lib/mobile/widgets/floating_mouse.dart new file mode 100644 index 000000000..d18011c63 --- /dev/null +++ b/flutter/lib/mobile/widgets/floating_mouse.dart @@ -0,0 +1,1209 @@ +// This floating mouse widget simulates a physical mouse when connecting from mobile to desktop in touch mode. + +import 'dart:async'; +import 'dart:math'; +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:flutter_hbb/utils/image.dart'; +import 'package:provider/provider.dart'; + +const int _kDotCount = 60; +const double _kDotAngle = 2 * pi / _kDotCount; +final Color _kDefaultColor = Colors.grey.withOpacity(0.7); +final Color _kDefaultHighlightColor = Colors.white24.withOpacity(0.7); +final Color _kTapDownColor = Colors.blue.withOpacity(0.7); +const double _baseMouseWidth = 112.0; +const double _baseMouseHeight = 138.0; +const double _kShowPressedScale = 1.2; +const double kScaleMax = 1.8; +const double kScaleMin = 0.8; + +double? _tryParseCoordinateFromEvt(Map? evt, String key) { + if (evt == null) return null; + final coord = evt[key]; + if (coord == null) return null; + return double.tryParse(coord); +} + +class FloatingMouse extends StatefulWidget { + final FFI ffi; + const FloatingMouse({ + super.key, + required this.ffi, + }); + + @override + State createState() => _FloatingMouseState(); +} + +class _CanvasScrollState { + static const double speedPressed = 3.0; + final InputModel inputModel; + final CanvasModel canvasModel; + final int _intervalMillis = 30; + Timer? _timer; + double _dx = 0; + double _dy = 0; + double _speed = 1.0; + Rect _displayRect = Rect.zero; + Offset _mouseGlobalPosition = Offset.zero; + + _CanvasScrollState({required this.inputModel, required this.canvasModel}); + + double get step => 5.0 * canvasModel.scale; + + set scrollX(double speed) { + _dx = step; + setSpeed(speed); + } + + set scrollY(double speed) { + _dy = step; + setSpeed(speed); + } + + void tryCancel() { + _dx = 0; + _dy = 0; + if (_timer == null) return; + _timer?.cancel(); + _timer = null; + } + + void setPressedSpeed() { + setSpeed(_speed > 0 + ? _CanvasScrollState.speedPressed + : -_CanvasScrollState.speedPressed); + } + + void setReleasedSpeed() { + setSpeed(_speed > 0 ? 1.0 : -1.0); + } + + void setSpeed(double newSpeed) { + _speed = newSpeed; + if (_speed > 0) { + _speed = _speed.clamp(0.1, 10.0); + } else { + _speed = _speed.clamp(-10.0, -0.1); + } + if (_dx != 0) { + _dx = step * _speed; + } else if (_dy != 0) { + _dy = step * _speed; + } + } + + void tryStart(Rect displayRect, Offset mouseGlobalPosition) { + _displayRect = displayRect; + _mouseGlobalPosition = mouseGlobalPosition; + if (_timer != null) return; + _timer = Timer.periodic(Duration(milliseconds: _intervalMillis), (timer) { + if (_dx == 0 && _dy == 0) { + tryCancel(); + } else { + if (_dx != 0) { + canvasModel.panX(_dx); + } + if (_dy != 0) { + canvasModel.panY(_dy); + } + final evt = inputModel.processEventToPeer( + InputModel.getMouseEventMove(), _mouseGlobalPosition, + moveCanvas: false); + if (shouldCancelScrollTimer(evt)) { + tryCancel(); + } + } + }); + } + + bool shouldCancelScrollTimer(Map? evt) { + if (evt == null) { + return true; + } + double s = canvasModel.scale; + assert(s > 0, 'canvasModel.scale should always be positive'); + if (s <= 0) { + return true; + } + if (_dx != 0) { + final x = _tryParseCoordinateFromEvt(evt, 'x'); + if (x == null) { + return true; + } else { + if (_dx < 0) { + if (isDoubleEqual(_displayRect.right - 1, x)) { + return true; + } else { + final dxDisplay = _dx / s; + if ((x - dxDisplay) > (_displayRect.right - 1)) { + canvasModel.panX((x - _displayRect.right + 1) * s); + return true; + } + } + } else { + if (isDoubleEqual(x, _displayRect.left)) { + return true; + } else { + final dxDisplay = _dx / s; + if ((x - dxDisplay) < _displayRect.left) { + canvasModel.panX((x - _displayRect.left) * s); + return true; + } + } + } + } + } + if (_dy != 0) { + final y = _tryParseCoordinateFromEvt(evt, 'y'); + if (y == null) { + return true; + } else { + if (_dy < 0) { + if (isDoubleEqual(_displayRect.bottom - 1, y)) { + return true; + } else { + final dyDisplay = _dy / s; + if ((y - dyDisplay) > (_displayRect.bottom - 1)) { + canvasModel.panY((y - _displayRect.bottom + 1) * s); + return true; + } + } + } else { + if (isDoubleEqual(y, _displayRect.top)) { + return true; + } else { + final dyDisplay = _dy / s; + if ((y - dyDisplay) < _displayRect.top) { + canvasModel.panY((y - _displayRect.top) * s); + return true; + } + } + } + } + } + return false; + } +} + +class _FloatingMouseState extends State { + Rect? _lastBlockedRect; + final GlobalKey _scrollWheelUpKey = GlobalKey(); + final GlobalKey _scrollWheelDownKey = GlobalKey(); + final GlobalKey _mouseWidgetKey = GlobalKey(); + final GlobalKey _cursorPaintKey = GlobalKey(); + + Offset _position = Offset.zero; + bool _isInitialized = false; + double _baseMouseScale = 1.0; + double _mouseScale = 1.0; + bool _isExpanded = true; + bool _isScrolling = false; + Offset? _scrollCenter; + double _snappedPointerAngle = 0.0; + double? _lastSnappedAngle; + late final _CanvasScrollState _canvasScrollState; + Orientation? _previousOrientation; + Timer? _collapseTimer; + late final VirtualMouseMode _virtualMouseMode; + + void _resetCollapseTimer() { + _collapseTimer?.cancel(); + if (_isExpanded) { + _collapseTimer = Timer(const Duration(seconds: 7), () { + if (mounted && _isExpanded) { + final minMouseScale = (_baseMouseScale * 0.3); + setState(() { + _mouseScale = minMouseScale; + _isExpanded = false; + _position += _expandOffset; + }); + } + }); + } + } + + double get mouseWidth => _baseMouseWidth * _mouseScale; + double get mouseHeight => _baseMouseHeight * _mouseScale; + + InputModel get _inputModel => widget.ffi.inputModel; + CursorModel get _cursorModel => widget.ffi.cursorModel; + CanvasModel get _canvasModel => widget.ffi.canvasModel; + + Offset get _expandOffset => + Offset(84 * _baseMouseScale, 12 * _baseMouseScale); + + @override + void initState() { + super.initState(); + _virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode; + _virtualMouseMode.addListener(_onVirtualMouseModeChanged); + _canvasScrollState = + _CanvasScrollState(inputModel: _inputModel, canvasModel: _canvasModel); + _cursorModel.blockEvents = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + _resetPosition(); + _resetCollapseTimer(); + }); + } + + void _onVirtualMouseModeChanged() { + if (mounted) { + setState(() { + if (_virtualMouseMode.showVirtualMouse) { + _isExpanded = true; + _resetCollapseTimer(); + } + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentOrientation = MediaQuery.of(context).orientation; + if (_previousOrientation != null && + _previousOrientation != currentOrientation) { + _resetPosition(); + } + _previousOrientation = currentOrientation; + } + + void _resetPosition() { + setState(() { + final size = MediaQuery.of(context).size; + _position = Offset( + (size.width - _baseMouseWidth * _mouseScale) / 2, + (size.height - _baseMouseHeight * _mouseScale) / 2, + ); + _isInitialized = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + @override + void dispose() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + _virtualMouseMode.removeListener(_onVirtualMouseModeChanged); + _canvasScrollState.tryCancel(); + _cursorModel.blockEvents = false; + _collapseTimer?.cancel(); + super.dispose(); + } + + void _updateBlockedRect() { + final context = _mouseWidgetKey.currentContext; + if (context == null) return; + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.attached) return; + + final newRect = renderBox.localToGlobal(Offset.zero) & renderBox.size; + + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + _cursorModel.addBlockedRect(newRect); + _lastBlockedRect = newRect; + } + + Offset _getMouseGlobalPosition() { + final RenderBox? renderBox = + _cursorPaintKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + return renderBox.localToGlobal(Offset.zero); + } else { + return _position; + } + } + + static Offset? _getPositionFromMouseRetEvt(Map? evt) { + final x = _tryParseCoordinateFromEvt(evt, 'x'); + final y = _tryParseCoordinateFromEvt(evt, 'y'); + if (x == null || y == null) { + return null; + } + return Offset(x, y); + } + + // Returns true if [value] is within 2.01 pixels of [edge]. + // We need this near check because it can make the auto scroll easier to trigger and control. + bool _isValueNearEdge(double edge, double value) { + return (value - edge).abs() < 2.01; + } + + bool _isValueAtEdge(double edge, double value) { + return (value - edge).abs() < 0.01; + } + + bool _isValueAtOrOutsideEdge(double edge, double? value) { + // If value is null, then consider it outside the edge. + return value == null || isDoubleEqual(value, edge); + } + + // If the mouse is very close to the edge of the display, + // we can only start auto scroll when the mouse is at the edge of the screen. + bool _shouldAutoScrollIfCursorNearRemoteEdge(double remoteEdge, + double remoteValue, double localEdge, double localValue) { + if ((remoteEdge - remoteValue).abs() < 100.0) { + if (!_isValueAtEdge(localEdge, localValue)) { + return false; + } + } + return true; + } + + void _onMoveUpdateDelta(Offset delta) { + _resetCollapseTimer(); + final context = this.context; + final size = MediaQuery.of(context).size; + Offset newPosition = _position + delta; + double minX = 0; + double minY = 0; + double maxX = size.width - mouseWidth; + double maxY = size.height - mouseHeight; + newPosition = Offset( + newPosition.dx.clamp(minX, maxX), + newPosition.dy.clamp(minY, maxY), + ); + setState(() { + final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) && + isDoubleEqual(newPosition.dy, _position.dy)); + _position = newPosition; + if (!_isExpanded) { + return; + } + + Offset? mouseGlobalPosition; + Offset? positionInRemoteDisplay; + if (isPositionChanged) { + mouseGlobalPosition = _getMouseGlobalPosition(); + final evt = _inputModel.handleMouse( + InputModel.getMouseEventMove(), mouseGlobalPosition, + moveCanvas: false); + positionInRemoteDisplay = _getPositionFromMouseRetEvt(evt); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + // Get the display rect + final displayRect = widget.ffi.ffiModel.displaysRect(); + if (displayRect == null) { + _canvasScrollState.tryCancel(); + return; + } + + // Get the mouse global position and position in remote display + mouseGlobalPosition ??= _getMouseGlobalPosition(); + if (positionInRemoteDisplay == null) { + final evt = _inputModel.processEventToPeer( + InputModel.getMouseEventMove(), mouseGlobalPosition, + moveCanvas: false); + positionInRemoteDisplay = _getPositionFromMouseRetEvt(evt); + } + + // Check if need to start auto canvas scroll + // If: + // 1. The mouse is near the edge of the screen. + // 2. The position in remote display is in the rect of the display. + // 3. If the remote cursor is near the edge of the remote display, + // then the local mouse must be at the edge of the screen. + // Then start auto canvas scroll. + if (_isValueNearEdge(minX, _position.dx)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.left, positionInRemoteDisplay?.dx)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.left, + positionInRemoteDisplay.dx, minX, _position.dx)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollX = 1.0 * _CanvasScrollState.speedPressed; + } else if (_isValueNearEdge(minY, _position.dy)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.top, positionInRemoteDisplay?.dy)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.top, + positionInRemoteDisplay.dy, minY, _position.dy)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollY = 1.0 * _CanvasScrollState.speedPressed; + } else if (_isValueNearEdge(maxX, _position.dx)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.right - 1, positionInRemoteDisplay?.dx)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.right - 1, + positionInRemoteDisplay.dx, maxX, _position.dx)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollX = -1.0 * _CanvasScrollState.speedPressed; + } else if (_isValueNearEdge(maxY, _position.dy)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.bottom - 1, positionInRemoteDisplay?.dy)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.bottom - 1, + positionInRemoteDisplay.dy, maxY, _position.dy)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollY = -1.0 * _CanvasScrollState.speedPressed; + } else { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.tryStart(displayRect, mouseGlobalPosition); + }); + } + + void _onDragHandleUpdate(DragUpdateDetails details) => + _onMoveUpdateDelta(details.delta); + + void _onBodyPointerMoveUpdate(PointerMoveEvent event) => + _onMoveUpdateDelta(event.delta); + + bool _containsPosition(GlobalKey key, Offset pos) { + final contextScroll = key.currentContext; + if (contextScroll == null) return false; + final RenderBox? scrollWheelBox = + contextScroll.findRenderObject() as RenderBox?; + if (scrollWheelBox == null || !scrollWheelBox.attached) return false; + Rect rect = scrollWheelBox.localToGlobal(Offset.zero) & scrollWheelBox.size; + return rect.contains(pos); + } + + void _handlePointerDown(PointerDownEvent event) { + _resetCollapseTimer(); + if (_isScrolling) return; + if (_containsPosition(_scrollWheelUpKey, event.position) || + _containsPosition(_scrollWheelDownKey, event.position)) { + final contextMouse = _mouseWidgetKey.currentContext; + if (contextMouse == null) return; + final RenderBox? mouseBox = contextMouse.findRenderObject() as RenderBox?; + if (mouseBox == null || !mouseBox.attached) return; + + // Only enter scroll mode when all RenderObjects are available. + final Offset mouseTopLeft = mouseBox.localToGlobal(Offset.zero); + final Size mouseSize = mouseBox.size; + final Offset center = + mouseTopLeft + Offset(mouseSize.width / 2, mouseSize.height / 2); + + final vector = event.position - center; + final rawAngle = atan2(vector.dy, vector.dx); + + final closestDotIndex = (rawAngle / _kDotAngle).round(); + _lastSnappedAngle = closestDotIndex * _kDotAngle; + + setState(() { + _isScrolling = true; + _cursorModel.blockEvents = true; + _scrollCenter = center; + _snappedPointerAngle = _lastSnappedAngle!; + }); + } + } + + void _handlePointerMove(PointerMoveEvent event) { + _resetCollapseTimer(); + if (!_isScrolling || _scrollCenter == null || _lastSnappedAngle == null) { + return; + } + + final touchPosition = event.position; + final vector = touchPosition - _scrollCenter!; + final rawCurrentAngle = atan2(vector.dy, vector.dx); + + final closestDotIndex = (rawCurrentAngle / _kDotAngle).round(); + final snappedCurrentAngle = closestDotIndex * _kDotAngle; + + if (snappedCurrentAngle == _lastSnappedAngle) return; + + double deltaAngle = snappedCurrentAngle - _lastSnappedAngle!; + + if (deltaAngle.abs() > pi) { + deltaAngle = (deltaAngle > 0) ? deltaAngle - 2 * pi : deltaAngle + 2 * pi; + } + + _lastSnappedAngle = snappedCurrentAngle; + + setState(() { + _snappedPointerAngle = snappedCurrentAngle; + _inputModel.scroll(deltaAngle > 0 ? -1 : 1); + }); + } + + void _tryCancelScrolling() { + _resetCollapseTimer(); + if (!_isScrolling) return; + setState(() { + _isScrolling = false; + _cursorModel.blockEvents = false; + _lastSnappedAngle = null; + _scrollCenter = null; + }); + } + + void _handlePointerUp(PointerUpEvent event) => _tryCancelScrolling(); + void _handlePointerCancel(PointerCancelEvent event) => _tryCancelScrolling(); + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return const Offstage(); + } + final virtualMouseMode = _virtualMouseMode; + if (!virtualMouseMode.showVirtualMouse) { + return const Offstage(); + } + _baseMouseScale = virtualMouseMode.virtualMouseScale; + if (_isExpanded) { + _mouseScale = _baseMouseScale; + } else { + final minMouseScale = (_baseMouseScale * 0.3); + _mouseScale = minMouseScale; + } + return Listener( + onPointerDown: _isExpanded ? _handlePointerDown : null, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + if (!_isScrolling) + Positioned( + left: _position.dx, + top: _position.dy, + child: _buildMouseWithHide(), + ), + if (_isScrolling && _scrollCenter != null) + Positioned.fill( + child: Builder( + builder: (context) { + final RenderBox? customPaintBox = + context.findRenderObject() as RenderBox?; + if (customPaintBox == null || !customPaintBox.attached) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isScrolling) setState(() {}); + }); + return const SizedBox.expand(); + } + final Offset customPaintTopLeft = + customPaintBox.localToGlobal(Offset.zero); + final Offset localCenter = + _scrollCenter! - customPaintTopLeft; + return CustomPaint( + painter: DottedCirclePainter( + center: localCenter, + pointerAngle: _snappedPointerAngle, + scale: _mouseScale, + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildMouseWithHide() { + double minMouseScale = (_baseMouseScale * 0.3); + if (!_isExpanded) { + return SizedBox( + width: mouseWidth, + height: mouseHeight, + child: GestureDetector( + onPanUpdate: _onDragHandleUpdate, + onTap: () { + setState(() { + _mouseScale = _baseMouseScale; + _isExpanded = true; + _position -= _expandOffset; + }); + _resetCollapseTimer(); + }, + child: MouseBody( + scrollWheelUpKey: _scrollWheelUpKey, + scrollWheelDownKey: _scrollWheelDownKey, + mouseWidgetKey: _mouseWidgetKey, + inputModel: _isExpanded ? _inputModel : null, + scale: _mouseScale, + resetCollapseTimer: _resetCollapseTimer, + ), + )); + } else { + return SizedBox( + width: mouseWidth, + height: mouseHeight, + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CursorPaint( + key: _cursorPaintKey, + scale: _mouseScale, + ), + const Spacer(), + GestureDetector( + onTap: () { + _collapseTimer?.cancel(); + setState(() { + _mouseScale = minMouseScale; + _isExpanded = false; + _position += _expandOffset; + }); + }, + child: Container( + width: 18 * _mouseScale, + height: 18 * _mouseScale, + child: Center( + child: Container( + width: 14 * _mouseScale, + height: 14 * _mouseScale, + decoration: const BoxDecoration( + color: Colors.grey, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon(Icons.close, + color: Colors.white, size: 12 * _mouseScale), + ), + ), + ), + ), + ], + ), + Padding( + padding: EdgeInsets.only(left: 14 * _mouseScale), + child: MouseBody( + scrollWheelUpKey: _scrollWheelUpKey, + scrollWheelDownKey: _scrollWheelDownKey, + mouseWidgetKey: _mouseWidgetKey, + onPointerMoveUpdate: _onBodyPointerMoveUpdate, + cancelCanvasScroll: _canvasScrollState.tryCancel, + setCanvasScrollPressed: _canvasScrollState.setPressedSpeed, + setCanvasScrollReleased: _canvasScrollState.setReleasedSpeed, + inputModel: _isExpanded ? _inputModel : null, + scale: _mouseScale, + resetCollapseTimer: _resetCollapseTimer, + )), + ], + ), + ); + } + } +} + +class MouseBody extends StatefulWidget { + final GlobalKey scrollWheelUpKey; + final GlobalKey scrollWheelDownKey; + final GlobalKey mouseWidgetKey; + final Function(PointerMoveEvent)? onPointerMoveUpdate; + final Function()? cancelCanvasScroll; + final Function()? setCanvasScrollPressed; + final Function()? setCanvasScrollReleased; + final InputModel? inputModel; + final double scale; + final Function()? resetCollapseTimer; + const MouseBody({ + super.key, + required this.scrollWheelUpKey, + required this.scrollWheelDownKey, + required this.mouseWidgetKey, + required this.scale, + this.inputModel, + this.onPointerMoveUpdate, + this.cancelCanvasScroll, + this.setCanvasScrollPressed, + this.setCanvasScrollReleased, + this.resetCollapseTimer, + }); + + @override + State createState() => _MouseBodyState(); +} + +class WidgetScale { + final double scale; + final double translateScale; + + const WidgetScale({required this.scale, required this.translateScale}); + + static WidgetScale getScale(bool down, double s) { + if (down) { + return WidgetScale( + scale: s * _kShowPressedScale, + translateScale: s * (_kShowPressedScale - 1.0) * 0.5); + } else { + return WidgetScale(scale: s, translateScale: 0.0); + } + } +} + +class _MouseBodyState extends State { + bool _leftDown = false; + bool _rightDown = false; + bool _midDown = false; + bool _dragDown = false; + + Widget _buildScrollUpDown(GlobalKey key, IconData iconData, double s) { + return Container( + key: key, + height: 17 * s, + child: Icon( + iconData, + color: _kDefaultHighlightColor, + size: 14 * s, + ), + ); + } + + Widget _buildScrollMidButton(double s) { + return Listener( + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _midDown = true; + widget.inputModel?.tapDown(MouseButtons.wheel); + }); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) { + setState(() { + _midDown = false; + widget.inputModel?.tapUp(MouseButtons.wheel); + widget.cancelCanvasScroll?.call(); + }); + } + : null, + onPointerCancel: widget.inputModel != null + ? (event) { + setState(() { + _midDown = false; + widget.inputModel?.tapUp(MouseButtons.wheel); + widget.cancelCanvasScroll?.call(); + }); + } + : null, + onPointerMove: widget.onPointerMoveUpdate, + behavior: HitTestBehavior.opaque, + child: Container( + height: 28 * s, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6 * s, + height: 2 * s, + color: _kDefaultHighlightColor, + ), + SizedBox(height: 3 * s), + Container( + width: 8 * s, + height: 2 * s, + color: _kDefaultHighlightColor, + ), + SizedBox(height: 3 * s), + Container( + width: 6 * s, + height: 2 * s, + color: _kDefaultHighlightColor, + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final s = widget.scale; + final leftScale = WidgetScale.getScale(_leftDown, s); + final rightScale = WidgetScale.getScale(_rightDown, s); + final midScale = WidgetScale.getScale(_midDown, s); + return Row( + children: [ + SizedBox( + key: widget.mouseWidgetKey, + width: 80 * s, + height: 120 * s, + child: Column( + children: [ + SizedBox( + height: 55 * s, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Left button + Transform.translate( + offset: Offset( + -(80 - 24) * 0.5 * leftScale.translateScale, + -32 * leftScale.translateScale), + child: SizedBox( + width: (80 - 24) * 0.5 * leftScale.scale, + child: Listener( + onPointerMove: widget.onPointerMoveUpdate, + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _leftDown = true; + widget.inputModel + ?.tapDown(MouseButtons.left); + }); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) => setState(() { + _leftDown = false; + widget.inputModel + ?.tapUp(MouseButtons.left); + widget.cancelCanvasScroll?.call(); + }) + : null, + onPointerCancel: widget.inputModel != null + ? (event) => setState(() { + _leftDown = false; + widget.inputModel + ?.tapUp(MouseButtons.left); + widget.cancelCanvasScroll?.call(); + }) + : null, + child: Container( + decoration: BoxDecoration( + color: _leftDown + ? _kTapDownColor + : _kDefaultColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(22 * s)), + ), + margin: EdgeInsets.only(right: 0.5 * s), + ), + ), + ), + ), + const Spacer(), + Transform.translate( + offset: Offset( + (80 - 24) * 0.5 * rightScale.translateScale, + -32 * rightScale.translateScale), + child: SizedBox( + width: (80 - 24) * 0.5 * rightScale.scale, + child: Listener( + onPointerMove: widget.onPointerMoveUpdate, + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _rightDown = true; + widget.inputModel + ?.tapDown(MouseButtons.right); + }); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) => setState(() { + _rightDown = false; + widget.inputModel + ?.tapUp(MouseButtons.right); + widget.cancelCanvasScroll?.call(); + }) + : null, + onPointerCancel: widget.inputModel != null + ? (event) => setState(() { + _rightDown = false; + widget.inputModel + ?.tapUp(MouseButtons.right); + widget.cancelCanvasScroll?.call(); + }) + : null, + child: Container( + decoration: BoxDecoration( + color: _rightDown + ? _kTapDownColor + : _kDefaultColor, + borderRadius: BorderRadius.only( + topRight: Radius.circular(22 * s)), + ), + margin: EdgeInsets.only(left: 0.5 * s), + ), + ), + ), + ), + ], + ), + // Middle function area overflows Row bottom + Positioned( + left: (80 * s - 22 * s) / 2, + top: 0, + child: Transform.translate( + offset: Offset(0, -2 * s), + child: Container( + width: 22 * s, + height: 67 * s, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.7), + borderRadius: BorderRadius.vertical( + top: Radius.circular(12 * s), + bottom: Radius.circular(16 * s), + ), + ), + padding: EdgeInsets.symmetric(vertical: 2 * s), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildScrollUpDown(widget.scrollWheelUpKey, + Icons.keyboard_arrow_up, midScale.scale), + _buildScrollMidButton(midScale.scale), + _buildScrollUpDown(widget.scrollWheelDownKey, + Icons.keyboard_arrow_down, midScale.scale), + ], + ), + ), + ), + ), + ], + ), + ), + // Thin gap separates upper and lower parts + SizedBox(height: 1 * s), + // Bottom part: drag area (top middle indentation) + Expanded( + child: Listener( + onPointerMove: widget.onPointerMoveUpdate, + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _dragDown = true; + }); + widget.setCanvasScrollPressed?.call(); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) { + setState(() { + _dragDown = false; + }); + widget.setCanvasScrollReleased?.call(); + } + : null, + onPointerCancel: widget.inputModel != null + ? (event) { + setState(() { + _dragDown = false; + }); + widget.setCanvasScrollReleased?.call(); + } + : null, + behavior: HitTestBehavior.opaque, + child: CustomPaint( + painter: DragAreaTopIndentPainter( + color: _dragDown ? _kTapDownColor : _kDefaultColor, + scale: widget.scale), + child: Container( + width: 80 * s, + alignment: Alignment.center, + child: Transform.rotate( + angle: pi / 2, + child: Icon(Icons.drag_indicator, + color: _kDefaultHighlightColor, size: 18 * s), + ), + ), + ), + ), + ), + ], + ), + ), + const Spacer() + ], + ); + } +} + +class DottedCirclePainter extends CustomPainter { + final Offset center; + final double pointerAngle; + final double scale; + final Offset? scrollWheelCenter; + + DottedCirclePainter( + {required this.center, + required this.pointerAngle, + required this.scale, + this.scrollWheelCenter}); + + @override + void paint(Canvas canvas, Size size) { + final radius = 48.0 * scale; + final circlePaint = Paint() + ..color = Colors.grey.shade400 + ..style = PaintingStyle.fill; + final pointerPaint = Paint() + ..color = Colors.blue + ..style = PaintingStyle.fill; + + const dotRadius = 2.5; + for (int i = 0; i < _kDotCount; i += 3) { + final angle = i * _kDotAngle; + final dotX = center.dx + radius * cos(angle); + final dotY = center.dy + radius * sin(angle); + canvas.drawCircle(Offset(dotX, dotY), dotRadius, circlePaint); + } + + final pointerX = center.dx + radius * cos(pointerAngle); + final pointerY = center.dy + radius * sin(pointerAngle); + final pointerPosition = Offset(pointerX, pointerY); + canvas.drawCircle(pointerPosition, 8.0, pointerPaint); + } + + @override + bool shouldRepaint(covariant DottedCirclePainter oldDelegate) { + return oldDelegate.pointerAngle != pointerAngle || + oldDelegate.center != center || + oldDelegate.scrollWheelCenter != scrollWheelCenter; + } +} + +// Painter for the bottom center indentation of the drag area +class BottomIndentPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.withOpacity(0.7) + ..style = PaintingStyle.fill; + // Draw bottom semicircle + final center = Offset(size.width / 2, size.height); + canvas.drawArc( + Rect.fromCenter(center: center, width: size.width, height: size.height), + pi, + pi, + false, + paint, + ); + // Use background color to carve a circular notch in the middle + final clearPaint = Paint()..blendMode = BlendMode.clear; + canvas.drawCircle(Offset(size.width / 2, size.height - 10), 10, clearPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// Painter for the top center indentation of the drag area +class DragAreaTopIndentPainter extends CustomPainter { + final double scale; + final Color color; + DragAreaTopIndentPainter({required this.color, required this.scale}); + + @override + void paint(Canvas canvas, Size size) { + // Use saveLayer to make the hollow part transparent + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + canvas.saveLayer(Offset.zero & size, Paint()); + // Draw drag area main body (rectangle + bottom rounded corners) + final rect = Rect.fromLTWH(0, 0, size.width, size.height); + final rrect = RRect.fromRectAndCorners( + rect, + bottomLeft: Radius.circular(40 * scale), + bottomRight: Radius.circular(40 * scale), + ); + canvas.drawRRect(rrect, paint); + // Use BlendMode.dstOut to carve a smaller semicircular notch at the top center + final clearPaint = Paint()..blendMode = BlendMode.dstOut; + canvas.drawArc( + Rect.fromCenter( + center: Offset(size.width / 2, 0), + width: 25 * scale, + height: 20 * scale), + 0, + pi, + false, + clearPaint, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant DragAreaTopIndentPainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.scale != scale; + } +} + +class CursorPaint extends StatelessWidget { + final double scale; + CursorPaint({super.key, required this.scale}); + + @override + Widget build(BuildContext context) { + final cursorModel = Provider.of(context); + double hotx = cursorModel.hotx; + double hoty = cursorModel.hoty; + var image = cursorModel.image; + if (image == null) { + if (preDefaultCursor.image != null) { + image = preDefaultCursor.image; + hotx = preDefaultCursor.image!.width / 2; + hoty = preDefaultCursor.image!.height / 2; + } + } + if (image == null) { + return const Offstage(); + } + assert(scale > 0, 'scale should always be positive'); + if (scale <= 0) { + return const Offstage(); + } + return CustomPaint( + painter: ImagePainter(image: image, x: -hotx, y: -hoty, scale: scale), + ); + } +} diff --git a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart new file mode 100644 index 000000000..ddb20860c --- /dev/null +++ b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart @@ -0,0 +1,880 @@ +// These floating mouse widgets are used to simulate a physical mouse +// when "mobile" -> "desktop" in mouse mode. +// This file does not contain whole mouse widgets, it only contains +// parts that help to control, such as wheel scroll and wheel button. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/remote_input.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; + +// Used for the wheel button and wheel scroll widgets +const double _kSpaceToHorizontalEdge = 25; +const double _wheelWidth = 50; +const double _wheelHeight = 162; +// Used for the left/right button widgets +const double _kSpaceToVerticalEdge = 15; +const double _kSpaceBetweenLeftRightButtons = 40; +const double _kLeftRightButtonWidth = 55; +const double _kLeftRightButtonHeight = 40; +const double _kBorderWidth = 1; +final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7); +final Color _kDefaultColor = Colors.black.withOpacity(0.4); +final Color _kTapDownColor = Colors.blue.withOpacity(0.7); +final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9); +const int _kInputTimerIntervalMillis = 100; + +class FloatingMouseWidgets extends StatefulWidget { + final FFI ffi; + const FloatingMouseWidgets({ + super.key, + required this.ffi, + }); + + @override + State createState() => _FloatingMouseWidgetsState(); +} + +class _FloatingMouseWidgetsState extends State { + InputModel get _inputModel => widget.ffi.inputModel; + CursorModel get _cursorModel => widget.ffi.cursorModel; + late final VirtualMouseMode _virtualMouseMode; + + @override + void initState() { + super.initState(); + _virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode; + _virtualMouseMode.addListener(_onVirtualMouseModeChanged); + _cursorModel.blockEvents = false; + isSpecialHoldDragActive = false; + } + + void _onVirtualMouseModeChanged() { + if (mounted) { + setState(() {}); + } + } + + @override + void dispose() { + _virtualMouseMode.removeListener(_onVirtualMouseModeChanged); + super.dispose(); + _cursorModel.blockEvents = false; + isSpecialHoldDragActive = false; + } + + @override + Widget build(BuildContext context) { + final virtualMouseMode = _virtualMouseMode; + if (!virtualMouseMode.showVirtualMouse) { + return const Offstage(); + } + return Stack( + children: [ + FloatingWheel( + inputModel: _inputModel, + cursorModel: _cursorModel, + ), + if (virtualMouseMode.showVirtualJoystick) + VirtualJoystick(cursorModel: _cursorModel), + FloatingLeftRightButton( + isLeft: true, + inputModel: _inputModel, + cursorModel: _cursorModel, + ), + FloatingLeftRightButton( + isLeft: false, + inputModel: _inputModel, + cursorModel: _cursorModel, + ), + ], + ); + } +} + +class FloatingWheel extends StatefulWidget { + final InputModel inputModel; + final CursorModel cursorModel; + const FloatingWheel( + {super.key, required this.inputModel, required this.cursorModel}); + + @override + State createState() => _FloatingWheelState(); +} + +class _FloatingWheelState extends State { + Offset _position = Offset.zero; + bool _isInitialized = false; + Rect? _lastBlockedRect; + + bool _isUpDown = false; + bool _isMidDown = false; + bool _isDownDown = false; + + Orientation? _previousOrientation; + + Timer? _scrollTimer; + + InputModel get _inputModel => widget.inputModel; + CursorModel get _cursorModel => widget.cursorModel; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _resetPosition(); + }); + } + + void _resetPosition() { + final size = MediaQuery.of(context).size; + setState(() { + _position = Offset( + size.width - _wheelWidth - _kSpaceToHorizontalEdge, + (size.height - _wheelHeight) / 2, + ); + _isInitialized = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + void _updateBlockedRect() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + final newRect = + Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight); + _cursorModel.addBlockedRect(newRect); + _lastBlockedRect = newRect; + } + + @override + void dispose() { + _scrollTimer?.cancel(); + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentOrientation = MediaQuery.of(context).orientation; + if (_previousOrientation != null && + _previousOrientation != currentOrientation) { + _resetPosition(); + } + _previousOrientation = currentOrientation; + } + + Widget _buildUpDownButton( + void Function(PointerDownEvent) onPointerDown, + void Function(PointerUpEvent) onPointerUp, + void Function(PointerCancelEvent) onPointerCancel, + bool Function() flagGetter, + BorderRadiusGeometry borderRadius, + IconData iconData) { + return Listener( + onPointerDown: onPointerDown, + onPointerUp: onPointerUp, + onPointerCancel: onPointerCancel, + child: Container( + width: _wheelWidth, + height: 55, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _kDefaultColor, + border: Border.all( + color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor, + width: 1), + borderRadius: borderRadius, + ), + child: Icon(iconData, color: _kDefaultBorderColor, size: 32), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return Positioned(child: Offstage()); + } + return Positioned( + left: _position.dx, + top: _position.dy, + child: _buildWidget(context), + ); + } + + Widget _buildWidget(BuildContext context) { + return Container( + width: _wheelWidth, + height: _wheelHeight, + child: Column( + children: [ + _buildUpDownButton( + (event) { + setState(() { + _isUpDown = true; + }); + _startScrollTimer(1); + }, + (event) { + setState(() { + _isUpDown = false; + }); + _stopScrollTimer(); + }, + (event) { + setState(() { + _isUpDown = false; + }); + _stopScrollTimer(); + }, + () => _isUpDown, + BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)), + Icons.keyboard_arrow_up, + ), + Listener( + onPointerDown: (event) { + setState(() { + _isMidDown = true; + }); + _inputModel.tapDown(MouseButtons.wheel); + }, + onPointerUp: (event) { + setState(() { + _isMidDown = false; + }); + _inputModel.tapUp(MouseButtons.wheel); + }, + onPointerCancel: (event) { + setState(() { + _isMidDown = false; + }); + _inputModel.tapUp(MouseButtons.wheel); + }, + child: Container( + width: _wheelWidth, + height: 52, + decoration: BoxDecoration( + color: _kDefaultColor, + border: Border.symmetric( + vertical: BorderSide( + color: + _isMidDown ? _kTapDownColor : _kDefaultBorderColor, + width: _kBorderWidth)), + ), + child: Center( + child: Container( + width: _wheelWidth - 10, + height: _wheelWidth - 10, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 2, + color: _kDefaultBorderColor, + ), + SizedBox(height: 6), + Container( + width: 24, + height: 2, + color: _kDefaultBorderColor, + ), + SizedBox(height: 6), + Container( + width: 18, + height: 2, + color: _kDefaultBorderColor, + ), + ], + ), + ), + ), + ), + ), + ), + _buildUpDownButton( + (event) { + setState(() { + _isDownDown = true; + }); + _startScrollTimer(-1); + }, + (event) { + setState(() { + _isDownDown = false; + }); + _stopScrollTimer(); + }, + (event) { + setState(() { + _isDownDown = false; + }); + _stopScrollTimer(); + }, + () => _isDownDown, + BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)), + Icons.keyboard_arrow_down, + ), + ], + ), + ); + } + + void _startScrollTimer(int direction) { + _scrollTimer?.cancel(); + _inputModel.scroll(direction); + _scrollTimer = Timer.periodic( + Duration(milliseconds: _kInputTimerIntervalMillis), (timer) { + _inputModel.scroll(direction); + }); + } + + void _stopScrollTimer() { + _scrollTimer?.cancel(); + _scrollTimer = null; + } +} + +class FloatingLeftRightButton extends StatefulWidget { + final bool isLeft; + final InputModel inputModel; + final CursorModel cursorModel; + const FloatingLeftRightButton( + {super.key, + required this.isLeft, + required this.inputModel, + required this.cursorModel}); + + @override + State createState() => + _FloatingLeftRightButtonState(); +} + +class _FloatingLeftRightButtonState extends State { + Offset _position = Offset.zero; + bool _isInitialized = false; + bool _isDown = false; + Rect? _lastBlockedRect; + + Orientation? _previousOrientation; + Offset _preSavedPos = Offset.zero; + + // Gesture ambiguity resolution + Timer? _tapDownTimer; + final Duration _pressTimeout = const Duration(milliseconds: 200); + bool _isDragging = false; + + bool get _isLeft => widget.isLeft; + InputModel get _inputModel => widget.inputModel; + CursorModel get _cursorModel => widget.cursorModel; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final currentOrientation = MediaQuery.of(context).orientation; + _previousOrientation = currentOrientation; + _resetPosition(currentOrientation); + }); + } + + @override + void dispose() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + _tapDownTimer?.cancel(); + _trySavePosition(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentOrientation = MediaQuery.of(context).orientation; + if (_previousOrientation == null || + _previousOrientation != currentOrientation) { + _resetPosition(currentOrientation); + } + _previousOrientation = currentOrientation; + } + + double _getOffsetX(double w) { + if (_isLeft) { + return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) * + 0.5; + } else { + return (w + _kSpaceBetweenLeftRightButtons) * 0.5; + } + } + + String _getPositionKey(Orientation ori) { + final strLeftRight = _isLeft ? 'l' : 'r'; + final strOri = ori == Orientation.landscape ? 'l' : 'p'; + return '$strLeftRight$strOri-mouse-btn-pos'; + } + + static Offset? _loadPositionFromString(String s) { + if (s.isEmpty) { + return null; + } + try { + final m = jsonDecode(s); + return Offset(m['x'], m['y']); + } catch (e) { + debugPrintStack(label: 'Failed to load position "$s" $e'); + return null; + } + } + + void _trySavePosition() { + if (_previousOrientation == null) return; + if (((_position - _preSavedPos)).distanceSquared < 0.1) return; + final pos = jsonEncode({ + 'x': _position.dx, + 'y': _position.dy, + }); + bind.setLocalFlutterOption( + k: _getPositionKey(_previousOrientation!), v: pos); + _preSavedPos = _position; + } + + void _restorePosition(Orientation ori) { + final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori)); + final pos = _loadPositionFromString(ps); + if (pos == null) { + final size = MediaQuery.of(context).size; + _position = Offset(_getOffsetX(size.width), + size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight); + } else { + _position = pos; + _preSavedPos = pos; + } + } + + void _resetPosition(Orientation ori) { + setState(() { + _restorePosition(ori); + _isInitialized = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + void _updateBlockedRect() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + final newRect = Rect.fromLTWH(_position.dx, _position.dy, + _kLeftRightButtonWidth, _kLeftRightButtonHeight); + _cursorModel.addBlockedRect(newRect); + _lastBlockedRect = newRect; + } + + void _onMoveUpdateDelta(Offset delta) { + final context = this.context; + final size = MediaQuery.of(context).size; + Offset newPosition = _position + delta; + double minX = _kSpaceToHorizontalEdge; + double minY = _kSpaceToVerticalEdge; + double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge; + double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge; + newPosition = Offset( + newPosition.dx.clamp(minX, maxX), + newPosition.dy.clamp(minY, maxY), + ); + final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) && + isDoubleEqual(newPosition.dy, _position.dy)); + setState(() { + _position = newPosition; + }); + if (isPositionChanged) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + } + + void _onBodyPointerMoveUpdate(PointerMoveEvent event) { + _cursorModel.blockEvents = true; + // If move, it's a drag, not a tap. + _isDragging = true; + // Cancel the timer to prevent it from being recognized as a tap/hold. + _tapDownTimer?.cancel(); + _tapDownTimer = null; + _onMoveUpdateDelta(event.delta); + } + + Widget _buildButtonIcon() { + final double w = _kLeftRightButtonWidth * 0.45; + final double h = _kLeftRightButtonHeight * 0.75; + final double borderRadius = w * 0.5; + final double quarterCircleRadius = borderRadius * 0.9; + return Stack( + children: [ + Container( + width: w, + height: h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225), + color: Colors.white, + ), + ), + Positioned( + left: _isLeft ? quarterCircleRadius * 0.25 : null, + right: _isLeft ? null : quarterCircleRadius * 0.25, + top: quarterCircleRadius * 0.25, + child: CustomPaint( + size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2), + painter: _QuarterCirclePainter( + color: _kDefaultColor, + isLeft: _isLeft, + radius: quarterCircleRadius, + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return Positioned(child: Offstage()); + } + return Positioned( + left: _position.dx, + top: _position.dy, + // We can't use the GestureDetector here, because `onTapDown` may be + // triggered sometimes when dragging. + child: Listener( + onPointerMove: _onBodyPointerMoveUpdate, + onPointerDown: (event) async { + _isDragging = false; + setState(() { + _isDown = true; + }); + // Start a timer. If it fires, it's a hold. + _tapDownTimer?.cancel(); + _tapDownTimer = Timer(_pressTimeout, () { + isSpecialHoldDragActive = true; + () async { + await _cursorModel.syncCursorPosition(); + await _inputModel + .tapDown(_isLeft ? MouseButtons.left : MouseButtons.right); + }(); + _tapDownTimer = null; + }); + }, + onPointerUp: (event) { + _cursorModel.blockEvents = false; + setState(() { + _isDown = false; + }); + // If timer is active, it's a quick tap. + if (_tapDownTimer != null) { + _tapDownTimer!.cancel(); + _tapDownTimer = null; + // Fire tap down and up quickly. + _inputModel + .tapDown(_isLeft ? MouseButtons.left : MouseButtons.right) + .then( + (_) => Future.delayed(const Duration(milliseconds: 50), () { + _inputModel.tapUp( + _isLeft ? MouseButtons.left : MouseButtons.right); + })); + } else { + // If it's not a quick tap, it could be a hold or drag. + // If it was a hold, isSpecialHoldDragActive is true. + if (isSpecialHoldDragActive) { + _inputModel + .tapUp(_isLeft ? MouseButtons.left : MouseButtons.right); + } + } + + if (_isDragging) { + _trySavePosition(); + } + isSpecialHoldDragActive = false; + }, + onPointerCancel: (event) { + _cursorModel.blockEvents = false; + setState(() { + _isDown = false; + }); + _tapDownTimer?.cancel(); + _tapDownTimer = null; + if (isSpecialHoldDragActive) { + _inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right); + } + isSpecialHoldDragActive = false; + if (_isDragging) { + _trySavePosition(); + } + }, + child: Container( + width: _kLeftRightButtonWidth, + height: _kLeftRightButtonHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _kDefaultColor, + border: Border.all( + color: _isDown ? _kTapDownColor : _kDefaultBorderColor, + width: _kBorderWidth), + borderRadius: _isLeft + ? BorderRadius.horizontal( + left: Radius.circular(_kLeftRightButtonHeight * 0.5)) + : BorderRadius.horizontal( + right: Radius.circular(_kLeftRightButtonHeight * 0.5)), + ), + child: _buildButtonIcon(), + ), + ), + ); + } +} + +class _QuarterCirclePainter extends CustomPainter { + final Color color; + final bool isLeft; + final double radius; + _QuarterCirclePainter( + {required this.color, required this.isLeft, required this.radius}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2); + if (isLeft) { + canvas.drawArc(rect, -pi, pi / 2, true, paint); + } else { + canvas.drawArc(rect, -pi / 2, pi / 2, true, paint); + } + } + + @override + 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. +class VirtualJoystick extends StatefulWidget { + final CursorModel cursorModel; + + const VirtualJoystick({super.key, required this.cursorModel}); + + @override + State createState() => _VirtualJoystickState(); +} + +class _VirtualJoystickState extends State { + Offset _position = Offset.zero; + bool _isInitialized = false; + Offset _offset = Offset.zero; + final double _joystickRadius = 50.0; + final double _thumbRadius = 20.0; + final double _moveStep = 3.0; + final double _speed = 1.0; + + // One-shot timer to detect a drag gesture + Timer? _dragStartTimer; + // Periodic timer for continuous movement + Timer? _continuousMoveTimer; + Size? _lastScreenSize; + bool _isPressed = false; + + @override + void initState() { + super.initState(); + widget.cursorModel.blockEvents = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + _lastScreenSize = MediaQuery.of(context).size; + _resetPosition(); + }); + } + + @override + void dispose() { + _stopSendEventTimer(); + widget.cursorModel.blockEvents = false; + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentScreenSize = MediaQuery.of(context).size; + if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) { + _resetPosition(); + } + _lastScreenSize = currentScreenSize; + } + + void _resetPosition() { + final size = MediaQuery.of(context).size; + setState(() { + _position = Offset( + _kSpaceToHorizontalEdge + _joystickRadius, + size.height * 0.5 + _joystickRadius * 1.5, + ); + _isInitialized = true; + }); + } + + Offset _offsetToPanDelta(Offset offset) { + return Offset( + offset.dx / _joystickRadius, + offset.dy / _joystickRadius, + ); + } + + void _stopSendEventTimer() { + _dragStartTimer?.cancel(); + _continuousMoveTimer?.cancel(); + _dragStartTimer = null; + _continuousMoveTimer = null; + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return Positioned(child: Offstage()); + } + return Positioned( + left: _position.dx - _joystickRadius, + top: _position.dy - _joystickRadius, + child: GestureDetector( + onPanStart: (details) { + setState(() { + _isPressed = true; + }); + widget.cursorModel.blockEvents = true; + _updateOffset(details.localPosition); + + // 1. Send a single, small pan event immediately for responsiveness. + // The movement is small for a gentle start. + final initialDelta = _offsetToPanDelta(_offset); + if (initialDelta.distance > 0) { + widget.cursorModel.updatePan(initialDelta, Offset.zero, false); + } + + // 2. Start a one-shot timer to check if the user is holding for a drag. + _dragStartTimer?.cancel(); + _dragStartTimer = Timer(const Duration(milliseconds: 120), () { + // 3. If the timer fires, it's a drag. Start the continuous movement timer. + _continuousMoveTimer?.cancel(); + _continuousMoveTimer = + periodic_immediate(const Duration(milliseconds: 20), () async { + if (_offset != Offset.zero) { + widget.cursorModel.updatePan( + _offsetToPanDelta(_offset) * _moveStep * _speed, + Offset.zero, + false); + } + }); + }); + }, + onPanUpdate: (details) { + _updateOffset(details.localPosition); + }, + onPanEnd: (details) { + setState(() { + _offset = Offset.zero; + _isPressed = false; + }); + widget.cursorModel.blockEvents = false; + + // 4. Critical step: On pan end, cancel all timers. + // If it was a flick, this cancels the drag detection before it fires. + // If it was a drag, this stops the continuous movement. + _stopSendEventTimer(); + }, + child: CustomPaint( + size: Size(_joystickRadius * 2, _joystickRadius * 2), + painter: _JoystickPainter( + _offset, _joystickRadius, _thumbRadius, _isPressed), + ), + ), + ); + } + + void _updateOffset(Offset localPosition) { + final center = Offset(_joystickRadius, _joystickRadius); + final offset = localPosition - center; + final distance = offset.distance; + + if (distance <= _joystickRadius) { + setState(() { + _offset = offset; + }); + } else { + final clampedOffset = offset / distance * _joystickRadius; + setState(() { + _offset = clampedOffset; + }); + } + } +} + +class _JoystickPainter extends CustomPainter { + final Offset _offset; + final double _joystickRadius; + final double _thumbRadius; + final bool _isPressed; + + _JoystickPainter( + this._offset, this._joystickRadius, this._thumbRadius, this._isPressed); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final joystickColor = _kDefaultColor; + final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor; + final thumbColor = _kWidgetHighlightColor; + + final joystickPaint = Paint() + ..color = joystickColor + ..style = PaintingStyle.fill; + + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final thumbPaint = Paint() + ..color = thumbColor + ..style = PaintingStyle.fill; + + // Draw joystick base and border + canvas.drawCircle(center, _joystickRadius, joystickPaint); + canvas.drawCircle(center, _joystickRadius, borderPaint); + + // Draw thumb + final thumbCenter = center + _offset; + canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint); + } + + @override + bool shouldRepaint(covariant _JoystickPainter oldDelegate) { + return oldDelegate._offset != _offset || + oldDelegate._isPressed != _isPressed; + } +} diff --git a/flutter/lib/mobile/widgets/gesture_help.dart b/flutter/lib/mobile/widgets/gesture_help.dart index 5ba696489..30150be5a 100644 --- a/flutter/lib/mobile/widgets/gesture_help.dart +++ b/flutter/lib/mobile/widgets/gesture_help.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/model.dart'; import 'package:toggle_switch/toggle_switch.dart'; class GestureIcons { @@ -35,20 +36,27 @@ typedef OnTouchModeChange = void Function(bool); class GestureHelp extends StatefulWidget { GestureHelp( - {Key? key, required this.touchMode, required this.onTouchModeChange}) + {Key? key, + required this.touchMode, + required this.onTouchModeChange, + required this.virtualMouseMode}) : super(key: key); final bool touchMode; final OnTouchModeChange onTouchModeChange; + final VirtualMouseMode virtualMouseMode; @override - State createState() => _GestureHelpState(touchMode); + State createState() => + _GestureHelpState(touchMode, virtualMouseMode); } class _GestureHelpState extends State { late int _selectedIndex; late bool _touchMode; + final VirtualMouseMode _virtualMouseMode; - _GestureHelpState(bool touchMode) { + _GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode) + : _virtualMouseMode = virtualMouseMode { _touchMode = touchMode; _selectedIndex = _touchMode ? 1 : 0; } @@ -68,31 +76,144 @@ class _GestureHelpState extends State { padding: const EdgeInsets.symmetric(vertical: 12.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ToggleSwitch( - initialLabelIndex: _selectedIndex, - activeFgColor: Colors.white, - inactiveFgColor: Colors.white60, - activeBgColor: [MyTheme.accent], - inactiveBgColor: Theme.of(context).hintColor, - totalSwitches: 2, - minWidth: 150, - fontSize: 15, - iconSize: 18, - labels: [translate("Mouse mode"), translate("Touch mode")], - icons: [Icons.mouse, Icons.touch_app], - onToggle: (index) { - setState(() { - if (_selectedIndex != index) { - _selectedIndex = index ?? 0; - _touchMode = index == 0 ? false : true; - widget.onTouchModeChange(_touchMode); - } - }); - }, + Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ToggleSwitch( + initialLabelIndex: _selectedIndex, + activeFgColor: Colors.white, + inactiveFgColor: Colors.white60, + activeBgColor: [MyTheme.accent], + inactiveBgColor: Theme.of(context).hintColor, + totalSwitches: 2, + minWidth: 150, + fontSize: 15, + iconSize: 18, + labels: [ + translate("Mouse mode"), + translate("Touch mode") + ], + icons: [Icons.mouse, Icons.touch_app], + onToggle: (index) { + setState(() { + if (_selectedIndex != index) { + _selectedIndex = index ?? 0; + _touchMode = index == 0 ? false : true; + widget.onTouchModeChange(_touchMode); + } + }); + }, + ), + Transform.translate( + offset: const Offset(-10.0, 0.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: _virtualMouseMode.showVirtualMouse, + onChanged: (value) async { + if (value == null) return; + await _virtualMouseMode.toggleVirtualMouse(); + setState(() {}); + }, + ), + InkWell( + onTap: () async { + await _virtualMouseMode.toggleVirtualMouse(); + setState(() {}); + }, + child: Text(translate('Show virtual mouse')), + ), + ], + ), + ), + if (_touchMode && _virtualMouseMode.showVirtualMouse) + Padding( + // Indent "Virtual mouse size" + padding: const EdgeInsets.only(left: 24.0), + child: SizedBox( + width: 260, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 0.0, bottom: 0), + child: Text(translate('Virtual mouse size')), + ), + Transform.translate( + offset: Offset(-0.0, -6.0), + child: Row( + children: [ + Padding( + padding: + const EdgeInsets.only(left: 0.0), + child: Text(translate('Small')), + ), + Expanded( + child: Slider( + value: _virtualMouseMode + .virtualMouseScale, + min: 0.8, + max: 1.8, + divisions: 10, + onChanged: (value) { + _virtualMouseMode + .setVirtualMouseScale(value); + setState(() {}); + }, + ), + ), + Padding( + padding: + const EdgeInsets.only(right: 16.0), + child: Text(translate('Large')), + ), + ], + ), + ), + ], + ), + ), + ), + if (!_touchMode && _virtualMouseMode.showVirtualMouse) + Transform.translate( + offset: const Offset(-10.0, -12.0), + child: Padding( + // Indent "Show virtual joystick" + padding: const EdgeInsets.only(left: 24.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: + _virtualMouseMode.showVirtualJoystick, + onChanged: (value) async { + if (value == null) return; + await _virtualMouseMode + .toggleVirtualJoystick(); + setState(() {}); + }, + ), + InkWell( + onTap: () async { + await _virtualMouseMode + .toggleVirtualJoystick(); + setState(() {}); + }, + child: Text( + translate("Show virtual joystick")), + ), + ], + )), + ), + ], + ), ), - const SizedBox(height: 30), Container( child: Wrap( spacing: space, diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 68cd2f501..03a9c7beb 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -766,6 +766,11 @@ class InputModel { command: command); } + static Map getMouseEventMove() => { + 'type': _kMouseEventMove, + 'buttons': 0, + }; + Map _getMouseEvent(PointerEvent evt, String type) { final Map out = {}; @@ -1222,16 +1227,17 @@ class InputModel { return false; } - void handleMouse( + Map? processEventToPeer( Map evt, Offset offset, { bool onExit = false, + bool moveCanvas = true, }) { - if (isViewCamera) return; + if (isViewCamera) return null; double x = offset.dx; double y = max(0.0, offset.dy); if (_checkPeerControlProtected(x, y)) { - return; + return null; } var type = kMouseEventTypeDefault; @@ -1248,7 +1254,7 @@ class InputModel { isMove = true; break; default: - return; + return null; } evt['type'] = type; @@ -1266,9 +1272,10 @@ class InputModel { type, onExit: onExit, buttons: evt['buttons'], + moveCanvas: moveCanvas, ); if (pos == null) { - return; + return null; } if (type != '') { evt['x'] = '0'; @@ -1286,7 +1293,22 @@ class InputModel { kForwardMouseButton: 'forward' }; evt['buttons'] = mapButtons[evt['buttons']] ?? ''; - bind.sessionSendMouse(sessionId: sessionId, msg: json.encode(modify(evt))); + return evt; + } + + Map? handleMouse( + Map evt, + Offset offset, { + bool onExit = false, + bool moveCanvas = true, + }) { + final evtToPeer = + processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas); + if (evtToPeer != null) { + bind.sessionSendMouse( + sessionId: sessionId, msg: json.encode(modify(evtToPeer))); + } + return evtToPeer; } Point? handlePointerDevicePos( @@ -1297,6 +1319,7 @@ class InputModel { String evtType, { bool onExit = false, int buttons = kPrimaryMouseButton, + bool moveCanvas = true, }) { final ffiModel = parent.target!.ffiModel; CanvasCoords canvas = @@ -1325,7 +1348,7 @@ class InputModel { y -= CanvasModel.topToEdge; x -= CanvasModel.leftToEdge; - if (isMove) { + if (isMove && moveCanvas) { parent.target!.canvasModel.moveDesktopMouse(x, y); } diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 066c148e5..3b475fcb1 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -114,6 +114,7 @@ class FfiModel with ChangeNotifier { bool? _secure; bool? _direct; bool _touchMode = false; + late VirtualMouseMode virtualMouseMode; Timer? _timer; var _reconnects = 1; bool _viewOnly = false; @@ -166,6 +167,7 @@ class FfiModel with ChangeNotifier { clear(); sessionId = parent.target!.sessionId; cachedPeerData.permissions = _permissions; + virtualMouseMode = VirtualMouseMode(this); } Rect? globalDisplaysRect() => _getDisplaysRect(_pi.displays, true); @@ -1109,6 +1111,9 @@ class FfiModel with ChangeNotifier { sessionId: sessionId, arg: kOptionTouchMode) != ''; } + if (isMobile) { + virtualMouseMode.loadOptions(); + } if (connType == ConnType.fileTransfer) { parent.target?.fileModel.onReady(); } else if (connType == ConnType.terminal) { @@ -1508,6 +1513,72 @@ class FfiModel with ChangeNotifier { } } +class VirtualMouseMode with ChangeNotifier { + bool _showVirtualMouse = false; + double _virtualMouseScale = 1.0; + bool _showVirtualJoystick = false; + + bool get showVirtualMouse => _showVirtualMouse; + double get virtualMouseScale => _virtualMouseScale; + bool get showVirtualJoystick => _showVirtualJoystick; + + FfiModel ffiModel; + + VirtualMouseMode(this.ffiModel); + + bool _shouldShow() => !ffiModel.isPeerAndroid; + + setShowVirtualMouse(bool b) { + if (b == _showVirtualMouse) return; + if (_shouldShow()) { + _showVirtualMouse = b; + notifyListeners(); + } + } + + setVirtualMouseScale(double s) { + if (s <= 0) return; + if (s == _virtualMouseScale) return; + _virtualMouseScale = s; + bind.mainSetLocalOption(key: kOptionVirtualMouseScale, value: s.toString()); + notifyListeners(); + } + + setShowVirtualJoystick(bool b) { + if (b == _showVirtualJoystick) return; + if (_shouldShow()) { + _showVirtualJoystick = b; + notifyListeners(); + } + } + + void loadOptions() { + _showVirtualMouse = + bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y'; + _virtualMouseScale = double.tryParse( + bind.mainGetLocalOption(key: kOptionVirtualMouseScale)) ?? + 1.0; + _showVirtualJoystick = + bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y'; + notifyListeners(); + } + + Future toggleVirtualMouse() async { + await bind.mainSetLocalOption( + key: kOptionShowVirtualMouse, value: showVirtualMouse ? 'N' : 'Y'); + setShowVirtualMouse( + bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y'); + } + + Future toggleVirtualJoystick() async { + await bind.mainSetLocalOption( + key: kOptionShowVirtualJoystick, + value: showVirtualJoystick ? 'N' : 'Y'); + setShowVirtualJoystick( + bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y'); + } +} + class ImageModel with ChangeNotifier { ui.Image? _image; @@ -2289,9 +2360,25 @@ class CursorModel with ChangeNotifier { Rect? get keyHelpToolsRectToAdjustCanvas => _lastKeyboardIsVisible ? _keyHelpToolsRect : null; - keyHelpToolsVisibilityChanged(Rect? r, bool keyboardIsVisible) { - _keyHelpToolsRect = r; - if (r == null) { + // The blocked rect is used to block the pointer/touch events in the remote page. + final List _blockedRects = []; + // Used in shouldBlock(). + // _blockEvents is a flag to block pointer/touch events on the remote image. + // It is set to true to prevent accidental touch events in the following scenarios: + // 1. In floating mouse mode, when the scroll circle is shown. + // 2. In floating mouse widgets mode, when the left/right buttons are moving. + // 3. In floating mouse widgets mode, when using the virtual joystick. + // When _blockEvents is true, all pointer/touch events are blocked regardless of the contents of _blockedRects. + // _blockedRects contains specific rectangular regions where events are blocked; these are checked when _blockEvents is false. + // In summary: _blockEvents acts as a global block, while _blockedRects provides fine-grained blocking. + bool _blockEvents = false; + List get blockedRects => List.unmodifiable(_blockedRects); + + set blockEvents(bool v) => _blockEvents = v; + + keyHelpToolsVisibilityChanged(Rect? rect, bool keyboardIsVisible) { + _keyHelpToolsRect = rect; + if (rect == null) { _lastIsBlocked = false; } else { // Block the touch event is safe here. @@ -2306,6 +2393,14 @@ class CursorModel with ChangeNotifier { _lastKeyboardIsVisible = keyboardIsVisible; } + addBlockedRect(Rect rect) { + _blockedRects.add(rect); + } + + removeBlockedRect(Rect rect) { + _blockedRects.remove(rect); + } + get lastIsBlocked => _lastIsBlocked; ui.Image? get image => _image; @@ -2372,13 +2467,22 @@ class CursorModel with ChangeNotifier { // mobile Soft keyboard, block touch event from the KeyHelpTools shouldBlock(double x, double y) { + if (_blockEvents) { + return true; + } + final offset = Offset(x, y); + for (final rect in _blockedRects) { + if (isPointInRect(offset, rect)) { + return true; + } + } + + // For help tools rectangle, only block touch event when in touch mode. if (!(parent.target?.ffiModel.touchMode ?? false)) { return false; } - if (_keyHelpToolsRect == null) { - return false; - } - if (isPointInRect(Offset(x, y), _keyHelpToolsRect!)) { + if (_keyHelpToolsRect != null && + isPointInRect(offset, _keyHelpToolsRect!)) { return true; } return false; @@ -2398,6 +2502,10 @@ class CursorModel with ChangeNotifier { return true; } + Future syncCursorPosition() async { + await parent.target?.inputModel.moveMouse(_x, _y); + } + bool isInRemoteRect(Offset offset) { return getRemotePosInRect(offset) != null; } diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 2afdc0b6c..6ba74e5a5 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 18fb3b5b6..e6a023388 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index d72ae1cb1..9b35b8fb0 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 9632bab29..78e070f07 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Control lliscant d'escala personalitzada"), ("Decrease", "Disminueix"), ("Increase", "Augmenta"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index be984b5c1..bcd8b9c71 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "自定义缩放滑块"), ("Decrease", "缩小"), ("Increase", "放大"), + ("Show virtual mouse", "显示虚拟鼠标"), + ("Virtual mouse size", "虚拟鼠标大小"), + ("Small", "小"), + ("Large", "大"), + ("Show virtual joystick", "显示虚拟摇杆"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 3b2c83fe5..7307c4a92 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index ef87a3e38..0270ed4d9 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index b5d9c25ee..9d06adc41 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Schieberegler für benutzerdefinierte Skalierung"), ("Decrease", "Verringern"), ("Increase", "Erhöhen"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 91e2512ef..6d60ff374 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 0b81db30b..dbabe31a4 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index ed4f60cc2..d1d90000c 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -714,5 +714,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Control deslizante de escala personalizada"), ("Decrease", "Disminuir"), ("Increase", "Aumentar"), + ("Preparing for installation ...", ""), + ("Show my cursor", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index ef71cafa5..034040142 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 273f1f7e0..4071371da 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index a10240893..20deff6f0 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 4da384bd3..033c42be7 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Curseur d’échelle personnalisée"), ("Decrease", "Diminuer"), ("Increase", "Augmenter"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 180df0ab7..a76735c10 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 3b6c82f1a..37ab0859a 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 1d657b996..b75bc39ec 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 199edfdf7..4291367ae 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -715,5 +715,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Egyéni méretarány-csúszka"), ("Decrease", "Csökkentés"), ("Increase", "Növelés"), + ("Show my cursor", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 6c84af5e9..357d3229f 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 557298012..e0085b330 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Cursore scala personalizzata"), ("Decrease", "Diminuisci"), ("Increase", "Aumenta"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 9514cae16..add1b638e 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -714,5 +714,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "カスタムスケールのスライダー"), ("Decrease", "縮小"), ("Increase", "拡大"), + ("Show my cursor", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index d7a4f8a17..eb6c872ea 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "사용자 지정 크기 조정 슬라이더"), ("Decrease", "축소"), ("Increase", "확대"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 1edf22078..69eb280a6 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 1cb79317d..8a2992365 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 7450cd1dd..af88fc91b 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 7ca3b2b41..4d503e2a5 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index c5f6fcd79..43e219d74 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Aangepaste schuifregelaar voor schaal"), ("Decrease", "Verlagen"), ("Increase", "Verhogen"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 487cf3bff..6cc97569d 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index bfc85835f..c2991a20d 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Controlo deslizante de escala personalizada"), ("Decrease", "Diminuir"), ("Increase", "Aumentar"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index ad08c58bf..8baad379b 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Controle deslizante de escala personalizada"), ("Decrease", "Diminuir"), ("Increase", "Aumentar"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 1409ff0d8..db2c37f1b 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Glisor pentru scalare personalizată"), ("Decrease", "Micșorează"), ("Increase", "Mărește"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index c518cd77c..892dc94c3 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "Ползунок пользовательского масштаба"), ("Decrease", "Уменьшить"), ("Increase", "Увеличить"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index e0494aa88..66bf55d92 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 6d90eb7f7..958bfb71b 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 569fa9a74..2fb23c5d1 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index ebca62081..83f7e3bdf 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index bba9c8ba2..04729340b 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index b9d37df3d..f4e1057db 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 7d5b2931f..1d161e9c5 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 5d8c32b82..acc82d947 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 9c7f9b16f..b9d1aa2b4 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 40013a26c..d255070d5 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 144d9c706..a20d7613c 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", "自訂縮放滑桿"), ("Decrease", "縮小"), ("Increase", "放大"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 51e577c53..a40c098f4 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 9bd3cc4be..eea8f4400 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -714,5 +714,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Custom scale slider", ""), ("Decrease", ""), ("Increase", ""), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), ].iter().cloned().collect(); }