From 96cd5cd1e234288e2f5631597e15fe8b6a043e02 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 03:29:27 +0800 Subject: [PATCH] fix(cursor): align Web hotspots with rounded cursor images --- flutter/lib/models/model.dart | 9 +++- flutter/lib/web/custom_cursor.dart | 8 ++-- flutter/test/cursor_web_test.dart | 74 ++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 flutter/test/cursor_web_test.dart diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 27f5e6224..56e4b31c3 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2920,8 +2920,8 @@ class CursorData { img2.encodePng( img2.copyResize( image, - width: (width * scale).toInt(), - height: (height * scale).toInt(), + width: isWeb ? (width * scale).round() : (width * scale).toInt(), + height: isWeb ? (height * scale).round() : (height * scale).toInt(), interpolation: img2.Interpolation.average, ), ), @@ -2932,6 +2932,11 @@ class CursorData { this.scale = scale; hotx = hotxOrigin * scale; hoty = hotyOrigin * scale; + if (isWeb) { + // CSS hotspots must follow the actual rounded PNG dimensions. + hotx = hotxOrigin * (width * scale).round() / width; + hoty = hotyOrigin * (height * scale).round() / height; + } return scale; } diff --git a/flutter/lib/web/custom_cursor.dart b/flutter/lib/web/custom_cursor.dart index 54df77e98..de5d7d88c 100644 --- a/flutter/lib/web/custom_cursor.dart +++ b/flutter/lib/web/custom_cursor.dart @@ -52,8 +52,8 @@ class CursorManager { 'cursor', jsonEncode({ 'url': cursorData.url, - 'hotx': cursorData.hotX.toInt(), - 'hoty': cursorData.hotY.toInt(), + 'hotx': cursorData.hotX.round(), + 'hoty': cursorData.hotY.round(), }) ]); } @@ -116,8 +116,8 @@ MouseCursor buildCursorOfCache( CursorManager.instance.registerCursor(CursorData( key: key, url: 'data:image/rgba;base64,${base64Encode(data)}', - width: (cache.width * cache.scale).toInt(), - height: (cache.height * cache.scale).toInt(), + width: (cache.width * cache.scale).round(), + height: (cache.height * cache.scale).round(), hotX: cache.hotx, hotY: cache.hoty)); cursor.addKey(key); diff --git a/flutter/test/cursor_web_test.dart b/flutter/test/cursor_web_test.dart new file mode 100644 index 000000000..b793018b5 --- /dev/null +++ b/flutter/test/cursor_web_test.dart @@ -0,0 +1,74 @@ +@TestOn('browser') +library; + +import 'dart:convert'; +import 'dart:js' as js; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_hbb/models/model.dart' as model; +import 'package:flutter_hbb/web/custom_cursor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +class _CursorModel extends Fake implements model.CursorModel { + @override + final Set cachedKeys = {}; + @override + void addKey(String key) => cachedKeys.add(key); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async { + Map? registered; + final original = js.context['setByName']; + js.context['setByName'] = js.allowInterop((String name, String value) { + expect(name, 'cursor'); + registered = jsonDecode(value) as Map; + }); + addTearDown(() => js.context['setByName'] = original); + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawColor(const ui.Color(0xffffffff), ui.BlendMode.src); + final picture = recorder.endRecording(); + final nativeImage = await picture.toImage(48, 48); + picture.dispose(); + addTearDown(nativeImage.dispose); + for (final (hotspot, scale, side, expected) in [ + ((7.0, 7.0), 633 / 1600, 19, (3, 3)), + ((21.0, 23.0), 633 / 1600, 19, (8, 9)), + ((22.0, 22.0), 633 / 1600, 19, (9, 9)), + ((21.0, 23.0), 0.05, 12, (5, 6)), + ((21.0, 23.0), 0.5, 24, (11, 12)), + ((7.0, 7.0), 1.0, 48, (7, 7)), + ]) { + final cache = _data(nativeImage, hotspot); + final session = + buildCursorOfCache(_CursorModel(), scale, cache).createSession(1); + await session.activate(); + final uri = Uri.parse(registered!['url'] as String); + final bitmap = img.decodePng(uri.data!.contentAsBytes())!; + expect((bitmap.width, bitmap.height), (side, side)); + expect((registered!['hotx'], registered!['hoty']), expected); + session.dispose(); + await deleteCustomCursor(cache.updateGetKey(scale)); + } + }); +} + +model.CursorData _data(ui.Image nativeImage, (double, double) hotspot) { + final image = img.Image(width: 48, height: 48, numChannels: 4); + img.fill(image, color: img.ColorRgba8(255, 255, 255, 255)); + return model.CursorData( + peerId: 'web-cursor-test', + id: '$hotspot', + image: image, + nativeImage: nativeImage, + scale: 1, + data: Uint8List.fromList(img.encodePng(image)), + hotxOrigin: hotspot.$1, + hotyOrigin: hotspot.$2, + width: 48, + height: 48, + ); +}