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