From 70c50402a27b263a4fe2700860d43375d4ab60fd Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 13 Sep 2026 10:58:34 +0800 Subject: [PATCH] Keep resized Web cursors within a proportional minimum --- flutter/lib/models/model.dart | 11 +++--- flutter/lib/web/custom_cursor.dart | 14 +++++--- flutter/test/cursor_web_test.dart | 58 +++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index a6e052634..63023cba9 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2906,6 +2906,9 @@ class CursorData { } } + // Web's long-edge minimum can round a thin axis below one raster pixel. + final webWidth = max(1, (width * scale).round()); + final webHeight = max(1, (height * scale).round()); if (resizeImage && _doubleToInt(oldScale) != _doubleToInt(scale)) { if (isWindows) { data = img2 @@ -2921,8 +2924,8 @@ class CursorData { img2.encodePng( img2.copyResize( image, - width: isWeb ? (width * scale).round() : (width * scale).toInt(), - height: isWeb ? (height * scale).round() : (height * scale).toInt(), + width: isWeb ? webWidth : (width * scale).toInt(), + height: isWeb ? webHeight : (height * scale).toInt(), interpolation: img2.Interpolation.average, ), ), @@ -2935,8 +2938,8 @@ class CursorData { 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; + hotx = hotxOrigin * webWidth / width; + hoty = hotyOrigin * webHeight / height; } return scale; } diff --git a/flutter/lib/web/custom_cursor.dart b/flutter/lib/web/custom_cursor.dart index de5d7d88c..ac0350fcf 100644 --- a/flutter/lib/web/custom_cursor.dart +++ b/flutter/lib/web/custom_cursor.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:js' as js; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -52,8 +53,9 @@ class CursorManager { 'cursor', jsonEncode({ 'url': cursorData.url, - 'hotx': cursorData.hotX.round(), - 'hoty': cursorData.hotY.round(), + // Rounding must keep the hotspot inside even a one-pixel raster. + 'hotx': cursorData.hotX.round().clamp(0, cursorData.width - 1), + 'hoty': cursorData.hotY.round().clamp(0, cursorData.height - 1), }) ]); } @@ -104,7 +106,9 @@ MouseCursor buildCursorOfCache( if (cache == null) { return MouseCursor.defer; } else { - final key = cache.updateGetKey(scale); + // A short-edge minimum can enlarge thin artwork beyond CSS cursor limits. + // Keep unzoomed images unchanged and use the long edge when resizing. + final key = cache.updateGetKey(scale, useLegacyMinimum: scale == 1.0); if (!cursor.cachedKeys.contains(key)) { // data should be checked here, because it may be changed after `updateGetKey()` final data = cache.data; @@ -116,8 +120,8 @@ MouseCursor buildCursorOfCache( CursorManager.instance.registerCursor(CursorData( key: key, url: 'data:image/rgba;base64,${base64Encode(data)}', - width: (cache.width * cache.scale).round(), - height: (cache.height * cache.scale).round(), + width: max(1, (cache.width * cache.scale).round()), + height: max(1, (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 index a32d0b78c..ad6ae3686 100644 --- a/flutter/test/cursor_web_test.dart +++ b/flutter/test/cursor_web_test.dart @@ -22,6 +22,7 @@ import 'package:provider/provider.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); _alphaTests(); + _thinCursorTests(); for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) { for (final dpr in [1.0, 2.0]) { testWidgets('ImagePaint Web $style zoom off DPR $dpr keeps source size', @@ -55,6 +56,42 @@ void main() { }); } +void _thinCursorTests() { + for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) { + for (final zoom in [false, true]) { + testWidgets('ImagePaint Web $style thin cursor zoom=$zoom', (tester) => + tester.runAsync(() => _checkPolicy(tester, style, 1, + zoom: zoom, source: (64, 4), hotspot: (32, 2), + expectedSize: zoom ? (32, 2) : (64, 4), + expectedHotspot: zoom ? (16, 1) : (32, 2)))); + } + } + test('Web thin cursors keep a raster pixel and an in-bounds hotspot', () async { + final registered = _captureCursor(); + final canvas = _Canvas(kRemoteViewStyleAdaptive); + addTearDown(canvas.dispose); + final ffi = _FFI(canvas); + for (final (source, hotspot, scale, size, expected) in [ + ((4, 64), (2.0, 32.0), 0.5, (2, 32), (1, 16)), + ((2, 128), (1.0, 64.0), 0.01, (1, 12), (0, 6)), + ((128, 2), (64.0, 1.0), 0.01, (12, 1), (6, 0)), + ]) { + final cursor = await _loadCursor(ffi, '$source', + source: source, hotspot: hotspot); + for (final factor in [1.0, scale, 1.0]) { + final session = + buildCursorOfCache(cursor, factor, cursor.cache).createSession(1); + await session.activate(); + session.dispose(); + final png = img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!; + expect((png.width, png.height), factor == 1 ? source : size); + expect((registered['hotx'], registered['hoty']), + factor == 1 ? (hotspot.$1, hotspot.$2) : expected); + } + } + }); +} + class _Image extends ChangeNotifier implements model.ImageModel { @override bool get useTextureRender => false; @@ -114,17 +151,18 @@ class _FFI extends Fake implements model.FFI { Future _loadCursor(model.FFI ffi, String id, {List pixel = const [255, 255, 255, 255], + (int, int) source = (48, 48), (double, double) hotspot = (7, 9), double? pixelRatio = 1}) async { final cursor = model.CursorModel(WeakReference(ffi))..id = id; await cursor.updateCursorData({ 'id': id, - 'width': '48', - 'height': '48', + 'width': '${source.$1}', + 'height': '${source.$2}', 'hotx': '${hotspot.$1}', 'hoty': '${hotspot.$2}', if (pixelRatio != null) 'scale': '$pixelRatio', - 'colors': jsonEncode([for (var i = 0; i < 48 * 48; i++) ...pixel]), + 'colors': jsonEncode([for (var i = 0; i < source.$1 * source.$2; i++) ...pixel]), }); addTearDown(() async { // Keep the session owner alive across asynchronous image decoding. @@ -161,12 +199,16 @@ Map _captureCursor() { return registered; } -Future _checkPolicy(WidgetTester tester, String style, double dpr) async { +Future _checkPolicy(WidgetTester tester, String style, double dpr, + {bool zoom = false, (int, int) source = (48, 48), + (double, double) hotspot = (7, 9), (int, int) expectedSize = (48, 48), + (int, int) expectedHotspot = (7, 9)}) async { final registered = _captureCursor(); final canvas = _Canvas(style); addTearDown(canvas.dispose); final ffi = _FFI(canvas); - final cursor = await _loadCursor(ffi, '$style-$dpr'); + final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source', + source: source, hotspot: hotspot); await tester.pumpWidget(MediaQuery( data: MediaQueryData(devicePixelRatio: dpr), child: MultiProvider( @@ -178,7 +220,7 @@ Future _checkPolicy(WidgetTester tester, String style, double dpr) async { child: ImagePaint( ffi: ffi, id: 'web-cursor-test', - zoomCursor: false.obs, + zoomCursor: zoom.obs, cursorOverImage: true.obs, keyboardEnabled: true.obs, remoteCursorMoved: false.obs)), @@ -192,8 +234,8 @@ Future _checkPolicy(WidgetTester tester, String style, double dpr) async { await tester.pumpWidget(const SizedBox.shrink()); final png = img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!; - expect((png.width, png.height), (48, 48)); - expect((registered['hotx'], registered['hoty']), (7, 9)); + expect((png.width, png.height), expectedSize); + expect((registered['hotx'], registered['hoty']), expectedHotspot); } void _alphaTests() {