From 6934b3cb14b0eda2f47a6f2232801cd5c83bc7ac Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 19:56:55 +0800 Subject: [PATCH] fix(cursor): follow Linux video pixel scale --- flutter/lib/desktop/pages/remote_page.dart | 39 ++++++++++-- flutter/test/cursor_dpi_policy_test.dart | 20 ++++++- flutter/test/cursor_paint_scale_test.dart | 70 ++++++++++++++++++---- 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 1ecf86ed0..ccc135df5 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1112,21 +1112,24 @@ class _ImagePaintState extends State { mouseRegion({child}) => Obx(() { double getCursorScale() { - final peerDpr = Provider.of(context).cache?.pixelRatio ?? 0; + final cursor = Provider.of(context); + final peerDpr = cursor.cache?.pixelRatio ?? 0; if (!isWeb && isViewScaled() && !zoomCursor.value && peerDpr > 0) { return (isWindows ? dpr : 1.0) / peerDpr; } // Density metadata is optional. Keep the legacy path for hosts that // omit it so capture-backend upgrades are not a client prerequisite. + final imageScale = isViewScaled() && zoomCursor.value + ? _cursorImageScale(widget.ffi, cursor) : s; var cursorScale = 1.0; if (isWindows) { // debug win10 if (zoomCursor.value && isViewScaled()) { - cursorScale = s * dpr; + cursorScale = imageScale * dpr; } } else { if (zoomCursor.value || isViewOriginal()) { - cursorScale = s; + cursorScale = imageScale; } else if (!isWeb) { // NSCursor and GdkCursor treat the bitmap size as logical // pixels, so an unzoomed cursor must be shrunk by the DPR to @@ -1422,7 +1425,7 @@ class CursorPaint extends StatelessWidget { final image = m.image ?? preDefaultCursor.image; final nativePixels = isWindows ? MediaQuery.devicePixelRatioOf(context) : 1.0; // Show remote cursor follows the image scale, independently of Zoom cursor. - double scale = c.scale; + double scale = _cursorImageScale(c.parent.target!, m); if (image != null && scale * nativePixels != 1.0) { final sx = kMinCursorSize / (image.width * nativePixels); final sy = kMinCursorSize / (image.height * nativePixels); @@ -1465,4 +1468,32 @@ class CursorPaint extends StatelessWidget { return Offset( (canvas.x / scale).toInt() * scale, (canvas.y / scale).toInt() * scale); } + +} + +// Match physical video pixels, independently of the cursor's density metadata: +// a single DRM output keeps physical desktop coordinates even at high DPI. +double _cursorImageScale(FFI ffi, CursorModel cursor) { + final canvas = ffi.canvasModel; + final peer = ffi.ffiModel; + if (!peer.isPeerLinux) return canvas.scale; + if (canvas.imageOverflow.isTrue && + canvas.scrollStyle != ScrollStyle.scrollauto && + !ffi.imageModel.useTextureRender && + !peer.pi.forceTextureRender) { + return canvas.scale; // The nontexture scrollbar also paints physical pixels. + } + final displays = peer.pi.getCurDisplays(); + if (displays.length == 1) return canvas.scale / displays.first.scale; + final rect = peer.rect; + if (rect != null) { + final position = Offset(cursor.x + rect.left, cursor.y + rect.top); + for (final display in displays) { + if (Rect.fromLTWH(display.x, display.y, display.width / display.scale, + display.height / display.scale).contains(position)) { + return canvas.scale / display.scale; + } + } + } + return canvas.scale; } diff --git a/flutter/test/cursor_dpi_policy_test.dart b/flutter/test/cursor_dpi_policy_test.dart index 91319c489..ab233366b 100644 --- a/flutter/test/cursor_dpi_policy_test.dart +++ b/flutter/test/cursor_dpi_policy_test.dart @@ -45,6 +45,8 @@ class _Canvas extends ChangeNotifier implements CanvasModel { @override bool get cursorEmbedded => false; @override + ScrollStyle get scrollStyle => ScrollStyle.scrollauto; + @override Size get size => _viewport; @override final double scale; @@ -88,7 +90,12 @@ class _Peer extends Fake implements FfiModel { @override final pi = PeerInfo(); @override - bool get isPeerLinux => false; + bool isPeerLinux = false; +} + +class _LinuxDisplay extends Display { + @override + double get scale => 2; } class _FFI extends Fake implements FFI { @@ -99,7 +106,7 @@ class _FFI extends Fake implements FFI { @override final inputModel = _Input(); @override - final ffiModel = _Peer(); + final _Peer ffiModel = _Peer(); } Future _data(int density, String id) async { @@ -167,12 +174,17 @@ void main() { } test('live DPR changes invalidate a cached native cursor', () => _checkDprChange(view, registrations)); + for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) { + testWidgets('Linux $style zoom follows video pixels', (tester) => tester.runAsync( + () => _checkPolicy(tester, (style, true, 2, 1.0, windows ? 1.0 : 0.5), + registrations, linux: true))); + } } Future _checkPolicy( WidgetTester tester, (String, bool, int, double, double) testCase, - List> registrations) async { + List> registrations, {bool linux = false}) async { final (style, zoom, density, viewScale, expectedScale) = testCase; tester.view.devicePixelRatio = 2; final data = await _data(density, '$style-$zoom-$density'); @@ -180,6 +192,8 @@ Future _checkPolicy( // A stale cached DPR must not affect the cursor when the window moves. final canvas = _Canvas(1, style: style, scale: viewScale); final ffi = _FFI(canvas); + ffi.ffiModel.isPeerLinux = linux; + if (linux) ffi.ffiModel.pi.displays.add(_LinuxDisplay()); final cursor = _Cursor(data, ffi); await tester.pumpWidget(MediaQuery( data: const MediaQueryData(devicePixelRatio: 2), diff --git a/flutter/test/cursor_paint_scale_test.dart b/flutter/test/cursor_paint_scale_test.dart index 7a34497e6..78d037089 100644 --- a/flutter/test/cursor_paint_scale_test.dart +++ b/flutter/test/cursor_paint_scale_test.dart @@ -16,7 +16,9 @@ const _canvasOffset = Offset(15.125, 10.25); const _viewport = Size(200, 160); class _CursorModel extends ChangeNotifier implements CursorModel { - _CursorModel(this.image); + _CursorModel(this.image, this.position); + + final Offset position; @override final ui.Image image; @@ -25,9 +27,9 @@ class _CursorModel extends ChangeNotifier implements CursorModel { @override double get hoty => _hotspot.dy; @override - double get x => _remotePosition.dx; + double get x => position.dx; @override - double get y => _remotePosition.dy; + double get y => position.dy; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @@ -40,13 +42,24 @@ class _ImageModel extends Fake implements ImageModel { final bool useTextureRender; } +class _Display extends Display { + _Display(this.scale, double left) { + x = left; + width = 3840; + height = 2160; + } + + @override + final double scale; +} + class _Peer extends Fake implements FfiModel { @override final pi = PeerInfo(); @override - bool get isPeerLinux => false; + bool isPeerLinux = false; @override - Rect get rect => Offset.zero & _viewport; + Rect rect = Offset.zero & _viewport; } class _FFI extends Fake implements FFI { @@ -55,7 +68,9 @@ class _FFI extends Fake implements FFI { @override final ImageModel imageModel; @override - final ffiModel = _Peer(); + final _Peer ffiModel = _Peer(); + @override + late CanvasModel canvasModel; } class _CanvasModel extends ChangeNotifier implements CanvasModel { @@ -67,9 +82,11 @@ class _CanvasModel extends ChangeNotifier implements CanvasModel { height: _viewport.height, displayWidth: _viewport.width.toInt(), displayHeight: _viewport.height.toInt(), - ); + ) { + _ffi.canvasModel = this; + } - final FFI _ffi; + final _FFI _ffi; @override WeakReference get parent => WeakReference(_ffi); @override @@ -118,7 +135,8 @@ class _Canvas extends Fake implements Canvas { } Future _paintCursor(WidgetTester tester, CanvasModel canvas, - {double dpr = 2, bool zoom = true, (int, int) source = (48, 64)}) async { + {double dpr = 2, bool zoom = true, (int, int) source = (48, 64), + Offset position = _remotePosition}) async { final image = (await tester .runAsync(() => createTestImage(width: source.$1, height: source.$2)))!; addTearDown(image.dispose); @@ -126,7 +144,7 @@ Future _paintCursor(WidgetTester tester, CanvasModel canvas, data: MediaQueryData(devicePixelRatio: dpr), child: MultiProvider( providers: [ - ChangeNotifierProvider(create: (_) => _CursorModel(image)), + ChangeNotifierProvider(create: (_) => _CursorModel(image, position)), ChangeNotifierProvider(create: (_) => canvas), ], child: CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs), @@ -139,6 +157,38 @@ Future _paintCursor(WidgetTester tester, CanvasModel canvas, } void main() { + for (final (texture, displayScale, allDisplays) in [ + (false, 2.0, false), (true, 2.0, false), (true, 2.0, true), + (false, 1.0, false), (true, 1.0, false), + ]) { + testWidgets('Linux scale=$displayScale texture=$texture all=$allDisplays', + (tester) async { + final canvas = _CanvasModel(kRemoteViewStyleCustom, 2, texture); + final peer = canvas._ffi.ffiModel; + peer.isPeerLinux = true; + // The first output's physical extent overlaps the second in logical + // coordinates. Selection must use logical extents and the union origin. + peer.pi.displays.assignAll([ + if (displayScale > 1) _Display(4, -960), _Display(displayScale, 0), + ]); + peer.pi.currentDisplay = allDisplays ? kAllDisplayValue + : peer.pi.displays.length - 1; + peer.rect = Rect.fromLTWH(allDisplays ? -960 : 0, 0, 3840, 2160); + final painter = await _paintCursor(tester, canvas, + dpr: 1, zoom: false, source: (64, 64), + position: allDisplays ? _remotePosition + const Offset(960, 0) + : _remotePosition); + final pixelScale = 2 / displayScale; + expect(painter.scale, pixelScale); + final origin = texture ? _canvasOffset + : Offset((_canvasOffset.dx / pixelScale).toInt() * pixelScale, + (_canvasOffset.dy / pixelScale).toInt() * pixelScale); + final position = allDisplays + ? _remotePosition + const Offset(960, 0) : _remotePosition; + expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale, + position * 2 + origin); + }); + } final minimumScale = Platform.isWindows ? 1 / 3 : 2 / 3; for (final (style, zoom, dpr, source, canvasScale, scale, texture) in [ (kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true),