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); + } +}