diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 8e7de894c..3abac2056 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1105,17 +1105,22 @@ class _ImagePaintState extends State { // changes, so read it live to follow the window across monitors. final dpr = MediaQuery.devicePixelRatioOf(context); - bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive; + bool isViewScaled() => + c.viewStyle.style == kRemoteViewStyleAdaptive || + c.viewStyle.style == kRemoteViewStyleCustom; bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal; mouseRegion({child}) => Obx(() { double getCursorScale() { - var c = Provider.of(context); + final peerDpr = Provider.of(context).cache?.pixelRatio ?? 0; + if (!isWeb && isViewScaled() && !zoomCursor.value && peerDpr > 0) { + return (isWindows ? dpr : 1.0) / peerDpr; + } var cursorScale = 1.0; if (isWindows) { // debug win10 - if (zoomCursor.value && isViewAdaptive()) { - cursorScale = s * c.devicePixelRatio; + if (zoomCursor.value && isViewScaled()) { + cursorScale = s * dpr; } } else { if (zoomCursor.value || isViewOriginal()) { @@ -1420,7 +1425,9 @@ class CursorPaint extends StatelessWidget { 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; + final minimumScale = c.viewStyle.style == kRemoteViewStyleOriginal + ? (sx > sy ? sx : sy) + : (sx < sy ? sx : sy); if (scale < minimumScale) scale = minimumScale; } final x = (m.x * c.scale + cx) / scale - hotx; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index cd9d935ea..27f5e6224 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2858,6 +2858,8 @@ class CursorData { // Borrowed from CursorModel/PredefinedCursor, which own its lifetime. // The plugin clones the handle before starting asynchronous encoding. final ui.Image nativeImage; + // Zero preserves sizing for peers that predate cursor density metadata. + final double pixelRatio; double scale; Uint8List? data; final double hotxOrigin; @@ -2872,6 +2874,7 @@ class CursorData { required this.id, required this.image, required this.nativeImage, + this.pixelRatio = 0, required this.scale, required this.data, required this.hotxOrigin, @@ -2885,9 +2888,13 @@ class CursorData { // Keep the minimum-size policy here. Native callers let the plugin rasterize // the original ui.Image; Web keeps the encoded-image resizing path. - double _checkUpdateScale(double scale, {bool resizeImage = true}) { + double _checkUpdateScale(double scale, + {bool resizeImage = true, bool useLegacyMinimum = true}) { double oldScale = this.scale; - if (scale != 1.0) { + if (!useLegacyMinimum) { + scale = max(scale, kMinCursorSize / max(width, height)); + } + if (useLegacyMinimum && scale != 1.0) { // Update data if scale changed. final tgtWidth = (width * scale).toInt(); final tgtHeight = (height * scale).toInt(); @@ -2928,8 +2935,10 @@ class CursorData { return scale; } - String updateGetKey(double scale, {bool resizeImage = true}) { - scale = _checkUpdateScale(scale, resizeImage: resizeImage); + String updateGetKey(double scale, + {bool resizeImage = true, bool useLegacyMinimum = true}) { + scale = _checkUpdateScale(scale, + resizeImage: resizeImage, useLegacyMinimum: useLegacyMinimum); return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}'; } } @@ -3436,6 +3445,10 @@ class CursorModel with ChangeNotifier { final hoty = double.parse(evt['hoty']); final width = int.parse(evt['width']); final height = int.parse(evt['height']); + final pixelRatio = double.parse(evt['scale'] ?? '0'); + if (!pixelRatio.isFinite || pixelRatio < 0) { + throw FormatException('Invalid cursor pixel ratio: $pixelRatio'); + } List colors = json.decode(evt['colors']); final rgba = Uint8List.fromList(colors.map((s) => s as int).toList()); final image = await img.decodeImageFromPixels( @@ -3443,9 +3456,12 @@ class CursorModel with ChangeNotifier { if (image == null) { return; } - if (await _updateCache(rgba, image, id, hotx, hoty, width, height)) { + if (await _updateCache(rgba, image, id, hotx, hoty, width, height, + pixelRatio: pixelRatio)) { _images[id]?.item1.dispose(); _images[id] = Tuple3(image, hotx, hoty); + } else { + image.dispose(); } // Update last cursor data. @@ -3460,8 +3476,9 @@ class CursorModel with ChangeNotifier { double hotx, double hoty, int w, - int h, - ) async { + int h, { + required double pixelRatio, + }) async { Uint8List? data; img2.Image imgOrigin = img2.Image.fromBytes( width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba); @@ -3471,6 +3488,7 @@ class CursorModel with ChangeNotifier { ByteData? imgBytes = await image.toByteData(format: ui.ImageByteFormat.png); if (imgBytes == null) { + debugPrint('Unable to encode cursor $id as PNG'); return false; } data = imgBytes.buffer.asUint8List(); @@ -3480,6 +3498,7 @@ class CursorModel with ChangeNotifier { id: id, image: imgOrigin, nativeImage: image, + pixelRatio: pixelRatio, scale: 1.0, data: data, hotxOrigin: hotx, diff --git a/flutter/lib/native/custom_cursor.dart b/flutter/lib/native/custom_cursor.dart index 3358ec0ab..b92c81590 100644 --- a/flutter/lib/native/custom_cursor.dart +++ b/flutter/lib/native/custom_cursor.dart @@ -9,6 +9,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart' show WidgetsBinding; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/models/model.dart'; deleteCustomCursor(String key) => @@ -24,7 +25,11 @@ MouseCursor buildCursorOfCache( // bitmap even when the remote view scale has not changed. final dpr = WidgetsBinding .instance.platformDispatcher.views.single.devicePixelRatio; - final key = '${cache.updateGetKey(scale, resizeImage: false)}_$dpr'; + // Keep Original and older peers unchanged. A long-edge minimum preserves + // the proportions of thin remote cursors when normalizing their DPI. + final legacyMinimum = cache.pixelRatio == 0 || + cursor.parent.target?.canvasModel.viewStyle.style == kRemoteViewStyleOriginal; + final key = '${cache.updateGetKey(scale, resizeImage: false, useLegacyMinimum: legacyMinimum)}_$dpr'; if (!cursor.cachedKeys.contains(key)) { debugPrint( "Register custom cursor with key $key (${cache.hotx},${cache.hoty})"); diff --git a/flutter/test/cursor_dpi_policy_test.dart b/flutter/test/cursor_dpi_policy_test.dart new file mode 100644 index 000000000..d70664814 --- /dev/null +++ b/flutter/test/cursor_dpi_policy_test.dart @@ -0,0 +1,180 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +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'; +import 'package:flutter_hbb/native/custom_cursor.dart' show deleteCustomCursor; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:image/image.dart' as img; +import 'package:provider/provider.dart'; + +const _viewport = Size(200, 160); + +class _Image extends ChangeNotifier implements 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 CanvasModel { + _Canvas(this.devicePixelRatio, {required this.style, required this.scale}); + + final String style; + + @override + final double devicePixelRatio; + @override + final imageOverflow = false.obs; + @override + late final viewStyle = ViewStyle( + style: style, + width: _viewport.width, + height: _viewport.height, + displayWidth: 400, + displayHeight: 320, + ); + @override + bool get cursorEmbedded => false; + @override + Size get size => _viewport; + @override + final double scale; + @override + double get x => 0; + @override + double get y => 0; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Cursor extends ChangeNotifier implements CursorModel { + _Cursor(this.cache, this._ffi); + + final FFI _ffi; + @override + WeakReference get parent => WeakReference(_ffi); + + @override + CursorData cache; + @override + ui.Image? get image => null; + @override + double get hotx => cache.hotxOrigin; + @override + double get hoty => cache.hotyOrigin; + @override + final Set cachedKeys = {}; + @override + void addKey(String key) => cachedKeys.add(key); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Input extends Fake implements InputModel { + @override + final relativeMouseMode = false.obs; +} + +class _Peer extends Fake implements FfiModel { + @override + final pi = PeerInfo(); + @override + bool get isPeerLinux => false; +} + +class _FFI extends Fake implements FFI { + _FFI(this.canvasModel); + + @override + final CanvasModel canvasModel; + @override + final inputModel = _Input(); + @override + final ffiModel = _Peer(); +} + +Future _data(int density, String id) async { + final image = await createTestImage(width: 9 * density, height: 18 * density); + return CursorData( + peerId: 'dpi-policy', + id: id, + image: img.Image(width: image.width, height: image.height, numChannels: 4), + nativeImage: image, + scale: 1, + data: null, + hotxOrigin: 4.0 * density, + hotyOrigin: 9.0 * density, + width: image.width, + height: image.height, + pixelRatio: density.toDouble(), + ); +} + +void main() { + for (final (style, zoom, density, viewScale, expectedScale) in [ + (kRemoteViewStyleAdaptive, false, 1, 0.25, Platform.isWindows ? 2.0 : 1.0), + (kRemoteViewStyleAdaptive, false, 2, 0.25, Platform.isWindows ? 1.0 : 0.5), + (kRemoteViewStyleCustom, false, 2, 0.25, Platform.isWindows ? 1.0 : 0.5), + (kRemoteViewStyleAdaptive, true, 2, 0.25, 1 / 3), + (kRemoteViewStyleCustom, true, 2, 0.25, 1 / 3), + (kRemoteViewStyleOriginal, false, 2, 0.5, Platform.isWindows ? 1.0 : 2 / 3), + ]) { + testWidgets( + '$style zoom=$zoom peerDPR=$density keeps cursor units', + (tester) => tester.runAsync(() async { + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.resetDevicePixelRatio); + final channel = Platform.isWindows + ? SystemChannels.mouseCursor + : const MethodChannel('flutter_custom_cursor'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async => call.method.startsWith('createCustomCursor') + ? (call.arguments as Map)['name'] + : null); + final data = await _data(density, '$style-$zoom-$density'); + // A stale cached DPR must not affect the cursor when the window moves. + final canvas = _Canvas(1, style: style, scale: viewScale); + final ffi = _FFI(canvas); + final cursor = _Cursor(data, ffi); + await tester.pumpWidget(MediaQuery( + data: const MediaQueryData(devicePixelRatio: 2), + child: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => _Image()), + ChangeNotifierProvider.value(value: canvas), + ChangeNotifierProvider.value(value: cursor), + ], + child: ImagePaint( + ffi: ffi, + id: 'dpi-policy', + zoomCursor: zoom.obs, + cursorOverImage: true.obs, + keyboardEnabled: true.obs, + remoteCursorMoved: false.obs, + )), + )); + await tester.pumpWidget(const SizedBox.shrink()); + await Future.wait(cursor.cachedKeys.map((key) async { + await deleteCustomCursor(key); + })); + data.nativeImage.dispose(); + cursor.dispose(); + canvas.dispose(); + tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + expect(data.scale, closeTo(expectedScale, 1e-9)); + expect(data.hotx, closeTo(4 * density * expectedScale, 1e-9)); + expect(data.hoty, closeTo(9 * density * expectedScale, 1e-9)); + })); + } +} diff --git a/flutter/test/cursor_paint_scale_test.dart b/flutter/test/cursor_paint_scale_test.dart index 62dd9b404..bb4632772 100644 --- a/flutter/test/cursor_paint_scale_test.dart +++ b/flutter/test/cursor_paint_scale_test.dart @@ -113,7 +113,7 @@ void main() { 2.0, (9, 18), 0.1, - Platform.isWindows ? 2 / 3 : 4 / 3, + Platform.isWindows ? 1 / 3 : 2 / 3, true ), ]) {