From cb068ba5df103c2e795af1557ced74d548d17b89 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 17:46:57 +0800 Subject: [PATCH] fix(cursor): decode predefined PNG images with correct alpha Use Flutter's PNG codec before native rasterization and retain the straight-alpha image cache. Cover both bundled assets through PNG and Windows BGRA registration. --- flutter/lib/models/model.dart | 21 ++++----- flutter/test/cursor_dpi_policy_test.dart | 54 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index c056ce10a..255913ee7 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2985,7 +2985,8 @@ class PredefinedCursor { CursorData? get cache => _cache; init() { - _image2 = img2.decodePng(base64Decode(png)); + final pngBytes = base64Decode(png); + _image2 = img2.decodePng(pngBytes); if (_image2 != null) { // The png type of forbidden cursor image is `PngColorType.indexed`. if (id == kPreForbiddenCursorId) { @@ -2993,18 +2994,18 @@ class PredefinedCursor { } () async { - final defaultImg = _image2!; - // This function is called only one time, no need to care about the performance. - Uint8List data = defaultImg.getBytes(order: img2.ChannelOrder.rgba); _image?.dispose(); - final nativeImage = await img.decodeImageFromPixels( - data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888); - _image = nativeImage; - if (nativeImage == null) { - print("decodeImageFromPixels failed, pre-defined cursor $id"); - return; + // PNG stores straight alpha; let the codec prepare the pixels for ui.Image. + final codec = await ui.instantiateImageCodec(pngBytes); + final ui.Image nativeImage; + try { + nativeImage = (await codec.getNextFrame()).image; + } finally { + codec.dispose(); } + _image = nativeImage; double scale = 1.0; + final Uint8List data; if (isWindows) { data = _image2!.getBytes(order: img2.ChannelOrder.bgra); } else { diff --git a/flutter/test/cursor_dpi_policy_test.dart b/flutter/test/cursor_dpi_policy_test.dart index f06a75718..91319c489 100644 --- a/flutter/test/cursor_dpi_policy_test.dart +++ b/flutter/test/cursor_dpi_policy_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; @@ -142,6 +143,13 @@ void main() { view.resetDevicePixelRatio(); binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); }); + for (final forbidden in [false, true]) { + test('predefined cursor forbidden=$forbidden preserves native RGBA', () { + view.devicePixelRatio = 1; + return _checkPredefinedCursor( + forbidden ? preForbiddenCursor : preDefaultCursor, registrations); + }); + } for (final testCase in [ (kRemoteViewStyleAdaptive, false, 0, 0.25, windows ? 1.0 : 4 / 3), (kRemoteViewStyleCustom, false, 0, 0.25, windows ? 1.0 : 4 / 3), @@ -226,3 +234,49 @@ Future _checkDprChange( cursor.dispose(); canvas.dispose(); } + +Future _checkPredefinedCursor(PredefinedCursor predefined, + List> registrations) async { + await Future.doWhile(() async { + await Future.delayed(const Duration(milliseconds: 10)); + return predefined.cache == null; + }).timeout(const Duration(seconds: 5)); + final cache = predefined.cache!; + final original = img + .decodePng(base64Decode(predefined.png))! + .convert(format: img.Format.uint8, numChannels: 4); + final canvas = _Canvas(1, style: kRemoteViewStyleAdaptive, scale: 1); + final cursor = _Cursor(cache, _FFI(canvas)); + buildCursorOfCache(cursor, 1, cache); + await deleteCustomCursor(cursor.cachedKeys.single); + cursor.dispose(); + canvas.dispose(); + + final args = registrations.single; + 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), (original.width, original.height)); + expect(args['imagePixelRatio'], 1.0); + expect((args['hotX'], args['hotY']), (cache.hotxOrigin, cache.hotyOrigin)); + expect(cache.image.getBytes(), original.getBytes()); + // Actual bundled pixels cover transparent, opaque and translucent colors. + for (final (x, y) in [(0, 0), (1, 8), (13, 22), (16, 16)]) { + final expected = original.getPixel(x, y); + final actual = decoded.getPixel(x, y); + expect(actual.a, expected.a); + for (final (got, want) in [ + (actual.r, expected.r), + (actual.g, expected.g), + (actual.b, expected.b) + ]) { + expect(got, closeTo(want, 1), reason: '${predefined.id} at ($x, $y)'); + } + } +}