rust-rustdesk/flutter/lib/mobile/widgets/floating_mouse.dart
fufesou 0f3a03aab7
feat: mobile, virtual mouse (#12911)
* 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>
2025-10-09 08:23:55 +08:00

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),
);
}
}