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.
This commit is contained in:
fufesou
2026-09-12 17:46:57 +08:00
parent 1d98d1a647
commit cb068ba5df
2 changed files with 65 additions and 10 deletions

View File

@@ -2985,7 +2985,8 @@ class PredefinedCursor {
CursorData? get cache => _cache; CursorData? get cache => _cache;
init() { init() {
_image2 = img2.decodePng(base64Decode(png)); final pngBytes = base64Decode(png);
_image2 = img2.decodePng(pngBytes);
if (_image2 != null) { if (_image2 != null) {
// The png type of forbidden cursor image is `PngColorType.indexed`. // The png type of forbidden cursor image is `PngColorType.indexed`.
if (id == kPreForbiddenCursorId) { if (id == kPreForbiddenCursorId) {
@@ -2993,18 +2994,18 @@ class PredefinedCursor {
} }
() async { () 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(); _image?.dispose();
final nativeImage = await img.decodeImageFromPixels( // PNG stores straight alpha; let the codec prepare the pixels for ui.Image.
data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888); final codec = await ui.instantiateImageCodec(pngBytes);
_image = nativeImage; final ui.Image nativeImage;
if (nativeImage == null) { try {
print("decodeImageFromPixels failed, pre-defined cursor $id"); nativeImage = (await codec.getNextFrame()).image;
return; } finally {
codec.dispose();
} }
_image = nativeImage;
double scale = 1.0; double scale = 1.0;
final Uint8List data;
if (isWindows) { if (isWindows) {
data = _image2!.getBytes(order: img2.ChannelOrder.bgra); data = _image2!.getBytes(order: img2.ChannelOrder.bgra);
} else { } else {

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
@@ -142,6 +143,13 @@ void main() {
view.resetDevicePixelRatio(); view.resetDevicePixelRatio();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); 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 [ for (final testCase in [
(kRemoteViewStyleAdaptive, false, 0, 0.25, windows ? 1.0 : 4 / 3), (kRemoteViewStyleAdaptive, false, 0, 0.25, windows ? 1.0 : 4 / 3),
(kRemoteViewStyleCustom, false, 0, 0.25, windows ? 1.0 : 4 / 3), (kRemoteViewStyleCustom, false, 0, 0.25, windows ? 1.0 : 4 / 3),
@@ -226,3 +234,49 @@ Future<void> _checkDprChange(
cursor.dispose(); cursor.dispose();
canvas.dispose(); canvas.dispose();
} }
Future<void> _checkPredefinedCursor(PredefinedCursor predefined,
List<Map<dynamic, dynamic>> registrations) async {
await Future.doWhile(() async {
await Future<void>.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)');
}
}
}