From d65faca21f2dde064a612ac10aa7a907376afc22 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 15:42:47 +0800 Subject: [PATCH] fix(cursor): preserve unzoomed Web size across browser DPRs --- flutter/lib/desktop/pages/remote_page.dart | 2 +- flutter/test/cursor_web_test.dart | 139 +++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index e10a9c15d..1ecf86ed0 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1127,7 +1127,7 @@ class _ImagePaintState extends State { } else { if (zoomCursor.value || isViewOriginal()) { cursorScale = s; - } else { + } else if (!isWeb) { // NSCursor and GdkCursor treat the bitmap size as logical // pixels, so an unzoomed cursor must be shrunk by the DPR to // keep 1 remote px == 1 physical px, the size Original view diff --git a/flutter/test/cursor_web_test.dart b/flutter/test/cursor_web_test.dart index 8e0dbf32a..8bee7182d 100644 --- a/flutter/test/cursor_web_test.dart +++ b/flutter/test/cursor_web_test.dart @@ -9,10 +9,16 @@ import 'dart:js' as js; import 'dart:typed_data'; 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/input_model.dart'; import 'package:flutter_hbb/models/model.dart' as model; import 'package:flutter_hbb/web/custom_cursor.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; import 'package:image/image.dart' as img; +import 'package:provider/provider.dart'; class _CursorModel extends Fake implements model.CursorModel { @override @@ -23,6 +29,12 @@ class _CursorModel extends Fake implements model.CursorModel { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) { + for (final dpr in [1.0, 2.0]) { + testWidgets('ImagePaint Web $style zoom off DPR $dpr keeps source size', + (tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr))); + } + } test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async { Map? registered; final original = js.context['setByName']; @@ -71,3 +83,130 @@ model.CursorData _data(ui.Image nativeImage, (double, double) hotspot) { height: 48, ); } + +class _Image extends ChangeNotifier implements model.ImageModel { + @override + bool get useTextureRender => false; + @override + ui.Image? get image => null; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Canvas extends ChangeNotifier implements model.CanvasModel { + _Canvas(String style) + : viewStyle = model.ViewStyle( + style: style, + width: 200, + height: 160, + displayWidth: 400, + displayHeight: 320); + @override + final model.ViewStyle viewStyle; + @override + final imageOverflow = false.obs; + @override + bool get cursorEmbedded => false; + @override + Size get size => const Size(200, 160); + @override + double get scale => 0.5; + @override + double get x => 0; + @override + double get y => 0; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Input extends Fake implements InputModel { + @override + final relativeMouseMode = false.obs; +} + +class _Peer extends Fake implements model.FfiModel { + @override + final pi = model.PeerInfo(); + @override + bool get isPeerLinux => false; +} + +class _FFI extends Fake implements model.FFI { + _FFI(this.canvasModel); + @override + final model.CanvasModel canvasModel; + @override + final inputModel = _Input(); + @override + final ffiModel = _Peer(); +} + +Future _loadCursor(model.FFI ffi, String id) async { + final cursor = model.CursorModel(WeakReference(ffi))..id = id; + await cursor.updateCursorData({ + 'id': id, + 'width': '48', + 'height': '48', + 'hotx': '7', + 'hoty': '9', + 'scale': '1', + 'colors': jsonEncode(List.filled(48 * 48 * 4, 255)), + }); + addTearDown(() async { + for (final key in cursor.cachedKeys) { + await deleteCustomCursor(key); + } + cursor.disposeImages(); + cursor.dispose(); + }); + return cursor; +} + +Future _checkPolicy(WidgetTester tester, String style, double dpr) async { + final originals = { + for (final key in ['isMobile', 'getByName', 'setByName']) + key: js.context[key] + }; + js.context['isMobile'] = js.allowInterop(() => false); + js.context['getByName'] = js.allowInterop((String name, String value) => ''); + Map? registered; + js.context['setByName'] = js.allowInterop((String name, String value) { + if (name == 'cursor') registered = jsonDecode(value); + }); + addTearDown(() { + for (final entry in originals.entries) { + js.context[entry.key] = entry.value; + } + }); + final canvas = _Canvas(style); + addTearDown(canvas.dispose); + final ffi = _FFI(canvas); + final cursor = await _loadCursor(ffi, '$style-$dpr'); + await tester.pumpWidget(MediaQuery( + data: MediaQueryData(devicePixelRatio: dpr), + child: MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => _Image()), + ChangeNotifierProvider.value(value: canvas), + ChangeNotifierProvider.value(value: cursor), + ], + child: ImagePaint( + ffi: ffi, + id: 'web-cursor-test', + zoomCursor: false.obs, + cursorOverImage: true.obs, + keyboardEnabled: true.obs, + remoteCursorMoved: false.obs)), + )); + final session = tester + .widget(find.byType(MouseRegion)) + .cursor + .createSession(1); + await session.activate(); + session.dispose(); + await tester.pumpWidget(const SizedBox.shrink()); + final png = + img.decodePng(Uri.parse(registered!['url']).data!.contentAsBytes())!; + expect((png.width, png.height), (48, 48)); + expect((registered!['hotx'], registered!['hoty']), (7, 9)); +}