diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index adf7b1d45..597fe2309 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -81,6 +81,8 @@ const String kWindowEventOpenMonitorSession = "open_monitor_session"; const String kOptionViewStyle = "view_style"; const String kOptionScrollStyle = "scroll_style"; const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness"; +const String kOptionRemoteCanvasMargin = "remote-canvas-margin"; +const double kMaxRemoteCanvasMargin = 400.0; const String kOptionImageQuality = "image_quality"; const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs"; const String kOptionTextureRender = "use-texture-render"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index d1d620014..483bd5852 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -1757,6 +1757,7 @@ class _DisplayState extends State<_Display> { return ListView(controller: scrollController, children: [ viewStyle(context), scrollStyle(context), + remoteCanvasMargin(context), imageQuality(context), codec(context), if (isDesktop) trackpadSpeed(context), @@ -1834,6 +1835,30 @@ class _DisplayState extends State<_Display> { ]); } + Widget remoteCanvasMargin(BuildContext context) { + onChanged(double value) async { + final normalizedValue = value.clamp(0, kMaxRemoteCanvasMargin).round(); + await bind.mainSetUserDefaultOption( + key: kOptionRemoteCanvasMargin, value: normalizedValue.toString()); + setState(() {}); + } + + final currentValue = (double.tryParse(bind.mainGetUserDefaultOption( + key: kOptionRemoteCanvasMargin)) ?? + 0) + .clamp(0, kMaxRemoteCanvasMargin) + .toDouble(); + + return _Card(title: 'canvas_margin', children: [ + EdgeThicknessControl( + value: currentValue, + min: 0, + max: kMaxRemoteCanvasMargin, + onChanged: onChanged, + ), + ]); + } + Widget imageQuality(BuildContext context) { onChanged(String value) async { await bind.mainSetUserDefaultOption( diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 29e710bbc..4ec2724bd 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -784,7 +784,7 @@ class _ImagePaintState extends State { m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender ? _BuildPaintTextureRender( c, s, Offset.zero, paintSize, isViewOriginal()) - : _buildScrollbarNonTextureRender(m, paintSize, s); + : _buildScrollbarNonTextureRender(m, c, paintSize, s); return NotificationListener( onNotification: (notification) { c.updateScrollPercent(); @@ -822,10 +822,14 @@ class _ImagePaintState extends State { } Widget _buildScrollbarNonTextureRender( - ImageModel m, Size imageSize, double s) { + ImageModel m, CanvasModel c, Size imageSize, double s) { return CustomPaint( size: imageSize, - painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s), + painter: ImagePainter( + image: m.image, + x: c.displayPaddingX, + y: c.displayPaddingY, + scale: s), ); } @@ -842,8 +846,8 @@ class _ImagePaintState extends State { size: Size(c.size.width, c.size.height), painter: ImagePainter( image: m.image, - x: c.x / sizeScale, - y: c.y / sizeScale, + x: c.x / sizeScale + c.displayPaddingX, + y: c.y / sizeScale + c.displayPaddingY, scale: sizeScale), ); } @@ -853,7 +857,7 @@ class _ImagePaintState extends State { final ffiModel = c.parent.target!.ffiModel; final displays = ffiModel.pi.getCurDisplays(); final children = []; - final rect = ffiModel.rect; + final rect = c.paddedRect; if (rect == null) { return Container(); } @@ -1016,7 +1020,7 @@ class CursorPaint extends StatelessWidget { double cy = c.y; if (c.viewStyle.style == kRemoteViewStyleOriginal && c.scrollStyle == ScrollStyle.scrollbar) { - final rect = c.parent.target!.ffiModel.rect; + final rect = c.paddedRect; if (rect == null) { // unreachable! debugPrint('unreachable! The displays rect is null.'); diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 44a2dc1c7..f87407ab2 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1404,6 +1404,7 @@ class _DisplayMenu extends StatefulWidget { class _DisplayMenuState extends State<_DisplayMenu> { final RxInt _customPercent = 100.obs; + double? _remoteCanvasMarginPreview; late final ScreenAdjustor _screenAdjustor = ScreenAdjustor( id: widget.id, ffi: widget.ffi, @@ -1567,22 +1568,44 @@ class _DisplayMenuState extends State<_DisplayMenu> { return futureBuilder(future: () async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; - final visible = viewStyle == kRemoteViewStyleOriginal || + final scrollVisible = viewStyle == kRemoteViewStyleOriginal || viewStyle == kRemoteViewStyleCustom; final scrollStyle = await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? ''; - final edgeScrollEdgeThickness = await bind - .sessionGetEdgeScrollEdgeThickness(sessionId: ffi.sessionId); + final edgeScrollEdgeThickness = scrollVisible + ? await bind.sessionGetEdgeScrollEdgeThickness( + sessionId: ffi.sessionId) + : null; + await widget.ffi.canvasModel.initializeRemoteCanvasMargin(); return { - 'visible': visible, + 'scrollVisible': scrollVisible, 'scrollStyle': scrollStyle, 'edgeScrollEdgeThickness': edgeScrollEdgeThickness, + 'supportsRemoteCanvasMargin': + widget.ffi.canvasModel.supportsRemoteCanvasMargin, + 'remoteCanvasMargin': widget.ffi.canvasModel.remoteCanvasMargin, }; }(), hasData: (data) { - final visible = data['visible'] as bool; - if (!visible) return Offstage(); + final scrollVisible = data['scrollVisible'] as bool; final groupValue = data['scrollStyle'] as String; - final edgeScrollEdgeThickness = data['edgeScrollEdgeThickness'] as int; + final edgeScrollEdgeThickness = + ((data['edgeScrollEdgeThickness'] as int?) ?? + EdgeThicknessControl.kMin.round()) + .clamp(EdgeThicknessControl.kMin.round(), + EdgeThicknessControl.kMax.round()) + .toInt(); + final supportsRemoteCanvasMargin = + data['supportsRemoteCanvasMargin'] as bool; + final savedRemoteCanvasMargin = data['remoteCanvasMargin'] as double; + final remoteCanvasMargin = + (_remoteCanvasMarginPreview ?? savedRemoteCanvasMargin) + .clamp(0, kMaxRemoteCanvasMargin) + .toDouble(); + final hasVisibleControls = scrollVisible || supportsRemoteCanvasMargin; + + if (!hasVisibleControls) { + return SizedBox.shrink(); + } onChangeScrollStyle(String? value) async { if (value == null) return; @@ -1601,48 +1624,86 @@ class _DisplayMenuState extends State<_DisplayMenu> { state.setState(() {}); } - return Obx(() => Column(children: [ - RdoMenuButton( - child: Text(translate('ScrollAuto')), - value: kRemoteScrollStyleAuto, - groupValue: groupValue, - onChanged: widget.ffi.canvasModel.imageOverflow.value - ? (value) => onChangeScrollStyle(value) - : null, - closeOnActivate: groupValue != kRemoteScrollStyleEdge, - ffi: widget.ffi, - ), - RdoMenuButton( - child: Text(translate('Scrollbar')), - value: kRemoteScrollStyleBar, - groupValue: groupValue, - onChanged: widget.ffi.canvasModel.imageOverflow.value - ? (value) => onChangeScrollStyle(value) - : null, - closeOnActivate: groupValue != kRemoteScrollStyleEdge, - ffi: widget.ffi, - ), - if (!isWeb) ...[ - RdoMenuButton( - child: Text(translate('ScrollEdge')), - value: kRemoteScrollStyleEdge, + onChangeRemoteCanvasMargin(double? value) async { + if (value == null) return; + _remoteCanvasMarginPreview = + value.clamp(0, kMaxRemoteCanvasMargin).toDouble(); + state.setState(() {}); + } + + onChangeRemoteCanvasMarginEnd(double value) async { + _remoteCanvasMarginPreview = + value.clamp(0, kMaxRemoteCanvasMargin).toDouble(); + await widget.ffi.canvasModel.setRemoteCanvasMargin(value); + _remoteCanvasMarginPreview = null; + state.setState(() {}); + } + + return Column(children: [ + if (scrollVisible) ...[ + Obx(() => RdoMenuButton( + child: Text(translate('ScrollAuto')), + value: kRemoteScrollStyleAuto, groupValue: groupValue, - closeOnActivate: false, onChanged: widget.ffi.canvasModel.imageOverflow.value ? (value) => onChangeScrollStyle(value) : null, + closeOnActivate: groupValue != kRemoteScrollStyleEdge, ffi: widget.ffi, - ), - Offstage( - offstage: groupValue != kRemoteScrollStyleEdge, + )), + Obx(() => RdoMenuButton( + child: Text(translate('Scrollbar')), + value: kRemoteScrollStyleBar, + groupValue: groupValue, + onChanged: widget.ffi.canvasModel.imageOverflow.value + ? (value) => onChangeScrollStyle(value) + : null, + closeOnActivate: groupValue != kRemoteScrollStyleEdge, + ffi: widget.ffi, + )), + if (!isWeb) ...[ + Obx(() => RdoMenuButton( + child: Text(translate('ScrollEdge')), + value: kRemoteScrollStyleEdge, + groupValue: groupValue, + closeOnActivate: false, + onChanged: widget.ffi.canvasModel.imageOverflow.value + ? (value) => onChangeScrollStyle(value) + : null, + ffi: widget.ffi, + )), + Offstage( + offstage: groupValue != kRemoteScrollStyleEdge, + child: EdgeThicknessControl( + value: edgeScrollEdgeThickness.toDouble(), + onChanged: onChangeEdgeScrollEdgeThickness, + colorScheme: colorScheme, + )), + ], + ], + if (supportsRemoteCanvasMargin) ...[ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Expanded(child: Text(translate('canvas_margin'))), + SizedBox( + width: 160, child: EdgeThicknessControl( - value: edgeScrollEdgeThickness.toDouble(), - onChanged: onChangeEdgeScrollEdgeThickness, + value: remoteCanvasMargin, + min: 0, + max: kMaxRemoteCanvasMargin, + onChanged: onChangeRemoteCanvasMargin, + onChangeEnd: onChangeRemoteCanvasMarginEnd, colorScheme: colorScheme, - )), - ], - Divider(), - ])); + ), + ), + ], + ), + ), + ], + Divider(), + ]); }); } @@ -3370,13 +3431,21 @@ Widget _buildPointerTrackWidget(Widget child, FFI? ffi) { class EdgeThicknessControl extends StatelessWidget { final double value; final ValueChanged? onChanged; + final ValueChanged? onChangeEnd; final ColorScheme? colorScheme; + final double min; + final double max; + final String unit; const EdgeThicknessControl({ Key? key, required this.value, this.onChanged, + this.onChangeEnd, this.colorScheme, + this.min = kMin, + this.max = kMax, + this.unit = 'px', }) : super(key: key); static const double kMin = 20; @@ -3393,25 +3462,25 @@ class EdgeThicknessControl extends StatelessWidget { overlayColor: colorScheme.primary.withOpacity(0.1), showValueIndicator: ShowValueIndicator.never, thumbShape: _RectValueThumbShape( - min: EdgeThicknessControl.kMin, - max: EdgeThicknessControl.kMax, + min: min, + max: max, width: 52, height: 24, radius: 4, - unit: 'px', + unit: unit, ), ), child: Semantics( value: value.toInt().toString(), child: Slider( value: value, - min: EdgeThicknessControl.kMin, - max: EdgeThicknessControl.kMax, - divisions: - (EdgeThicknessControl.kMax - EdgeThicknessControl.kMin).round(), + min: min, + max: max, + divisions: (max - min).round(), semanticFormatterCallback: (double newValue) => - "${newValue.round()}px", + "${newValue.round()}$unit", onChanged: onChanged, + onChangeEnd: onChangeEnd, ), ), ); diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 984d6a25c..c8d371fc2 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -34,6 +34,10 @@ class CanvasCoords { double scale = 1.0; double scrollX = 0; double scrollY = 0; + double displayWidth = 0; + double displayHeight = 0; + double paddingX = 0; + double paddingY = 0; ScrollStyle scrollStyle = ScrollStyle.scrollauto; Size size = Size.zero; @@ -46,6 +50,10 @@ class CanvasCoords { 'scale': scale, 'scrollX': scrollX, 'scrollY': scrollY, + 'displayWidth': displayWidth, + 'displayHeight': displayHeight, + 'paddingX': paddingX, + 'paddingY': paddingY, 'scrollStyle': scrollStyle.toJson(), 'size': { 'w': size.width, @@ -56,14 +64,22 @@ class CanvasCoords { static CanvasCoords fromJson(Map json) { final model = CanvasCoords(); - model.x = json['x']; - model.y = json['y']; - model.scale = json['scale']; - model.scrollX = json['scrollX']; - model.scrollY = json['scrollY']; + model.x = (json['x'] ?? 0).toDouble(); + model.y = (json['y'] ?? 0).toDouble(); + model.scale = (json['scale'] ?? 1).toDouble(); + model.scrollX = (json['scrollX'] ?? 0).toDouble(); + model.scrollY = (json['scrollY'] ?? 0).toDouble(); + model.displayWidth = (json['displayWidth'] ?? 0).toDouble(); + model.displayHeight = (json['displayHeight'] ?? 0).toDouble(); + model.paddingX = (json['paddingX'] ?? 0).toDouble(); + model.paddingY = (json['paddingY'] ?? 0).toDouble(); model.scrollStyle = ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto); - model.size = Size(json['size']['w'], json['size']['h']); + final sizeMap = json['size']; + model.size = Size( + (sizeMap?['w'] ?? 0).toDouble(), + (sizeMap?['h'] ?? 0).toDouble(), + ); return model; } @@ -74,6 +90,10 @@ class CanvasCoords { coords.scale = model.scale; coords.scrollX = model.scrollX; coords.scrollY = model.scrollY; + coords.displayWidth = model.getDisplayWidth().toDouble(); + coords.displayHeight = model.getDisplayHeight().toDouble(); + coords.paddingX = model.displayPaddingX; + coords.paddingY = model.displayPaddingY; coords.scrollStyle = model.scrollStyle; coords.size = model.size; return coords; @@ -1625,7 +1645,8 @@ class InputModel { if (e is PointerScrollEvent) { final rawDx = e.scrollDelta.dx; final rawDy = e.scrollDelta.dy; - final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs(); + final dominantDelta = + rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs(); final isSmooth = dominantDelta < 1; final nowUs = DateTime.now().microsecondsSinceEpoch; final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs; @@ -1972,8 +1993,12 @@ class InputModel { final nearThr = 3; var nearRight = (canvas.size.width - x) < nearThr; var nearBottom = (canvas.size.height - y) < nearThr; - final imageWidth = rect.width * canvas.scale; - final imageHeight = rect.height * canvas.scale; + final displayWidth = + canvas.displayWidth > 0 ? canvas.displayWidth : rect.width; + final displayHeight = + canvas.displayHeight > 0 ? canvas.displayHeight : rect.height; + final imageWidth = displayWidth * canvas.scale; + final imageHeight = displayHeight * canvas.scale; if (canvas.scrollStyle != ScrollStyle.scrollauto) { x += imageWidth * canvas.scrollX; y += imageHeight * canvas.scrollY; @@ -2001,8 +2026,19 @@ class InputModel { y += step; } } - x += rect.left; - y += rect.top; + final paddedX = x; + final paddedY = y; + x = paddedX - canvas.paddingX + rect.left; + y = paddedY - canvas.paddingY + rect.top; + + final insidePaddedRect = paddedX >= 0 && + paddedY >= 0 && + paddedX <= displayWidth && + paddedY <= displayHeight; + if (insidePaddedRect) { + x = x.clamp(rect.left, rect.right).toDouble(); + y = y.clamp(rect.top, rect.bottom).toDouble(); + } if (onExit) { final pos = setNearestEdge(x, y, rect); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index e94834a2b..11ac8167e 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2154,6 +2154,8 @@ class CanvasModel with ChangeNotifier { ScrollStyle _scrollStyle = ScrollStyle.scrollauto; // edge scroll mode: trigger scrolling when the cursor is close to the edge of the view int _edgeScrollEdgeThickness = 100; + double _remoteCanvasMargin = 0; + bool _remoteCanvasMarginInitialized = false; // tracks whether edge scroll should be active, prevents spurious // scrolling when the cursor enters the view from outside EdgeScrollState _edgeScrollState = EdgeScrollState.inactive; @@ -2191,6 +2193,86 @@ class CanvasModel with ChangeNotifier { ScrollStyle get scrollStyle => _scrollStyle; ViewStyle get viewStyle => _lastViewStyle; RxBool get imageOverflow => _imageOverflow; + Rect? get realRect => parent.target?.ffiModel.rect; + + double get remoteCanvasMargin { + if (!supportsRemoteCanvasMargin) { + return 0; + } + return _remoteCanvasMargin; + } + + bool get supportsRemoteCanvasMargin => + (isDesktop || isWebDesktop) && + parent.target?.connType == ConnType.defaultConn; + + Future setRemoteCanvasMargin(double value) async { + if (!supportsRemoteCanvasMargin) { + return; + } + final normalizedValue = value.clamp(0, kMaxRemoteCanvasMargin).round(); + await bind.sessionSetFlutterOption( + sessionId: sessionId, + k: kOptionRemoteCanvasMargin, + v: normalizedValue.toString()); + _remoteCanvasMargin = normalizedValue.toDouble(); + _remoteCanvasMarginInitialized = true; + await updateViewStyle(); + } + + Future initializeRemoteCanvasMargin() async { + if (_remoteCanvasMarginInitialized || !supportsRemoteCanvasMargin) { + return; + } + final sessionValue = await bind.sessionGetFlutterOption( + sessionId: sessionId, k: kOptionRemoteCanvasMargin); + if (_remoteCanvasMarginInitialized || !supportsRemoteCanvasMargin) { + return; + } + final defaultValue = + bind.mainGetUserDefaultOption(key: kOptionRemoteCanvasMargin); + final value = + sessionValue?.isNotEmpty == true ? sessionValue : defaultValue; + _remoteCanvasMargin = (double.tryParse(value ?? '') ?? 0) + .clamp(0, kMaxRemoteCanvasMargin) + .toDouble(); + _remoteCanvasMarginInitialized = true; + } + + Rect? get paddedRect { + final rect = realRect; + if (rect == null) { + return null; + } + final margin = remoteCanvasMargin; + if (margin <= 0) { + return rect; + } + return Rect.fromLTRB( + rect.left - margin, + rect.top - margin, + rect.right + margin, + rect.bottom + margin, + ); + } + + double get displayPaddingX { + final padded = paddedRect; + final rect = realRect; + if (padded == null || rect == null) { + return 0; + } + return rect.left - padded.left; + } + + double get displayPaddingY { + final padded = paddedRect; + final rect = realRect; + if (padded == null || rect == null) { + return 0; + } + return rect.top - padded.top; + } _resetScroll() => setScrollPercent(0.0, 0.0); @@ -2275,6 +2357,7 @@ class CanvasModel with ChangeNotifier { return; } + await initializeRemoteCanvasMargin(); updateSize(); final displayWidth = getDisplayWidth(); final displayHeight = getDisplayHeight(); @@ -2396,14 +2479,14 @@ class CanvasModel with ChangeNotifier { final defaultWidth = (isDesktop || isWebDesktop) ? kDesktopDefaultDisplayWidth : kMobileDefaultDisplayWidth; - return parent.target?.ffiModel.rect?.width.toInt() ?? defaultWidth; + return paddedRect?.width.toInt() ?? defaultWidth; } int getDisplayHeight() { final defaultHeight = (isDesktop || isWebDesktop) ? kDesktopDefaultDisplayHeight : kMobileDefaultDisplayHeight; - return parent.target?.ffiModel.rect?.height.toInt() ?? defaultHeight; + return paddedRect?.height.toInt() ?? defaultHeight; } static double get windowBorderWidth => stateGlobal.windowBorderWidth.value; @@ -2653,6 +2736,8 @@ class CanvasModel with ChangeNotifier { _y = 0; _scale = 1.0; _lastViewStyle = ViewStyle.defaultViewStyle(); + _remoteCanvasMargin = 0; + _remoteCanvasMarginInitialized = false; _timerMobileFocusCanvasCursor?.cancel(); _timerMobileRestoreCanvasOffset?.cancel(); _offsetBeforeMobileSoftKeyboard = null; @@ -2986,8 +3071,10 @@ class CursorModel with ChangeNotifier { ui.Image? get image => _image; CursorData? get cache => _cache; - double get x => _x - _displayOriginX; - double get y => _y - _displayOriginY; + double get x => + _x - _displayOriginX + (parent.target?.canvasModel.displayPaddingX ?? 0); + double get y => + _y - _displayOriginY + (parent.target?.canvasModel.displayPaddingY ?? 0); double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio; @@ -3024,8 +3111,12 @@ class CursorModel with ChangeNotifier { final xoffset = parent.target?.canvasModel.x ?? 0; final yoffset = parent.target?.canvasModel.y ?? 0; final scale = parent.target?.canvasModel.scale ?? 1; - final x0 = _displayOriginX - xoffset / scale; - final y0 = _displayOriginY - yoffset / scale; + final x0 = _displayOriginX - + (parent.target?.canvasModel.displayPaddingX ?? 0) - + xoffset / scale; + final y0 = _displayOriginY - + (parent.target?.canvasModel.displayPaddingY ?? 0) - + yoffset / scale; return Rect.fromLTWH(x0, y0, size.width / scale, size.height / scale); } @@ -3036,10 +3127,14 @@ class CursorModel with ChangeNotifier { // See `getVisibleRect()` // _x = _displayOriginX - xoffset / scale + size.width / scale * 0.5; // _y = _displayOriginY - yoffset / scale + size.height / scale * 0.5; + final displayOriginX = + _displayOriginX - (parent.target?.canvasModel.displayPaddingX ?? 0); + final displayOriginY = + _displayOriginY - (parent.target?.canvasModel.displayPaddingY ?? 0); final size = parent.target?.canvasModel.getSize() ?? MediaQueryData.fromView(ui.window).size; - final xoffset = (_displayOriginX - _x) * scale + size.width * 0.5; - final yoffset = (_displayOriginY - _y) * scale + size.height * 0.5; + final xoffset = (displayOriginX - _x) * scale + size.width * 0.5; + final yoffset = (displayOriginY - _y) * scale + size.height * 0.5; return Offset(xoffset, yoffset); } @@ -3155,11 +3250,10 @@ class CursorModel with ChangeNotifier { var cx = r.center.dx; var cy = r.center.dy; var tryMoveCanvasX = false; - final displayRect = parent.target?.ffiModel.rect; + final displayRect = parent.target?.canvasModel.paddedRect; if (dx > 0) { - final maxCanvasCanMove = _displayOriginX + - (displayRect?.width ?? 1280) - - r.right.roundToDouble(); + final maxCanvasCanMove = + (displayRect?.right ?? 1280) - r.right.roundToDouble(); tryMoveCanvasX = _x + dx > cx && maxCanvasCanMove > 0; if (tryMoveCanvasX) { dx = min(dx, maxCanvasCanMove); @@ -3168,7 +3262,8 @@ class CursorModel with ChangeNotifier { dx = min(dx, maxCursorCanMove); } } else if (dx < 0) { - final maxCanvasCanMove = _displayOriginX - r.left.roundToDouble(); + final maxCanvasCanMove = + (displayRect?.left ?? 0) - r.left.roundToDouble(); tryMoveCanvasX = _x + dx < cx && maxCanvasCanMove < 0; if (tryMoveCanvasX) { dx = max(dx, maxCanvasCanMove); @@ -3179,9 +3274,8 @@ class CursorModel with ChangeNotifier { } var tryMoveCanvasY = false; if (dy > 0) { - final mayCanvasCanMove = _displayOriginY + - (displayRect?.height ?? 720) - - r.bottom.roundToDouble(); + final mayCanvasCanMove = + (displayRect?.bottom ?? 720) - r.bottom.roundToDouble(); tryMoveCanvasY = _y + dy > cy && mayCanvasCanMove > 0; if (tryMoveCanvasY) { dy = min(dy, mayCanvasCanMove); @@ -3190,7 +3284,7 @@ class CursorModel with ChangeNotifier { dy = min(dy, mayCursorCanMove); } } else if (dy < 0) { - final mayCanvasCanMove = _displayOriginY - r.top.roundToDouble(); + final mayCanvasCanMove = (displayRect?.top ?? 0) - r.top.roundToDouble(); tryMoveCanvasY = _y + dy < cy && mayCanvasCanMove < 0; if (tryMoveCanvasY) { dy = max(dy, mayCanvasCanMove); @@ -3201,6 +3295,8 @@ class CursorModel with ChangeNotifier { } if (dx == 0 && dy == 0) return; + final canvasDx = dx; + final canvasDy = dy; Point? newPos; final rect = parent.target?.ffiModel.rect; @@ -3218,17 +3314,25 @@ class CursorModel with ChangeNotifier { rect, buttons: kPrimaryButton); if (newPos == null) { + if (tryMoveCanvasX && canvasDx != 0) { + parent.target?.canvasModel.panX(-canvasDx * scale); + } + if (tryMoveCanvasY && canvasDy != 0) { + parent.target?.canvasModel.panY(-canvasDy * scale); + } return; } dx = newPos.x - _x; dy = newPos.y - _y; _x = newPos.x; _y = newPos.y; - if (tryMoveCanvasX && dx != 0) { - parent.target?.canvasModel.panX(-dx * scale); + final panDx = dx != 0 ? dx : canvasDx; + final panDy = dy != 0 ? dy : canvasDy; + if (tryMoveCanvasX && panDx != 0) { + parent.target?.canvasModel.panX(-panDx * scale); } - if (tryMoveCanvasY && dy != 0) { - parent.target?.canvasModel.panY(-dy * scale); + if (tryMoveCanvasY && panDy != 0) { + parent.target?.canvasModel.panY(-panDy * scale); } parent.target?.inputModel.moveMouse(_x, _y); diff --git a/flutter/test/canvas_margin_test.dart b/flutter/test/canvas_margin_test.dart new file mode 100644 index 000000000..6206e9665 --- /dev/null +++ b/flutter/test/canvas_margin_test.dart @@ -0,0 +1,271 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _nearEdgeThreshold = 3.0; + +// Minimal test fixtures for the pure canvas-margin math. These intentionally +// cover only the fields used by pointer mapping. +enum ScrollStyle { scrollbar, scrollauto } + +class CanvasCoords { + double x = 0; + double y = 0; + double scale = 1.0; + double scrollX = 0; + double scrollY = 0; + double displayWidth = 0; + double displayHeight = 0; + double paddingX = 0; + double paddingY = 0; + ScrollStyle scrollStyle = ScrollStyle.scrollauto; + Size size = Size.zero; +} + +double clampMargin(double value) { + return min(kMaxRemoteCanvasMargin, max(0.0, value)); +} + +int normalizeMarginForStorage(double value) { + return value.clamp(0, kMaxRemoteCanvasMargin).round(); +} + +Rect? computePaddedRect(Rect? realRect, double margin) { + if (realRect == null) return null; + if (margin <= 0) return realRect; + return Rect.fromLTRB( + realRect.left - margin, + realRect.top - margin, + realRect.right + margin, + realRect.bottom + margin, + ); +} + +double computeDisplayPaddingX(Rect? paddedRect, Rect? realRect) { + if (paddedRect == null || realRect == null) return 0; + return realRect.left - paddedRect.left; +} + +double computeDisplayPaddingY(Rect? paddedRect, Rect? realRect) { + if (paddedRect == null || realRect == null) return 0; + return realRect.top - paddedRect.top; +} + +double computeAdaptiveScale({ + required double viewWidth, + required double viewHeight, + required int displayWidth, + required int displayHeight, +}) { + if (viewWidth == 0 || + viewHeight == 0 || + displayWidth == 0 || + displayHeight == 0) { + return 1.0; + } + return min(viewWidth / displayWidth, viewHeight / displayHeight); +} + +(double, double) computeCanvasOffset( + Size viewSize, int displayWidth, int displayHeight, double scale) { + final x = (viewSize.width - displayWidth * scale) / 2; + final y = (viewSize.height - displayHeight * scale) / 2; + return (x, y); +} + +(double, double)? computePointerPosition({ + required double pointerX, + required double pointerY, + required CanvasCoords canvas, + required Rect remoteRect, +}) { + double x = pointerX; + double y = pointerY; + + final nearRight = (canvas.size.width - x) < _nearEdgeThreshold; + final nearBottom = (canvas.size.height - y) < _nearEdgeThreshold; + final displayWidth = + canvas.displayWidth > 0 ? canvas.displayWidth : remoteRect.width; + final displayHeight = + canvas.displayHeight > 0 ? canvas.displayHeight : remoteRect.height; + final imageWidth = displayWidth * canvas.scale; + final imageHeight = displayHeight * canvas.scale; + + if (canvas.scrollStyle != ScrollStyle.scrollauto) { + x += imageWidth * canvas.scrollX; + y += imageHeight * canvas.scrollY; + + if (canvas.size.width > imageWidth) { + x -= ((canvas.size.width - imageWidth) / 2); + } + if (canvas.size.height > imageHeight) { + y -= ((canvas.size.height - imageHeight) / 2); + } + } else { + x -= canvas.x; + y -= canvas.y; + } + + x /= canvas.scale; + y /= canvas.scale; + if (canvas.scale > 0 && canvas.scale < 1) { + final step = 1.0 / canvas.scale - 1; + if (nearRight) { + x += step; + } + if (nearBottom) { + y += step; + } + } + + final paddedX = x; + final paddedY = y; + x = paddedX - canvas.paddingX + remoteRect.left; + y = paddedY - canvas.paddingY + remoteRect.top; + + final insidePaddedRect = paddedX >= 0 && + paddedY >= 0 && + paddedX <= displayWidth && + paddedY <= displayHeight; + if (insidePaddedRect) { + x = x.clamp(remoteRect.left, remoteRect.right).toDouble(); + y = y.clamp(remoteRect.top, remoteRect.bottom).toDouble(); + } + + return (x, y); +} + +void main() { + group('Remote canvas margin math', () { + test('clamps and normalizes margin values', () { + expect(clampMargin(-10), 0); + expect(clampMargin(50), 50); + expect(clampMargin(999), kMaxRemoteCanvasMargin); + + expect(normalizeMarginForStorage(50.7), 51); + expect(normalizeMarginForStorage(-5), 0); + expect(normalizeMarginForStorage(999), kMaxRemoteCanvasMargin.toInt()); + }); + + test('expands remote rect and derives display padding', () { + final realRect = Rect.fromLTWH(0, 0, 1920, 1080); + final paddedRect = computePaddedRect(realRect, 100)!; + + expect(paddedRect, Rect.fromLTRB(-100, -100, 2020, 1180)); + expect(computeDisplayPaddingX(paddedRect, realRect), 100); + expect(computeDisplayPaddingY(paddedRect, realRect), 100); + }); + + test('margin-expanded display affects adaptive scale and centering', () { + final realRect = Rect.fromLTWH(0, 0, 1920, 1080); + final paddedRect = computePaddedRect(realRect, 100)!; + final displayWidth = paddedRect.width.toInt(); + final displayHeight = paddedRect.height.toInt(); + final scale = computeAdaptiveScale( + viewWidth: 1920, + viewHeight: 1080, + displayWidth: displayWidth, + displayHeight: displayHeight, + ); + final (x, y) = computeCanvasOffset( + Size(1920, 1080), displayWidth, displayHeight, scale); + + expect(scale, closeTo(1080 / 1280, 0.0001)); + expect(x, closeTo((1920 - 2120 * scale) / 2, 0.01)); + expect(y, closeTo(0, 0.01)); + }); + }); + + group('Pointer coordinate transforms', () { + test('no margin maps pointer directly in scrollauto mode', () { + final canvas = CanvasCoords() + ..scale = 1.0 + ..displayWidth = 1920 + ..displayHeight = 1080 + ..size = Size(1920, 1080); + final remoteRect = Rect.fromLTWH(0, 0, 1920, 1080); + + final result = computePointerPosition( + pointerX: 960, pointerY: 540, canvas: canvas, remoteRect: remoteRect); + + expect(result, isNotNull); + expect(result!.$1, closeTo(960, 0.01)); + expect(result.$2, closeTo(540, 0.01)); + }); + + test('margin padding offsets pointer coordinates', () { + final canvas = CanvasCoords() + ..displayWidth = 2120 + ..displayHeight = 1280 + ..paddingX = 100 + ..paddingY = 100 + ..scale = 1.0 + ..size = Size(2120, 1280); + final remoteRect = Rect.fromLTWH(0, 0, 1920, 1080); + + final result = computePointerPosition( + pointerX: 100, pointerY: 100, canvas: canvas, remoteRect: remoteRect); + + expect(result, isNotNull); + expect(result!.$1, closeTo(0, 0.01)); + expect(result.$2, closeTo(0, 0.01)); + }); + + test('pointer in margin area clamps to remote rect boundary', () { + final canvas = CanvasCoords() + ..displayWidth = 2120 + ..displayHeight = 1280 + ..paddingX = 100 + ..paddingY = 100 + ..scale = 1.0 + ..size = Size(2120, 1280); + final remoteRect = Rect.fromLTWH(0, 0, 1920, 1080); + + final result = computePointerPosition( + pointerX: 50, pointerY: 50, canvas: canvas, remoteRect: remoteRect); + + expect(result, isNotNull); + expect(result!.$1, closeTo(0, 0.01)); + expect(result.$2, closeTo(0, 0.01)); + }); + + test('margin and adaptive scale map view center to remote center', () { + final scale = 1080.0 / 2360; + final canvas = CanvasCoords() + ..displayWidth = 4040 + ..displayHeight = 2360 + ..paddingX = 100 + ..paddingY = 100 + ..scale = scale + ..x = (1920 - 4040 * scale) / 2 + ..y = (1080 - 2360 * scale) / 2 + ..size = Size(1920, 1080); + final remoteRect = Rect.fromLTWH(0, 0, 3840, 2160); + + final result = computePointerPosition( + pointerX: 960, pointerY: 540, canvas: canvas, remoteRect: remoteRect); + + expect(result, isNotNull); + expect(result!.$1, closeTo(1920, 1)); + expect(result.$2, closeTo(1080, 1)); + }); + + test('zoomed-out near edge applies edge correction', () { + final canvas = CanvasCoords() + ..scale = 0.5 + ..displayWidth = 200 + ..displayHeight = 200 + ..size = Size(100, 100); + final remoteRect = Rect.fromLTWH(0, 0, 200, 200); + + final result = computePointerPosition( + pointerX: 99, pointerY: 99, canvas: canvas, remoteRect: remoteRect); + + expect(result, isNotNull); + expect(result!.$1, closeTo(199, 0.01)); + expect(result.$2, closeTo(199, 0.01)); + }); + }); +} diff --git a/src/lang/cn.rs b/src/lang/cn.rs index a90e5e194..58ad58773 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "阻止用户输入"), ("Unblock user input", "取消阻止用户输入"), ("Adjust Window", "调节窗口"), + ("canvas_margin", "画布边距"), ("Original", "原始比例"), ("Shrink", "收缩"), ("Stretch", "伸展"), diff --git a/src/lang/da.rs b/src/lang/da.rs index c9d3b4eb0..8ee3d88bf 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Bloker brugerinput"), ("Unblock user input", "Fjern blokering af brugerinput"), ("Adjust Window", "Juster vinduet"), + ("canvas_margin", "Lærredsmargen"), ("Original", "Original"), ("Shrink", "Krymp"), ("Stretch", "Stræk ud"), diff --git a/src/lang/de.rs b/src/lang/de.rs index e6233e91e..52949bbc9 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Benutzereingaben blockieren"), ("Unblock user input", "Benutzereingaben freigeben"), ("Adjust Window", "Fenster anpassen"), + ("canvas_margin", "Leinwandrand"), ("Original", "Original"), ("Shrink", "Verkleinern"), ("Stretch", "Strecken"), diff --git a/src/lang/en.rs b/src/lang/en.rs index 595169b8a..3eb7346cd 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -99,6 +99,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Control Actions", "Control actions"), ("Display Settings", "Display settings"), ("Image Quality", "Image quality"), + ("canvas_margin", "Canvas margin"), ("Scroll Style", "Scroll style"), ("Show Toolbar", "Show toolbar"), ("Hide Toolbar", "Hide toolbar"), diff --git a/src/lang/es.rs b/src/lang/es.rs index 5e73b58a8..244a100f5 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Bloquear entrada de usuario"), ("Unblock user input", "Desbloquear entrada de usuario"), ("Adjust Window", "Ajustar ventana"), + ("canvas_margin", "Margen del lienzo"), ("Original", "Original"), ("Shrink", "Encoger"), ("Stretch", "Estirar"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index f8283685b..d985288e6 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Estä käyttäjän toiminta"), ("Unblock user input", "Salli käyttäjän toiminta"), ("Adjust Window", "Sovita ikkuna"), + ("canvas_margin", "Piirtoalueen marginaali"), ("Original", "Alkuperäinen"), ("Shrink", "Pienennä"), ("Stretch", "Venytä"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index f21d9b0df..3d9bd32bb 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Bloquer la saisie de l’utilisateur"), ("Unblock user input", "Débloquer la saisie de l’utilisateur"), ("Adjust Window", "Ajuster la fenêtre"), + ("canvas_margin", "Marge du canevas"), ("Original", "Ratio d'origine"), ("Shrink", "Rétrécir"), ("Stretch", "Étirer"), diff --git a/src/lang/it.rs b/src/lang/it.rs index a5132e027..a3498e55d 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Blocca input utente"), ("Unblock user input", "Sblocca input utente"), ("Adjust Window", "Adatta finestra"), + ("canvas_margin", "Margine della tela"), ("Original", "Originale"), ("Shrink", "Restringi"), ("Stretch", "Allarga"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 2879e86bf..6d63253ef 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "ユーザーの入力をブロック"), ("Unblock user input", "ユーザーの入力を許可"), ("Adjust Window", "ウィンドウを調整"), + ("canvas_margin", "キャンバス余白"), ("Original", "オリジナル"), ("Shrink", "縮小"), ("Stretch", "伸縮"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 350d570b0..2cb6c708a 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "사용자 입력 차단"), ("Unblock user input", "사용자 입력 차단 해제"), ("Adjust Window", "창 크기 조정"), + ("canvas_margin", "캔버스 여백"), ("Original", "원본"), ("Shrink", "축소"), ("Stretch", "늘이기"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 9325dfa1f..d1ba7c0dc 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Blokker brukerinput"), ("Unblock user input", "Fjern blokkering av brukerinput"), ("Adjust Window", "Juster vinduet"), + ("canvas_margin", "Lerretsmarg"), ("Original", "Original"), ("Shrink", "Krymp"), ("Stretch", "Strekk ut"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 55d272666..e205c9666 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Gebruikersinvoer blokkeren"), ("Unblock user input", "Gebruikersinvoer deblokkeren"), ("Adjust Window", "Venster Aanpassen"), + ("canvas_margin", "Canvasmarge"), ("Original", "Origineel"), ("Shrink", "Verkleinen"), ("Stretch", "Uitrekken"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index fdf4ae8c5..ac12799fd 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Blokuj peryferia użytkownika"), ("Unblock user input", "Odblokuj peryferia użytkownika"), ("Adjust Window", "Dostosuj okno"), + ("canvas_margin", "Margines płótna"), ("Original", "Oryginalny"), ("Shrink", "Zmniejsz"), ("Stretch", "Rozciągnij"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 4138b46e4..4a3d950a3 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Bloquear entrada de utilizador"), ("Unblock user input", "Desbloquear entrada de utilizador"), ("Adjust Window", "Ajustar Janela"), + ("canvas_margin", "Margem do ecrã"), ("Original", "Original"), ("Shrink", "Reduzir"), ("Stretch", "Aumentar"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 1428a71d0..eff607228 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "Bloquear entrada de usuário"), ("Unblock user input", "Desbloquear entrada de usuário"), ("Adjust Window", "Ajustar Janela"), + ("canvas_margin", "Margem da tela"), ("Original", "Original"), ("Shrink", "Reduzir"), ("Stretch", "Aumentar"), diff --git a/src/lang/template.rs b/src/lang/template.rs index 33b359c5e..152d20669 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", ""), ("Unblock user input", ""), ("Adjust Window", ""), + ("canvas_margin", ""), ("Original", ""), ("Shrink", ""), ("Stretch", ""), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 6df025303..5532c0854 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -117,6 +117,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Block user input", "封鎖使用者輸入"), ("Unblock user input", "取消封鎖使用者輸入"), ("Adjust Window", "調整視窗"), + ("canvas_margin", "畫布邊距"), ("Original", "原始"), ("Shrink", "縮減"), ("Stretch", "延展"),