fix(flutter): preserve cursor minimum sizes and native hotspots

This commit is contained in:
fufesou
2026-09-11 13:51:24 +08:00
parent d202a2fba4
commit 7bb3fe6b5a
3 changed files with 65 additions and 26 deletions

View File

@@ -2893,7 +2893,7 @@ class CursorData {
double _checkUpdateScale(double scale) {
double oldScale = this.scale;
if (!_usesLogicalCursorPixels && scale != 1.0) {
if (scale != 1.0) {
// Update data if scale changed.
final tgtWidth = (width * scale).toInt();
final tgtHeight = (height * scale).toInt();
@@ -2929,8 +2929,8 @@ class CursorData {
}
this.scale = scale;
hotx = hotxOrigin * (_usesLogicalCursorPixels ? scaledWidth / width : scale);
hoty = hotyOrigin * (_usesLogicalCursorPixels ? scaledHeight / height : scale);
hotx = hotxOrigin * scaledWidth / width;
hoty = hotyOrigin * scaledHeight / height;
return scale;
}

View File

@@ -16,8 +16,8 @@ deleteCustomCursor(String key) =>
resetSystemCursor() {}
double _nativeHotspot(double hotspot, int bitmapSize) {
if (!isLinux) return hotspot;
// GDK takes integer hotspots inside the bitmap; avoid truncating toward zero.
if (!isLinux && !isWindows) return hotspot;
// GDK and Win32 take integer hotspots inside the bitmap; use the nearest pixel.
return min(hotspot.round(), bitmapSize - 1).toDouble();
}

View File

@@ -7,17 +7,31 @@ import 'package:flutter_hbb/native/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
// Source size, source hotspot, scale, artwork size, Linux hotspot.
const _cases = [
((9, 18), (4.0, 9.0), 1 / 3, (3, 6), (1.0, 3.0)),
((17, 23), (4.0, 4.0), 1 / 3, (6, 8), (1.0, 1.0)),
((32, 16), (16.0, 8.0), 0.5, (16, 8), (8.0, 4.0)),
((24, 24), (11.0, 11.0), 1 / 3, (8, 8), (4.0, 4.0)),
((3, 3), (2.0, 2.0), 1 / 3, (1, 1), (0.0, 0.0)),
((1, 48), (0.0, 24.0), 1 / 3, (1, 16), (0.0, 8.0)),
((9, 18), (4.0, 9.0), 1.0, (9, 18), (4.0, 9.0)),
((9, 18), (4.0, 9.0), 7 / 18, (4, 7), (2.0, 4.0)),
((24, 24), (4.0, 4.0), 0.1, (2, 2), (0.0, 0.0)),
// Source size, source hotspot, requested/effective scale, artwork size, integer hotspot.
final _cases = [
((17, 23), (4.0, 4.0), 1 / 3, 12 / 17, (12, 16), (3.0, 3.0)),
((32, 16), (16.0, 8.0), 0.5, 0.75, (24, 12), (12.0, 6.0)),
((34, 46), (8.0, 8.0), 0.5, 0.5, (17, 23), (4.0, 4.0)),
((24, 24), (11.0, 11.0), 1 / 3, 0.5, (12, 12), (6.0, 6.0)),
((1, 48), (0.0, 24.0), 1.0, 1.0, (1, 48), (0.0, 24.0)),
((9, 18), (4.0, 9.0), 1.0, 1.0, (9, 18), (4.0, 9.0)),
((24, 24), (4.0, 4.0), 0.1, 0.5, (12, 12), (2.0, 2.0)),
(
(19, 27),
(6.0, 13.0),
1.37,
1.37,
Platform.isWindows ? (26, 36) : (26, 37),
Platform.isWindows ? (8.0, 17.0) : (8.0, 18.0)
),
(
(18, 36),
(8.0, 18.0),
0.75,
0.75,
Platform.isWindows ? (13, 27) : (14, 27),
(6.0, 14.0)
),
];
const _hotspotTolerance = 1e-9;
@@ -51,7 +65,9 @@ CursorData _cursorData((int, int) size, (double, double) hotspot) {
id: 'native',
image: image,
scale: 1,
data: Uint8List.fromList(img.encodePng(image)),
data: Platform.isWindows
? image.getBytes(order: img.ChannelOrder.bgra)
: Uint8List.fromList(img.encodePng(image)),
hotxOrigin: hotspot.$1,
hotyOrigin: hotspot.$2,
width: size.$1,
@@ -61,11 +77,17 @@ CursorData _cursorData((int, int) size, (double, double) hotspot) {
Future<Map<dynamic, dynamic>> _register(
WidgetTester tester, CursorData data, double scale) async {
const channel = MethodChannel('flutter_custom_cursor');
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
Map<dynamic, dynamic>? registered;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
expect(call.method, 'createCustomCursor');
expect(
call.method,
Platform.isWindows
? 'createCustomCursor/windows'
: 'createCustomCursor');
registered = call.arguments as Map<dynamic, dynamic>;
return registered!['name'];
});
@@ -129,16 +151,30 @@ void main() {
}
});
for (final (source, hotspot, scale, size, linuxHotspot) in _cases) {
for (final (source, hotspot, scale, effectiveScale, size, integerHotspot)
in _cases) {
testWidgets('${source.$1}x${source.$2} cursor at scale $scale',
(tester) async {
final data = _cursorData(source, hotspot);
final cursor = await _register(tester, data, scale);
final artwork = img.decodePng(data.data!)!;
final native = img.decodePng(cursor['buffer'] as Uint8List)!;
final artwork = Platform.isWindows
? img.Image.fromBytes(
width: data.scaledWidth,
height: data.scaledHeight,
bytes: data.data!.buffer,
order: img.ChannelOrder.bgra)
: img.decodePng(data.data!)!;
final native = Platform.isWindows
? img.Image.fromBytes(
width: cursor['width'],
height: cursor['height'],
bytes: (cursor['buffer'] as Uint8List).buffer,
bytesOffset: (cursor['buffer'] as Uint8List).offsetInBytes,
order: img.ChannelOrder.bgra)
: img.decodePng(cursor['buffer'] as Uint8List)!;
final width = Platform.isLinux && size.$2 > size.$1 ? size.$2 : size.$1;
expect(data.scale, scale);
expect(data.scale, effectiveScale);
expect((artwork.width, artwork.height), size);
expect(data.hotx / size.$1,
closeTo(hotspot.$1 / source.$1, _hotspotTolerance));
@@ -146,12 +182,15 @@ void main() {
closeTo(hotspot.$2 / source.$2, _hotspotTolerance));
expect((native.width, native.height), (width, size.$2));
expect((cursor['width'], cursor['height']), (width, size.$2));
expect((cursor['hotX'], cursor['hotY']),
Platform.isLinux ? linuxHotspot : (data.hotx, data.hoty));
expect(
(cursor['hotX'], cursor['hotY']),
Platform.isLinux || Platform.isWindows
? integerHotspot
: (data.hotx, data.hoty));
_expectArtwork(native, artwork);
if (width == artwork.width) {
expect(cursor['buffer'], data.data);
}
}, skip: Platform.isWindows);
});
}
}