diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index ef6794f86..55258ffe1 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1125,9 +1125,8 @@ class _ImagePaintState extends State { // the existing unzoomed sizing policy for other hosts. return (isWindows ? dpr : 1.0) / peerDpr; } - // Without density, keep the legacy unzoomed scale of 1. Dividing by - // controller DPR would also trigger the legacy short-edge minimum, - // enlarging thin artwork that previously needed no resizing. + // Without density, preserve the legacy unzoomed size. Controller + // DPR alone cannot determine the remote bitmap's logical size. final imageScale = isViewScaled() && zoomCursor.isTrue ? _cursorImageScale(widget.ffi, cursor, useLocalPointer: true) : s; @@ -1456,10 +1455,8 @@ class CursorPaint extends StatelessWidget { if (image != null && (logicalMinimum || scale * nativePixels != 1.0)) { final sx = kMinCursorSize / (image.width * nativePixels); final sy = kMinCursorSize / (image.height * nativePixels); - // Preserve Original's short-edge minimum; scaled views use the long edge. - final minimumScale = c.viewStyle.style == kRemoteViewStyleOriginal - ? (sx > sy ? sx : sy) - : (sx < sy ? sx : sy); + // Match native resizing: a thin axis must not enlarge the whole cursor. + final minimumScale = sx < sy ? sx : sy; if (scale < minimumScale) scale = minimumScale; } // Anchor the hotspot to the video position even when the minimum size diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 0447a454d..3a7fccbca 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2915,19 +2915,11 @@ class CursorData { debugPrint('Rejected cursor $id: invalid scale $scale'); return null; } - if (!useLegacyMinimum) { + // Preserve unscaled legacy artwork, but never enlarge a thin cursor just + // to make its short edge reach the visibility minimum. + if (!useLegacyMinimum || scale != 1.0) { scale = max(scale, kMinCursorSize / max(width, height)); } - if (useLegacyMinimum && scale != 1.0) { - // Update data if scale changed. - final tgtWidth = width * scale; - final tgtHeight = height * scale; - if (tgtWidth < kMinCursorSize || tgtHeight < kMinCursorSize) { - double sw = kMinCursorSize.toDouble() / width; - double sh = kMinCursorSize.toDouble() / height; - scale = sw < sh ? sh : sw; - } - } if (!_validCursorRasterSize(width * scale, height * scale, rasterScale: rasterScale)) { diff --git a/flutter/lib/native/custom_cursor.dart b/flutter/lib/native/custom_cursor.dart index 83e9eeca0..db28296ca 100644 --- a/flutter/lib/native/custom_cursor.dart +++ b/flutter/lib/native/custom_cursor.dart @@ -26,8 +26,8 @@ MouseCursor buildCursorOfCache( // bitmap even when the remote view scale has not changed. final dpr = WidgetsBinding .instance.platformDispatcher.views.single.devicePixelRatio; - // Keep Original and older peers unchanged. A long-edge minimum preserves - // the proportions of thin remote cursors when normalizing their DPI. + // Keep Original and older peers' pixel units and scale-1 behavior. + // Resizing uses a long-edge minimum regardless of source density. final legacyMinimum = cache.pixelRatio == 0 || cursor.parent.target?.canvasModel.viewStyle.style == kRemoteViewStyleOriginal; // The minimum is logical, while Windows callers pass a physical scale. diff --git a/flutter/test/cursor_density_validation_test.dart b/flutter/test/cursor_density_validation_test.dart index db1cf4bef..cf3a65160 100644 --- a/flutter/test/cursor_density_validation_test.dart +++ b/flutter/test/cursor_density_validation_test.dart @@ -38,7 +38,6 @@ void _rasterBoundsTests() { for (final (width, height, scale, legacy, rasterScale) in [ (32, 32, 1e300, false, 1.0), (32, 32, 100.0, false, 2.0), - (512, 1, 0.1, true, 1.0), ]) { test('rejects raster ${width}x$height scale=$scale before key creation', () async { diff --git a/flutter/test/cursor_dpi_policy_test.dart b/flutter/test/cursor_dpi_policy_test.dart index 51cb9e57d..bd0d4e7fe 100644 --- a/flutter/test/cursor_dpi_policy_test.dart +++ b/flutter/test/cursor_dpi_policy_test.dart @@ -35,10 +35,11 @@ class _LinuxDisplay extends Display { double get scale => 2; } -Future _data(int density, String id) async { +Future _data(int density, String id, + {(int, int) source = (9, 18)}) async { final bitmapDensity = density == 0 ? 1 : density; final image = await createTestImage( - width: 9 * bitmapDensity, height: 18 * bitmapDensity); + width: source.$1 * bitmapDensity, height: source.$2 * bitmapDensity); return CursorData( peerId: 'dpi-policy', id: id, @@ -46,8 +47,8 @@ Future _data(int density, String id) async { nativeImage: image, scale: 1, data: Uint8List.fromList([1, 2]), - hotxOrigin: 4.0 * bitmapDensity, - hotyOrigin: 9.0 * bitmapDensity, + hotxOrigin: (source.$1 ~/ 2) * bitmapDensity.toDouble(), + hotyOrigin: (source.$2 ~/ 2) * bitmapDensity.toDouble(), width: image.width, height: image.height, pixelRatio: density.toDouble(), @@ -76,7 +77,8 @@ void main() { (kRemoteViewStyleCustom, false, 2, 0.25, windows ? 1.0 : 0.5), (kRemoteViewStyleAdaptive, true, 2, 0.25, windows ? 2 / 3 : 1 / 3), (kRemoteViewStyleCustom, true, 2, 0.25, windows ? 2 / 3 : 1 / 3), - (kRemoteViewStyleOriginal, false, 2, 0.5, windows ? 1.0 : 2 / 3), + (kRemoteViewStyleOriginal, false, 1, 0.5, windows ? 1.0 : 2 / 3), + (kRemoteViewStyleOriginal, false, 2, 0.5, windows ? 1.0 : 0.5), ]) { testWidgets( '${testCase.$1} zoom=${testCase.$2} peerDPR=${testCase.$3}', @@ -86,6 +88,12 @@ void main() { test('live DPR changes invalidate a cached native cursor', () => _checkDprChange(view, registrations)); for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) { + final dpr = style == kRemoteViewStyleAdaptive ? 1.0 : 2.0; + final source = style == kRemoteViewStyleAdaptive ? (64, 4) : (4, 64); + testWidgets('$style unknown-density thin cursor preserves zoom', (tester) => + tester.runAsync(() => _checkPolicy(tester, + (style, true, 0, 0.5, windows ? 0.5 * dpr : 0.5), registrations, + dpr: dpr, source: source))); testWidgets('$style forbidden cursor ignores remote DPR', (tester) => tester.runAsync(() => _checkPolicy(tester, (style, false, 2, 0.25, 0.5), registrations, dpr: 1))); @@ -99,10 +107,10 @@ Future _checkPolicy( WidgetTester tester, (String, bool, int, double, double) testCase, List> registrations, - {bool linux = false, double dpr = 2}) async { + {bool linux = false, double dpr = 2, (int, int) source = (9, 18)}) async { final (style, zoom, density, viewScale, expectedScale) = testCase; tester.view.devicePixelRatio = dpr; - final data = await _data(density, '$style-$zoom-$density'); + final data = await _data(density, '$style-$zoom-$density', source: source); final originalBytes = data.data; // A stale cached DPR must not affect the cursor when the window moves. final canvas = CursorTestCanvas(1, style: style, scale: viewScale); @@ -146,6 +154,16 @@ Future _checkPolicy( expect(data.hotx, closeTo(data.hotxOrigin * expectedScale, 1e-9)); expect(data.hoty, closeTo(data.hotyOrigin * expectedScale, 1e-9)); expect(data.data, same(originalBytes)); + if (zoom && density == 0) { + final args = registrations.single; + final rasterScale = expectedScale * (Platform.isWindows ? 1 : dpr); + final width = source.$1 * rasterScale; + final height = source.$2 * rasterScale; + expect((args['width'], args['height']), + (Platform.isLinux && height > width ? height : width, height)); + expect((args['hotX'], args['hotY']), + ((source.$1 ~/ 2) * rasterScale, (source.$2 ~/ 2) * rasterScale)); + } if (revoke) { final args = registrations.last; expect(args['name'], contains('_${kPreForbiddenCursorId}_')); diff --git a/flutter/test/cursor_paint_scale_test.dart b/flutter/test/cursor_paint_scale_test.dart index bc1ed7c50..325da26f8 100644 --- a/flutter/test/cursor_paint_scale_test.dart +++ b/flutter/test/cursor_paint_scale_test.dart @@ -18,19 +18,20 @@ const _canvasOffset = Offset(15.125, -10.25); const _viewport = Size(200, 160); class _CursorModel extends ChangeNotifier implements CursorModel { - _CursorModel(this.image, this.position, this.density); + _CursorModel(this.image, this.position, this.density, {this.hotspot = _hotspot}); final Offset position; final double density; + final Offset hotspot; @override CursorData get cache => _Density(density); @override final ui.Image image; @override - double get hotx => _hotspot.dx; + double get hotx => hotspot.dx; @override - double get hoty => _hotspot.dy; + double get hoty => hotspot.dy; @override double get x => position.dx; @override @@ -143,7 +144,8 @@ class _ScrollbarCanvasModel extends _CanvasModel { Future _paintCursor(WidgetTester tester, CanvasModel canvas, {double dpr = 2, bool zoom = true, (int, int) source = (48, 64), - Offset position = _remotePosition, double density = 0}) async { + Offset position = _remotePosition, double density = 0, + Offset hotspot = _hotspot}) async { final image = (await tester .runAsync(() => createTestImage(width: source.$1, height: source.$2)))!; addTearDown(image.dispose); @@ -156,7 +158,7 @@ Future _paintCursor(WidgetTester tester, CanvasModel canvas, child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: canvas.parent.target!.imageModel), - ChangeNotifierProvider(create: (_) => _CursorModel(image, position, density)), + ChangeNotifierProvider(create: (_) => _CursorModel(image, position, density, hotspot: hotspot)), ChangeNotifierProvider(create: (_) => canvas), ], child: Stack(fit: StackFit.expand, children: [ @@ -221,21 +223,26 @@ void main() { (kRemoteViewStyleCustom, true, 2.0, (48, 64), 2.0, 2.0, false, 0.0), (kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false, 0.0), (kRemoteViewStyleAdaptive, true, 2.0, (9, 18), 0.1, minimumScale, true, 0.0), + (kRemoteViewStyleAdaptive, true, 1.0, (64, 4), 0.5, 0.5, true, 0.0), + (kRemoteViewStyleCustom, true, 2.0, (4, 64), 0.5, 0.5, true, 0.0), (kRemoteViewStyleAdaptive, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0), (kRemoteViewStyleAdaptive, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0), (kRemoteViewStyleCustom, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0), (kRemoteViewStyleCustom, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0), (kRemoteViewStyleCustom, true, 2.0, (8, 10), 1.0, 1.2, true, 2.0), - (kRemoteViewStyleOriginal, true, 1.0, (48, 64), 0.05, 0.25, true, 2.0), + (kRemoteViewStyleOriginal, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0), (kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.05, - Platform.isWindows ? 0.125 : 0.25, true, 2.0), + Platform.isWindows ? 0.09375 : 0.1875, true, 2.0), ]) { testWidgets( '$style zoom=$zoom dpr=$dpr source=$source texture=$texture density=$density keeps remote geometry', (tester) async { + final sourceHotspot = source.$1 <= _hotspot.dx || source.$2 <= _hotspot.dy + ? Offset(source.$1 / 2, source.$2 / 2) : _hotspot; final painter = await _paintCursor( tester, _CanvasModel(style, canvasScale, texture), - dpr: dpr, zoom: zoom, source: source, density: density); + dpr: dpr, zoom: zoom, source: source, density: density, + hotspot: sourceHotspot); expect(painter.scale, scale); var imageOrigin = texture ? tester.getTopLeft(find.byType(Texture).first) : _canvasOffset; @@ -250,12 +257,12 @@ void main() { imageOrigin = background.position!; } final target = _remotePosition * canvasScale + imageOrigin; - final hotspot = (Offset(painter.x, painter.y) + _hotspot) * scale; + final hotspot = (Offset(painter.x, painter.y) + sourceHotspot) * scale; expect(hotspot.dx, closeTo(target.dx, 1e-9)); expect(hotspot.dy, closeTo(target.dy, 1e-9)); final canvas = CursorTestDraw(); painter.paint(canvas, _viewport); - final position = canvas.position! + _hotspot * canvas.factor; + final position = canvas.position! + sourceHotspot * canvas.factor; expect(position.dx, closeTo(target.dx, 1e-9)); expect(position.dy, closeTo(target.dy, 1e-9)); });