From 844bf40703a8b3d58d243cd955de04ada0338445 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 02:24:31 +0800 Subject: [PATCH] fix(cursor): align the painted cursor with the remote image --- flutter/lib/desktop/pages/remote_page.dart | 59 ++++---- flutter/lib/utils/image.dart | 8 +- flutter/test/cursor_paint_scale_test.dart | 164 +++++++++++++++++++++ 3 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 flutter/test/cursor_paint_scale_test.dart diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 78612f48e..8e7de894c 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1393,8 +1393,9 @@ class CursorPaint extends StatelessWidget { } } - double cx = c.x; - double cy = c.y; + final imageOffset = _softwareImageOffset(c); + double cx = imageOffset?.dx ?? c.x; + double cy = imageOffset?.dy ?? c.y; if (c.viewStyle.style == kRemoteViewStyleOriginal && c.scrollStyle == ScrollStyle.scrollbar) { final rect = c.parent.target!.ffiModel.rect; @@ -1413,38 +1414,44 @@ class CursorPaint extends StatelessWidget { } } - double x = m.x * c.scale + cx - hotx; - double y = m.y * c.scale + cy - hoty; - double scale = 1.0; - final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal; - if (zoomCursor.value || isViewOriginal) { - x = m.x - hotx + cx / c.scale; - y = m.y - hoty + cy / c.scale; - scale = c.scale; - } else if (!isWindows) { - // Keep the painted cursor the same physical size as the native one - // built by getCursorScale() above, including its min-size clamp. - scale = 1.0 / MediaQuery.devicePixelRatioOf(context); - final image = m.image ?? preDefaultCursor.image; - if (scale != 1.0 && - image != null && - ((image.width * scale).toInt() < kMinCursorSize || - (image.height * scale).toInt() < kMinCursorSize)) { - final sw = kMinCursorSize / image.width; - final sh = kMinCursorSize / image.height; - scale = sw < sh ? sh : sw; - } - x = (m.x * c.scale + cx) / scale - hotx; - y = (m.y * c.scale + cy) / scale - hoty; + final image = m.image ?? preDefaultCursor.image; + final nativePixels = isWindows ? MediaQuery.devicePixelRatioOf(context) : 1.0; + double scale = c.scale; + if (image != null && scale * nativePixels != 1.0) { + final sx = kMinCursorSize / (image.width * nativePixels); + final sy = kMinCursorSize / (image.height * nativePixels); + final minimumScale = sx > sy ? sx : sy; + if (scale < minimumScale) scale = minimumScale; } + final x = (m.x * c.scale + cx) / scale - hotx; + final y = (m.y * c.scale + cy) / scale - hoty; return CustomPaint( painter: ImagePainter( - image: m.image ?? preDefaultCursor.image, + image: image, x: x, y: y, scale: scale, + useIntegerPosition: false, ), ); } + + Offset? _softwareImageOffset(CanvasModel canvas) { + if (canvas.imageOverflow.isTrue && + canvas.scrollStyle != ScrollStyle.scrollauto) { + return null; + } + final ffi = canvas.parent.target!; + final peer = ffi.ffiModel; + if (ffi.imageModel.useTextureRender || peer.pi.forceTextureRender) { + return null; + } + var scale = canvas.scale; + final displays = peer.pi.getCurDisplays(); + if (peer.isPeerLinux && displays.isNotEmpty) scale /= displays[0].scale; + // Match the origin used by _buildScrollAutoNonTextureRender's ImagePainter. + return Offset( + (canvas.x / scale).toInt() * scale, (canvas.y / scale).toInt() * scale); + } } diff --git a/flutter/lib/utils/image.dart b/flutter/lib/utils/image.dart index d5042e61a..4e2fe1e74 100644 --- a/flutter/lib/utils/image.dart +++ b/flutter/lib/utils/image.dart @@ -96,12 +96,14 @@ class ImagePainter extends CustomPainter { required this.x, required this.y, required this.scale, + this.useIntegerPosition = true, }); ui.Image? image; double x; double y; double scale; + final bool useIntegerPosition; @override void paint(Canvas canvas, Size size) { @@ -122,8 +124,10 @@ class ImagePainter extends CustomPainter { if (isWeb) { paint.filterQuality = FilterQuality.high; } - canvas.drawImage( - image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint); + final position = useIntegerPosition + ? Offset(x.toInt().toDouble(), y.toInt().toDouble()) + : Offset(x, y); + canvas.drawImage(image!, position, paint); } @override diff --git a/flutter/test/cursor_paint_scale_test.dart b/flutter/test/cursor_paint_scale_test.dart new file mode 100644 index 000000000..62dd9b404 --- /dev/null +++ b/flutter/test/cursor_paint_scale_test.dart @@ -0,0 +1,164 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/pages/remote_page.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/utils/image.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:provider/provider.dart'; + +const _hotspot = Offset(4, 9); +const _remotePosition = Offset(100.25, 80.75); +const _canvasOffset = Offset(15.125, 10.25); +const _viewport = Size(200, 160); + +class _CursorModel extends ChangeNotifier implements CursorModel { + _CursorModel(this.image); + + @override + final ui.Image image; + @override + double get hotx => _hotspot.dx; + @override + double get hoty => _hotspot.dy; + @override + double get x => _remotePosition.dx; + @override + double get y => _remotePosition.dy; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ImageModel extends Fake implements ImageModel { + _ImageModel(this.useTextureRender); + + @override + final bool useTextureRender; +} + +class _Peer extends Fake implements FfiModel { + @override + final pi = PeerInfo(); + @override + bool get isPeerLinux => false; +} + +class _FFI extends Fake implements FFI { + _FFI(bool useTexture) : imageModel = _ImageModel(useTexture); + + @override + final ImageModel imageModel; + @override + final ffiModel = _Peer(); +} + +class _CanvasModel extends ChangeNotifier implements CanvasModel { + _CanvasModel(String style, this.scale, bool useTexture) + : _ffi = _FFI(useTexture), + viewStyle = ViewStyle( + style: style, + width: _viewport.width, + height: _viewport.height, + displayWidth: _viewport.width.toInt(), + displayHeight: _viewport.height.toInt(), + ); + + final FFI _ffi; + @override + WeakReference get parent => WeakReference(_ffi); + @override + final imageOverflow = false.obs; + @override + final ViewStyle viewStyle; + @override + double get x => _canvasOffset.dx; + @override + double get y => _canvasOffset.dy; + @override + final double scale; + @override + ScrollStyle get scrollStyle => ScrollStyle.scrollauto; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Canvas extends Fake implements Canvas { + double factor = 1; + Offset? position; + + @override + void scale(double sx, [double? sy]) => factor *= sx; + + @override + void drawImage(ui.Image image, Offset offset, Paint paint) { + position = offset * factor; + } +} + +void main() { + for (final (style, zoom, dpr, source, canvasScale, scale, texture) in [ + (kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true), + (kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true), + (kRemoteViewStyleCustom, false, 2.0, (48, 64), 0.25, 0.25, false), + (kRemoteViewStyleCustom, true, 2.0, (48, 64), 2.0, 2.0, false), + (kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false), + ( + kRemoteViewStyleAdaptive, + true, + 2.0, + (9, 18), + 0.1, + Platform.isWindows ? 2 / 3 : 4 / 3, + true + ), + ]) { + testWidgets( + '$style zoom=$zoom dpr=$dpr source=$source texture=$texture keeps remote geometry', + (tester) async { + final image = (await tester.runAsync( + () => createTestImage(width: source.$1, height: source.$2)))!; + addTearDown(image.dispose); + await tester.pumpWidget(MediaQuery( + data: MediaQueryData(devicePixelRatio: dpr), + child: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => _CursorModel(image)), + ChangeNotifierProvider( + create: (_) => _CanvasModel(style, canvasScale, texture)), + ], + child: CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs), + ), + )); + final painter = tester + .widget(find.byType(CustomPaint)) + .painter! as ImagePainter; + + expect(painter.image, same(image)); + expect(painter.scale, scale); + var imageOrigin = _canvasOffset; + if (!texture) { + final background = _Canvas(); + ImagePainter( + image: image, + x: _canvasOffset.dx / canvasScale, + y: _canvasOffset.dy / canvasScale, + scale: canvasScale, + ).paint(background, _viewport); + imageOrigin = background.position!; + } + final target = _remotePosition * canvasScale + imageOrigin; + expect((Offset(painter.x, painter.y) + _hotspot) * scale, target); + final canvas = _Canvas(); + painter.paint(canvas, _viewport); + final position = canvas.position! + _hotspot * canvas.factor; + expect(position.dx, closeTo(target.dx, 1e-9)); + expect(position.dy, closeTo(target.dy, 1e-9)); + }); + } +}