diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 255913ee7..f1f89f5e2 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -3463,8 +3463,12 @@ class CursorModel with ChangeNotifier { pixelRatio > 0 && parent.target?.ffiModel.pi.platform == kPeerPlatformMacOS) { // macOS cursors with density metadata use premultiplied alpha; older - // hosts send straight alpha. Keep native decoding and old hosts unchanged. + // hosts send straight alpha. Web needs a straight-alpha resize source. (rgba, image) = await _decodeWebMacCursor(rgba, width, height); + } else if (!isWeb && + pixelRatio == 0 && + parent.target?.ffiModel.pi.platform == kPeerPlatformMacOS) { + image = await _decodeLegacyMacCursor(rgba, width, height); } else { image = await img.decodeImageFromPixels( rgba, width, height, ui.PixelFormat.rgba8888); @@ -3485,6 +3489,22 @@ class CursorModel with ChangeNotifier { _updateCurData(); } + Future _decodeLegacyMacCursor( + Uint8List rgba, int width, int height) async { + // Old macOS packets are straight alpha. Premultiply a copy for native + // ui.Image, retaining the original colors for the separate byte cache. + final source = img2.Image.fromBytes( + width: width, height: height, bytes: rgba.buffer, order: img2.ChannelOrder.rgba); + for (final pixel in source) { + final opacity = pixel.a / pixel.maxChannelValue; + pixel.r = (pixel.r * opacity).round(); + pixel.g = (pixel.g * opacity).round(); + pixel.b = (pixel.b * opacity).round(); + } + return img.decodeImageFromPixels( + source.getBytes(), width, height, ui.PixelFormat.rgba8888); + } + Future<(Uint8List, ui.Image)> _decodeWebMacCursor( Uint8List rgba, int width, int height) async { final source = img2.Image.fromBytes( diff --git a/flutter/test/cursor_native_alpha_test.dart b/flutter/test/cursor_native_alpha_test.dart new file mode 100644 index 000000000..916765981 --- /dev/null +++ b/flutter/test/cursor_native_alpha_test.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/consts.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; + +const _side = 32; +const _hotspot = Offset(7, 9); +const _colors = [ + [64, 32, 16, 128], + [240, 100, 20, 255], + [0, 0, 0, 0], + [0, 0, 0, 128], +]; +final _stripeWidth = _side ~/ _colors.length; + +class _Canvas extends Fake implements CanvasModel { + @override + final viewStyle = ViewStyle( + style: kRemoteViewStyleAdaptive, + width: 200, + height: 160, + displayWidth: 200, + displayHeight: 160); +} + +class _Peer extends Fake implements FfiModel { + _Peer(String platform) : pi = (PeerInfo()..platform = platform); + @override + final PeerInfo pi; +} + +class _FFI extends Fake implements FFI { + _FFI(String platform) : ffiModel = _Peer(platform); + @override + final canvasModel = _Canvas(); + @override + final _Peer ffiModel; +} + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final view = binding.platformDispatcher.views.single; + final channel = Platform.isWindows + ? SystemChannels.mouseCursor + : const MethodChannel('flutter_custom_cursor'); + final registrations = >[]; + setUp(() { + registrations.clear(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, + (call) async { + if (!call.method.startsWith('createCustomCursor')) return null; + final args = call.arguments as Map; + registrations.add(args); + return args['name']; + }); + }); + tearDown(() { + view.resetDevicePixelRatio(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + for (final testCase in <(String, String?, List)>[ + (kPeerPlatformMacOS, null, [64, 32, 16, 128]), + (kPeerPlatformMacOS, '0', [64, 32, 16, 128]), + (kPeerPlatformMacOS, '1', [32, 16, 8, 128]), + (kPeerPlatformMacOS, '2', [32, 16, 8, 128]), + (kPeerPlatformLinux, '0', [32, 16, 8, 128]), + ]) { + for (final dpr in [1.0, 2.0]) { + test( + 'native ${testCase.$1} scale=${testCase.$2} DPR=$dpr preserves alpha', + () { + view.devicePixelRatio = dpr; + return _checkCursor(testCase, dpr, registrations); + }); + } + } +} + +Future _checkCursor((String, String?, List) testCase, double dpr, + List> registrations) async { + final (platform, density, color) = testCase; + final ffi = _FFI(platform); + final cursor = CursorModel(WeakReference(ffi))..id = '$testCase-$dpr'; + addTearDown(() { + cursor.disposeImages(); + cursor.dispose(); + }); + final palette = [color, ..._colors.skip(1)]; + final rgba = [ + for (var y = 0; y < _side; y++) + for (var x = 0; x < _side; x++) ...palette[x ~/ _stripeWidth] + ]; + await cursor.updateCursorData({ + 'id': '$testCase-$dpr', + 'width': '$_side', + 'height': '$_side', + 'hotx': '${_hotspot.dx}', + 'hoty': '${_hotspot.dy}', + if (density != null) 'scale': density, + 'colors': jsonEncode(rgba), + }); + final cache = cursor.cache!; + buildCursorOfCache(cursor, Platform.isWindows ? dpr : 1, cache); + await deleteCustomCursor(cursor.cachedKeys.single); + expect(cache.image.getBytes(), rgba); // Keep the original byte-cache source. + _checkRegistration(registrations.single, dpr); + // The same ui.Image also supplies the painted remote cursor. + final straight = await cache.nativeImage + .toByteData(format: ui.ImageByteFormat.rawStraightRgba); + _checkColors(img.Image.fromBytes( + width: _side, + height: _side, + bytes: straight!.buffer, + bytesOffset: straight.offsetInBytes, + order: img.ChannelOrder.rgba)); +} + +void _checkRegistration(Map args, double dpr) { + final bytes = args['buffer'] as Uint8List; + final decoded = Platform.isWindows + ? img.Image.fromBytes( + width: args['width'] as int, + height: args['height'] as int, + bytes: bytes.buffer, + bytesOffset: bytes.offsetInBytes, + order: img.ChannelOrder.bgra) + : img.decodePng(bytes)!; + expect((decoded.width, decoded.height), (_side * dpr, _side * dpr)); + expect((args['hotX'], args['hotY']), (_hotspot.dx * dpr, _hotspot.dy * dpr)); + _checkColors(decoded); +} + +void _checkColors(img.Image bitmap) { + for (var stripe = 0; stripe < _colors.length; stripe++) { + final pixel = bitmap.getPixel( + ((stripe + 0.5) * bitmap.width / _colors.length).floor(), + bitmap.height ~/ 2); + final expected = _colors[stripe]; + expect(pixel.a, expected.last); + for (final (actual, wanted) in [ + (pixel.r, expected[0]), + (pixel.g, expected[1]), + (pixel.b, expected[2]) + ]) { + expect(actual, closeTo(wanted, 1), reason: 'RGBA stripe $stripe'); + } + } +}