diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 76d04faf1..cd9d935ea 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2855,6 +2855,9 @@ class CursorData { final String peerId; final String id; final img2.Image image; + // Borrowed from CursorModel/PredefinedCursor, which own its lifetime. + // The plugin clones the handle before starting asynchronous encoding. + final ui.Image nativeImage; double scale; Uint8List? data; final double hotxOrigin; @@ -2868,6 +2871,7 @@ class CursorData { required this.peerId, required this.id, required this.image, + required this.nativeImage, required this.scale, required this.data, required this.hotxOrigin, @@ -2879,7 +2883,9 @@ class CursorData { int _doubleToInt(double v) => (v * 10e6).round().toInt(); - double _checkUpdateScale(double scale) { + // 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 oldScale = this.scale; if (scale != 1.0) { // Update data if scale changed. @@ -2892,7 +2898,7 @@ class CursorData { } } - if (_doubleToInt(oldScale) != _doubleToInt(scale)) { + if (resizeImage && _doubleToInt(oldScale) != _doubleToInt(scale)) { if (isWindows) { data = img2 .copyResize( @@ -2922,8 +2928,8 @@ class CursorData { return scale; } - String updateGetKey(double scale) { - scale = _checkUpdateScale(scale); + String updateGetKey(double scale, {bool resizeImage = true}) { + scale = _checkUpdateScale(scale, resizeImage: resizeImage); return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}'; } } @@ -2976,9 +2982,10 @@ class PredefinedCursor { // This function is called only one time, no need to care about the performance. Uint8List data = defaultImg.getBytes(order: img2.ChannelOrder.rgba); _image?.dispose(); - _image = await img.decodeImageFromPixels( + final nativeImage = await img.decodeImageFromPixels( data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888); - if (_image == null) { + _image = nativeImage; + if (nativeImage == null) { print("decodeImageFromPixels failed, pre-defined cursor $id"); return; } @@ -2993,6 +3000,7 @@ class PredefinedCursor { peerId: '', id: id, image: _image2!.clone(), + nativeImage: nativeImage, scale: scale, data: data, hotxOrigin: @@ -3471,6 +3479,7 @@ class CursorModel with ChangeNotifier { peerId: peerId, id: id, image: imgOrigin, + nativeImage: image, scale: 1.0, data: data, hotxOrigin: hotx, diff --git a/flutter/lib/native/custom_cursor.dart b/flutter/lib/native/custom_cursor.dart index e85d42a55..3358ec0ab 100644 --- a/flutter/lib/native/custom_cursor.dart +++ b/flutter/lib/native/custom_cursor.dart @@ -1,10 +1,14 @@ +import 'dart:async'; + import 'package:flutter_custom_cursor/cursor_manager.dart' as custom_cursor_manager; import 'package:flutter_custom_cursor/flutter_custom_cursor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show WidgetsBinding; +import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/model.dart'; deleteCustomCursor(String key) => @@ -16,27 +20,32 @@ MouseCursor buildCursorOfCache( if (cache == null) { return MouseCursor.defer; } else { - final key = cache.updateGetKey(scale); + // Include the live DPR so moving between monitors rebuilds the native + // 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'; if (!cursor.cachedKeys.contains(key)) { - // data should be checked here, because it may be changed after `updateGetKey()` - final data = cache.data; - if (data == null) { - return MouseCursor.defer; - } debugPrint( "Register custom cursor with key $key (${cache.hotx},${cache.hoty})"); - // [Safety] - // It's ok to call async registerCursor in current synchronous context, - // because activating the cursor is also an async call and will always - // be executed after this. - custom_cursor_manager.CursorManager.instance - .registerCursor(custom_cursor_manager.CursorData() - ..name = key - ..buffer = data - ..width = (cache.width * cache.scale).toInt() - ..height = (cache.height * cache.scale).toInt() - ..hotX = cache.hotx - ..hotY = cache.hoty); + unawaited(custom_cursor_manager.CursorManager.instance + .registerCursorImage( + name: key, + image: cache.nativeImage, + hotSpot: Offset(cache.hotxOrigin, cache.hotyOrigin), + // Windows callers already express scale in physical pixels. + // The plugin takes logical scale and applies DPR during rasterization. + scale: isWindows ? cache.scale / dpr : cache.scale, + devicePixelRatio: dpr, + ) + .then((_) {}, onError: (Object error, StackTrace stack) { + cursor.cachedKeys.remove(key); + FlutterError.reportError(FlutterErrorDetails( + exception: error, + stack: stack, + library: 'native cursor', + context: ErrorDescription('registering cursor $key'))); + })); cursor.addKey(key); } return FlutterCustomMemoryImageCursor(key: key); diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 1ac46ac7a..678112667 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -522,9 +522,9 @@ packages: dependency: "direct main" description: path: "." - ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e - resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e - url: "https://github.com/rustdesk-org/flutter_custom_cursor" + ref: aa9e68a38b2efad7411d39ddb1a762cb2c6170eb + resolved-ref: aa9e68a38b2efad7411d39ddb1a762cb2c6170eb + url: "https://github.com/fufesou/flutter_custom_cursor" source: git version: "0.0.3" flutter_gpu_texture_renderer: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 7a2162c12..894d0738b 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -62,8 +62,8 @@ dependencies: freezed_annotation: ^2.0.3 flutter_custom_cursor: git: - url: https://github.com/rustdesk-org/flutter_custom_cursor - ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e + url: https://github.com/fufesou/flutter_custom_cursor + ref: aa9e68a38b2efad7411d39ddb1a762cb2c6170eb window_size: git: url: https://github.com/21pages/flutter-desktop-embedding.git diff --git a/flutter/test/cursor_plugin_test.dart b/flutter/test/cursor_plugin_test.dart new file mode 100644 index 000000000..b9fee90dd --- /dev/null +++ b/flutter/test/cursor_plugin_test.dart @@ -0,0 +1,108 @@ +// These tests drive the lifecycle normally owned by MouseTracker. +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/native/custom_cursor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +class _CursorModel implements CursorModel { + @override + final Set cachedKeys = {}; + @override + void addKey(String key) => cachedKeys.add(key); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future _data() async { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawColor(const ui.Color(0xff123456), ui.BlendMode.src); + final picture = recorder.endRecording(); + final nativeImage = await picture.toImage(20, 30); + picture.dispose(); + addTearDown(nativeImage.dispose); + return CursorData( + peerId: 'plugin', + id: 'edit', + image: img.Image(width: 20, height: 30, numChannels: 4), + nativeImage: nativeImage, + scale: 1, + data: Uint8List.fromList([1, 2]), + hotxOrigin: 4, + hotyOrigin: 16, + width: 20, + height: 30); +} + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final view = binding.platformDispatcher.views.single; + final channel = Platform.isWindows + ? SystemChannels.mouseCursor + : const MethodChannel('flutter_custom_cursor'); + late List> registrations; + setUp(() { + registrations = []; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, + (call) async { + if (call.method.startsWith('createCustomCursor')) { + final args = call.arguments as Map; + registrations.add(args); + return args['name']; + } + return null; + }); + }); + tearDown(() { + view.resetDevicePixelRatio(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('native bridge delegates rasterization and retains the minimum policy', + () => _checkRasterization(view, registrations)); + test('live DPR changes invalidate a cached native cursor', + () => _checkDprChange(view, registrations)); +} + +Future _checkRasterization( + TestFlutterView view, List> registrations) async { + view.devicePixelRatio = 2; + final data = await _data(); + final originalBytes = data.data; + final cursor = _CursorModel(); + final session = buildCursorOfCache(cursor, 0.5, data).createSession(1); + await session.activate(); + session.dispose(); + final args = registrations.single; + final pixelsPerUnit = Platform.isWindows ? 1 : 2; + expect(data.scale, 0.6); + expect(identical(data.data, originalBytes), isTrue); + expect(args['height'], 18 * pixelsPerUnit); + expect(args['width'], (Platform.isLinux ? 18 : 12) * pixelsPerUnit); + expect(args['imagePixelRatio'], 2.0); + expect(args['hotX'], Platform.isMacOS ? 4.8 : 2 * pixelsPerUnit); + expect(args['hotY'], Platform.isMacOS ? 19.2 : 10 * pixelsPerUnit); + await deleteCustomCursor(args['name'] as String); +} + +Future _checkDprChange( + TestFlutterView view, List> registrations) async { + final data = await _data(); + final cursor = _CursorModel(); + for (final dpr in [2.0, 1.0]) { + view.devicePixelRatio = dpr; + final session = buildCursorOfCache(cursor, 1, data).createSession(1); + await session.activate(); + session.dispose(); + } + expect(registrations, hasLength(2)); + expect(registrations[0]['name'], isNot(registrations[1]['name'])); + for (final args in registrations) { + await deleteCustomCursor(args['name'] as String); + } +}