From 8869a440daed325bf11f5b414f511a28389aa7c6 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 13 Sep 2026 16:52:29 +0800 Subject: [PATCH] fix(cursor): comments Signed-off-by: fufesou --- flutter/lib/desktop/pages/remote_page.dart | 85 +++++++++++++++++++--- flutter/lib/models/input_model.dart | 5 +- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 735820e07..4d5b15ea4 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1118,12 +1118,17 @@ class _ImagePaintState extends State { ? cursor.cache ?? preDefaultCursor.cache : preForbiddenCursor.cache; final peerDpr = cache?.pixelRatio ?? 0; - if (!isWeb && isViewScaled() && !zoomCursor.value && peerDpr > 0) { + if (!isWeb && isViewScaled() && zoomCursor.isFalse && peerDpr > 0) { + // Adaptive/Custom scales the video, but Zoom cursor is off: preserve the + // cursor's logical size instead of multiplying it by the canvas scale. + // Divide by the source bitmap density to obtain logical cursor pixels. + // Windows expects a physical-pixel scale at this call boundary, hence dpr; + // buildCursorOfCache() converts it back to logical scale for the plugin. 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 + final imageScale = isViewScaled() && zoomCursor.isTrue ? _cursorImageScale(widget.ffi, cursor, useLocalPointer: true) : s; var cursorScale = 1.0; @@ -1218,12 +1223,19 @@ class _ImagePaintState extends State { } } + /// Schedules a refresh after layout to keep the painted cursor aligned with video. + /// + /// Viewport, view scale or DPR changes can clamp or detach scroll positions + /// without a ScrollNotification, so read the resulting metrics after layout. + /// Save this build's fractions: a delayed refresh may update the model without + /// notifying, leaving the cursor painted from older values. CanvasModel compares + /// the refreshed fractions with this snapshot and notifies only if they differ, + /// repairing the cursor position without creating a rebuild loop. void _syncScrollAfterLayout(CanvasModel canvas) { // Custom scrollbars also paint from scroll fractions; preserve Original's path. if (canvas.scrollStyle != ScrollStyle.scrolledge && canvas.viewStyle.style != kRemoteViewStyleCustom) return; final renderedScroll = (canvas.scrollX, canvas.scrollY); - // Relayout can clamp or detach scroll positions without a scroll event. SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; canvas.updateScrollAfterLayout(renderedScroll); @@ -1458,8 +1470,11 @@ class CursorPaint extends StatelessWidget { : (sx < sy ? sx : sy); if (scale < minimumScale) scale = minimumScale; } - // Keep the hotspot at the remote position even when the minimum enlarges - // the cursor; fractional coordinates must survive painting below. + // Drawing origin = (remote cursor position * canvas scale + video origin) + // / cursor image scale - source hotspot. + // ImagePainter scales both the drawing origin and the artwork, so this + // keeps the hotspot at the displayed remote position even if the cursor + // scale differs from the canvas scale, e.g. because of the minimum size. final x = (m.x * c.scale + cx) / scale - hotx; final y = (m.y * c.scale + cy) / scale - hoty; @@ -1474,6 +1489,20 @@ class CursorPaint extends StatelessWidget { ); } + /// Returns the renderer-adjusted video origin in local logical pixels, + /// keeping the painted cursor aligned with the video. + /// + /// Software rendering truncates the origin in image coordinates before + /// scaling it back. Linux texture rendering truncates it directly in logical + /// pixels. Match that rounding without rounding the cursor or its hotspot. + /// + /// Returns null for overflowing scrollbar/edge-scroll layouts, whose origin + /// the caller computes separately, and for other texture renderers, which + /// use canvas.x/y unchanged. + /// + /// Example: canvas.x = 10.75 and video scale = 0.5 produce a software-rendered + /// origin of (10.75 / 0.5).toInt() * 0.5 = 10.5. Using that same origin for + /// the cursor avoids a 0.25-logical-pixel alignment error. Offset? _softwareImageOffset(CanvasModel canvas) { if (canvas.imageOverflow.isTrue && canvas.scrollStyle != ScrollStyle.scrollauto) { @@ -1497,8 +1526,43 @@ class CursorPaint extends StatelessWidget { } -// Match physical video pixels, independently of the cursor's density metadata: -// a single DRM output keeps physical desktop coordinates even at high DPI. +/// Returns the base cursor artwork scale needed to match the remote video. +/// +/// The result is Flutter logical pixels per source cursor bitmap pixel. +/// Callers handle minimum-size clamping, native-platform DPR conversion, +/// rasterization, and hotspot positioning separately. This function does not +/// draw the cursor or transform its remote position. +/// +/// Callers decide whether the cursor should follow the video: +/// - [CursorPaint] uses this scale regardless of the Zoom cursor setting. +/// - The MouseRegion cursor uses it in Adaptive/Custom view only when Zoom +/// cursor is enabled. With Zoom cursor disabled, a separate sizing policy +/// keeps the native cursor independent of video zoom. +/// Selecting Adaptive/Custom view alone does not imply cursor zoom. +/// +/// For Linux hosts, desktop coordinates and captured bitmap pixels can use +/// different scales. The video renderer normally converts bitmap pixels to +/// view coordinates using `canvas.scale / display.scale`; cursor artwork +/// must use the same conversion. The non-texture scrollbar/edge-scroll +/// renderer is an exception: it paints directly at `canvas.scale`. +/// Non-Linux hosts also use `canvas.scale` directly. +/// +/// With multiple viewed Linux displays, [useLocalPointer] selects which +/// position determines the relevant display. When true, prefer the remote +/// coordinates mapped from local mouse input, so native cursor sizing updates +/// on display crossings without waiting for a host-position echo. When false, +/// use [cursor]'s host-reported position, as required for the painted overlay. +/// Before the first mapped local position, both paths use [cursor]. +/// If no display can be selected, fall back to `canvas.scale`. +/// +/// Do not substitute cursor-bitmap density (`CursorData.pixelRatio`) for +/// `display.scale`: this helper follows video geometry, not unzoomed cursor +/// DPI normalization. A high-DPI capture path can still use physical desktop +/// coordinates, so cursor density alone does not determine the video scale. +/// +/// For example, with Linux `display.scale == 2` and `canvas.scale == 0.5`, +/// the usual rendering path returns 0.25: a 64-pixel cursor is drawn 16 logical +/// pixels wide, before any minimum-size adjustment. double _cursorImageScale(FFI ffi, CursorModel cursor, {bool useLocalPointer = false}) { final canvas = ffi.canvasModel; @@ -1514,12 +1578,15 @@ double _cursorImageScale(FFI ffi, CursorModel cursor, if (displays.length == 1) return canvas.scale / displays.first.scale; final rect = peer.rect; if (rect != null) { - // Local input drives native sizing; host positions still drive the overlay. - // Before the first local movement, retain the existing position source. + // Mapped local input is already in host desktop coordinates. CursorModel's + // position is relative to rect, so restore its origin for display lookup. + // Before the first local movement, both paths use the host position. final position = (useLocalPointer ? ffi.inputModel.remotePointerPosition.value : null) ?? Offset(cursor.x + rect.left, cursor.y + rect.top); for (final display in displays) { + // Display origins are desktop coordinates; dimensions are physical pixels. + // Convert the dimensions so these bounds use the same units as position. if (Rect.fromLTWH(display.x, display.y, display.width / display.scale, display.height / display.scale).contains(position)) { return canvas.scale / display.scale; diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index d18cd0ed0..1b38e6f03 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -449,7 +449,10 @@ class InputModel { final isPhysicalMouse = false.obs; int _lastButtons = 0; Offset lastMousePos = Offset.zero; - // Host position echoes may be absent; keep locally mapped input separately. + // Latest local pointer position mapped to absolute remote desktop coordinates. + // Used to select the native cursor's display scale in Linux All Displays mode, + // without waiting for a host position update. Keep it separate from the + // host-reported position used to paint the remote cursor. final remotePointerPosition = Rxn(); int _lastWheelTsUs = 0;