mirror of
https://github.com/rustdesk/rustdesk.git
synced 2025-11-28 16:50:24 +08:00
* feat: mobile, virtual mouse Signed-off-by: fufesou <linlong1266@gmail.com> * feat: mobile, virtual mouse, mouse mode Signed-off-by: fufesou <linlong1266@gmail.com> * refact: mobile, virtual mouse, mouse mode Signed-off-by: fufesou <linlong1266@gmail.com> * feat: mobile, virtual mouse mode Signed-off-by: fufesou <linlong1266@gmail.com> * feat: mobile virtual mouse, options Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
1209 lines
40 KiB
Dart
1209 lines
40 KiB
Dart
// 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<String, dynamic>? 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<FloatingMouse> 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<String, dynamic>? 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<FloatingMouse> {
|
|
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<String, dynamic>? 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<MouseBody> 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<MouseBody> {
|
|
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<CursorModel>(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),
|
|
);
|
|
}
|
|
}
|