From f96d00d9d1beaf5f4266783c48864b57f7242330 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 10 Sep 2026 21:31:24 +0800 Subject: [PATCH] fix(flutter): correct cursor scaling and Linux cursor clipping Keep Linux/macOS bitmap sizing and painted cursor DPI conversion consistent, and derive hotspots from the actual scaled bitmap. Pad tall Linux native cursor buffers with transparency to preserve the lower half in the hardware cursor plane. Cover bitmap sizing, hotspots, artwork preservation, and painting with focused regression tests. --- flutter/lib/desktop/pages/remote_page.dart | 15 +-- flutter/lib/models/model.dart | 22 +++- flutter/lib/native/custom_cursor.dart | 39 +++++++- flutter/test/cursor_dpi_scale_test.dart | 111 +++++++++++++++++++++ flutter/test/cursor_paint_scale_test.dart | 96 ++++++++++++++++++ 5 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 flutter/test/cursor_dpi_scale_test.dart create mode 100644 flutter/test/cursor_paint_scale_test.dart diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 78612f48e..f726cdae8 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1120,7 +1120,7 @@ class _ImagePaintState extends State { } else { if (zoomCursor.value || isViewOriginal()) { cursorScale = s; - } else { + } else if (isLinux || isMacOS) { // NSCursor and GdkCursor treat the bitmap size as logical // pixels, so an unzoomed cursor must be shrunk by the DPR to // keep 1 remote px == 1 physical px, the size Original view @@ -1421,19 +1421,8 @@ class CursorPaint extends StatelessWidget { x = m.x - hotx + cx / c.scale; y = m.y - hoty + cy / c.scale; scale = c.scale; - } else if (!isWindows) { - // Keep the painted cursor the same physical size as the native one - // built by getCursorScale() above, including its min-size clamp. + } else if (isLinux || isMacOS) { scale = 1.0 / MediaQuery.devicePixelRatioOf(context); - final image = m.image ?? preDefaultCursor.image; - if (scale != 1.0 && - image != null && - ((image.width * scale).toInt() < kMinCursorSize || - (image.height * scale).toInt() < kMinCursorSize)) { - final sw = kMinCursorSize / image.width; - final sh = kMinCursorSize / image.height; - scale = sw < sh ? sh : sw; - } x = (m.x * c.scale + cx) / scale - hotx; y = (m.y * c.scale + cy) / scale - hoty; } diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 76d04faf1..7ea638762 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2879,9 +2879,21 @@ class CursorData { int _doubleToInt(double v) => (v * 10e6).round().toInt(); + bool get _usesLogicalCursorPixels => isLinux || isMacOS; + int get scaledWidth => _scaledDimension(width, scale); + int get scaledHeight => _scaledDimension(height, scale); + + int _scaledDimension(int dimension, double scale) { + const minBitmapSize = 1; + final pixels = dimension * scale; + return _usesLogicalCursorPixels + ? max(minBitmapSize, pixels.round()) + : pixels.toInt(); + } + double _checkUpdateScale(double scale) { double oldScale = this.scale; - if (scale != 1.0) { + if (!_usesLogicalCursorPixels && scale != 1.0) { // Update data if scale changed. final tgtWidth = (width * scale).toInt(); final tgtHeight = (height * scale).toInt(); @@ -2907,8 +2919,8 @@ class CursorData { img2.encodePng( img2.copyResize( image, - width: (width * scale).toInt(), - height: (height * scale).toInt(), + width: _scaledDimension(width, scale), + height: _scaledDimension(height, scale), interpolation: img2.Interpolation.average, ), ), @@ -2917,8 +2929,8 @@ class CursorData { } this.scale = scale; - hotx = hotxOrigin * scale; - hoty = hotyOrigin * scale; + hotx = hotxOrigin * (_usesLogicalCursorPixels ? scaledWidth / width : scale); + hoty = hotyOrigin * (_usesLogicalCursorPixels ? scaledHeight / height : scale); return scale; } diff --git a/flutter/lib/native/custom_cursor.dart b/flutter/lib/native/custom_cursor.dart index e85d42a55..0dcb476f8 100644 --- a/flutter/lib/native/custom_cursor.dart +++ b/flutter/lib/native/custom_cursor.dart @@ -1,16 +1,39 @@ +import 'dart:math'; + import 'package:flutter_custom_cursor/cursor_manager.dart' as custom_cursor_manager; import 'package:flutter_custom_cursor/flutter_custom_cursor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:image/image.dart' as img; +import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/model.dart'; deleteCustomCursor(String key) => custom_cursor_manager.CursorManager.instance.deleteCursor(key); resetSystemCursor() {} +double _nativeHotspot(double hotspot, int bitmapSize) { + if (!isLinux) return hotspot; + // GDK takes integer hotspots inside the bitmap; avoid truncating toward zero. + return min(hotspot.round(), bitmapSize - 1).toDouble(); +} + +Uint8List _padCursorWidth(Uint8List data, int width) { + final bitmap = img.decodePng(data); + if (bitmap == null) { + throw const FormatException('Invalid native cursor PNG'); + } + final padded = img.copyExpandCanvas(bitmap, + newWidth: width, + newHeight: bitmap.height, + position: img.ExpandCanvasPosition.topLeft, + toImage: img.Image(width: width, height: bitmap.height, numChannels: 4)); + return Uint8List.fromList(img.encodePng(padded)); +} + MouseCursor buildCursorOfCache( CursorModel cursor, double scale, CursorData? cache) { if (cache == null) { @@ -23,6 +46,12 @@ MouseCursor buildCursorOfCache( if (data == null) { return MouseCursor.defer; } + // Pad tall Linux buffers to prevent clipping in the hardware cursor plane. + final width = isLinux + ? max(cache.scaledWidth, cache.scaledHeight) + : cache.scaledWidth; + final buffer = + width == cache.scaledWidth ? data : _padCursorWidth(data, width); debugPrint( "Register custom cursor with key $key (${cache.hotx},${cache.hoty})"); // [Safety] @@ -32,11 +61,11 @@ MouseCursor buildCursorOfCache( custom_cursor_manager.CursorManager.instance .registerCursor(custom_cursor_manager.CursorData() ..name = key - ..buffer = data - ..width = (cache.width * cache.scale).toInt() - ..height = (cache.height * cache.scale).toInt() - ..hotX = cache.hotx - ..hotY = cache.hoty); + ..buffer = buffer + ..width = width + ..height = cache.scaledHeight + ..hotX = _nativeHotspot(cache.hotx, cache.scaledWidth) + ..hotY = _nativeHotspot(cache.hoty, cache.scaledHeight)); cursor.addKey(key); } return FlutterCustomMemoryImageCursor(key: key); diff --git a/flutter/test/cursor_dpi_scale_test.dart b/flutter/test/cursor_dpi_scale_test.dart new file mode 100644 index 000000000..d70bab7b2 --- /dev/null +++ b/flutter/test/cursor_dpi_scale_test.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/models/model.dart'; +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)), +]; +const _hotspotTolerance = 1e-9; + +class _CursorModel implements CursorModel { + @override + final Set cachedKeys = {}; + + @override + void addKey(String key) => cachedKeys.add(key); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +CursorData _cursorData((int, int) size, (double, double) hotspot) { + final image = img.Image(width: size.$1, height: size.$2, numChannels: 4); + for (var y = 0; y < image.height; y++) { + image.setPixelRgba(image.width ~/ 2, y, 255, 255, 255, 255); + } + for (var x = 0; x < image.width; x++) { + image.setPixelRgba(x, 0, 255, 0, 0, 255); + image.setPixelRgba(x, image.height - 1, 0, 0, 255, 255); + } + return CursorData( + peerId: 'cursor-test', + id: 'native', + image: image, + scale: 1, + data: Uint8List.fromList(img.encodePng(image)), + hotxOrigin: hotspot.$1, + hotyOrigin: hotspot.$2, + width: size.$1, + height: size.$2, + ); +} + +Future> _register( + WidgetTester tester, CursorData data, double scale) async { + const channel = MethodChannel('flutter_custom_cursor'); + Map? registered; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, + (call) async { + expect(call.method, 'createCustomCursor'); + registered = call.arguments as Map; + return registered!['name']; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + buildCursorOfCache(_CursorModel(), scale, data); + await tester.pump(); + expect(registered, isNotNull); + return registered!; +} + +void _expectArtwork(img.Image native, img.Image artwork) { + final content = img.copyCrop(native, + x: 0, y: 0, width: artwork.width, height: artwork.height); + expect(content.getBytes(), artwork.getBytes()); + for (final pixel in native) { + if (pixel.x >= artwork.width) { + expect(pixel.a, 0, reason: 'Cursor padding must be transparent'); + } + } +} + +void main() { + for (final (source, hotspot, scale, size, linuxHotspot) 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 width = Platform.isLinux && size.$2 > size.$1 ? size.$2 : size.$1; + + expect(data.scale, scale); + expect((artwork.width, artwork.height), size); + expect(data.hotx / size.$1, + closeTo(hotspot.$1 / source.$1, _hotspotTolerance)); + expect(data.hoty / size.$2, + 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)); + _expectArtwork(native, artwork); + if (width == artwork.width) { + expect(cursor['buffer'], data.data); + } + }, skip: Platform.isWindows); + } +} diff --git a/flutter/test/cursor_paint_scale_test.dart b/flutter/test/cursor_paint_scale_test.dart new file mode 100644 index 000000000..5de3f5808 --- /dev/null +++ b/flutter/test/cursor_paint_scale_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/pages/remote_page.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/utils/image.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:provider/provider.dart'; + +const _hotspot = Offset(4, 9); +const _remotePosition = Offset(100, 80); +const _canvasOffset = Offset(15, 10); +const _canvasScale = 0.5; +const _viewport = Size(200, 160); + +class _CursorModel extends ChangeNotifier implements CursorModel { + _CursorModel(this.image); + + @override + final ui.Image image; + @override + double get hotx => _hotspot.dx; + @override + double get hoty => _hotspot.dy; + @override + double get x => _remotePosition.dx; + @override + double get y => _remotePosition.dy; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _CanvasModel extends ChangeNotifier implements CanvasModel { + _CanvasModel(String style) + : viewStyle = ViewStyle( + style: style, + width: _viewport.width, + height: _viewport.height, + displayWidth: _viewport.width.toInt(), + displayHeight: _viewport.height.toInt(), + ); + + @override + final ViewStyle viewStyle; + @override + double get x => _canvasOffset.dx; + @override + double get y => _canvasOffset.dy; + @override + double get scale => _canvasScale; + @override + ScrollStyle get scrollStyle => ScrollStyle.scrollauto; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + for (final (style, zoom, dpr, scale) in [ + (kRemoteViewStyleAdaptive, false, 2.0, 0.5), + (kRemoteViewStyleAdaptive, false, 3.0, 1 / 3), + (kRemoteViewStyleAdaptive, true, 3.0, _canvasScale), + (kRemoteViewStyleOriginal, false, 2.0, _canvasScale), + ]) { + testWidgets('$style zoom=$zoom dpr=$dpr keeps cursor scale and hotspot', + (tester) async { + final image = + (await tester.runAsync(() => createTestImage(width: 9, height: 18)))!; + addTearDown(image.dispose); + await tester.pumpWidget(MediaQuery( + data: MediaQueryData(devicePixelRatio: dpr), + child: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => _CursorModel(image)), + ChangeNotifierProvider( + create: (_) => _CanvasModel(style)), + ], + child: CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs), + ), + )); + final painter = tester + .widget(find.byType(CustomPaint)) + .painter! as ImagePainter; + + expect(painter.image, same(image)); + expect(painter.scale, scale); + expect((Offset(painter.x, painter.y) + _hotspot) * scale, + _remotePosition * _canvasScale + _canvasOffset); + }, skip: Platform.isWindows && style == kRemoteViewStyleAdaptive && !zoom); + } +}