From b630f14483545301d5c0d6071b433326f251566a Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 13 Sep 2026 13:27:46 +0800 Subject: [PATCH] Synchronize edge-scroll cursor geometry after relayout Refresh edge-scroll fractions from laid-out scroll positions and repaint when they differ from the values used by the frame. Preserve the repaint when a deferred settings refresh has already changed the model. Cover DPR changes, viewport clamping, detached scroll axes and restoration with the actual video and cursor widgets in Original and Custom modes. Verified 87 native Flutter cursor tests on each of macOS and Linux, plus 17 Chrome Web cursor tests. --- flutter/lib/desktop/pages/remote_page.dart | 11 +++ flutter/lib/models/model.dart | 8 ++ flutter/test/cursor_scroll_edge_test.dart | 108 ++++++++++++++++++--- 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index b7041e1e3..1c9aa9901 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1173,6 +1173,7 @@ class _ImagePaintState extends State { child: child); }); if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) { + _syncEdgeScrollAfterLayout(c); final paintWidth = c.getDisplayWidth() * s; final paintHeight = c.getDisplayHeight() * s; final paintSize = Size(paintWidth, paintHeight); @@ -1217,6 +1218,16 @@ class _ImagePaintState extends State { } } + void _syncEdgeScrollAfterLayout(CanvasModel canvas) { + if (canvas.scrollStyle != ScrollStyle.scrolledge) 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.updateEdgeScrollAfterLayout(renderedScroll); + }); + } + Widget _buildScrollbarNonTextureRender( ImageModel m, Size imageSize, double s) { return CustomPaint( diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 1e0e96e7a..0582571a7 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2702,6 +2702,14 @@ class CanvasModel with ChangeNotifier { notifyListeners(); } + void updateEdgeScrollAfterLayout((double, double) renderedScroll) { + updateScrollPercent(); + // A delayed refresh may already have changed the model without repainting. + if (renderedScroll != (_scrollX, _scrollY)) { + notifyListeners(); + } + } + panX(double dx) { _x += dx; if (isMobile) { diff --git a/flutter/test/cursor_scroll_edge_test.dart b/flutter/test/cursor_scroll_edge_test.dart index bb50a9869..dc7e2ef8a 100644 --- a/flutter/test/cursor_scroll_edge_test.dart +++ b/flutter/test/cursor_scroll_edge_test.dart @@ -58,12 +58,29 @@ class _ScrollCanvas extends CanvasModel { resetOffset(); imageOverflow.value = true; } + Size viewport = _viewport; + double imageScale = 1; @override - Size get size => _viewport; + Size get size => viewport; + @override + double get scale => imageScale; + @override + double get x => (size.width - getDisplayWidth() * scale) / 2; + @override + double get y => (size.height - getDisplayHeight() * scale) / 2; @override ScrollStyle get scrollStyle => ScrollStyle.scrolledge; @override final ViewStyle viewStyle; + + void relayout(Size viewport, double dpr) { + this.viewport = viewport; + imageScale = 1 / dpr; + imageOverflow.value = viewport.width < getDisplayWidth() * scale || + viewport.height < getDisplayHeight() * scale; + setScrollPercent(0, 0); + notifyListeners(); + } } class _Cursor extends CursorModel { @@ -127,6 +144,12 @@ void main() { (tester) => tester .runAsync(() => _check(tester, (style, texture, frame, dpr)))); } + for (final refreshBeforeLayout in [true, false]) { + testWidgets( + 'ScrollEdge relayout $style texture=$texture early=$refreshBeforeLayout', + (tester) => tester.runAsync(() => + _checkRelayout(tester, (style, texture, refreshBeforeLayout)))); + } } } } @@ -166,20 +189,83 @@ Future _check( expect(vertical ? ffi.canvasModel.scrollY : ffi.canvasModel.scrollX, closeTo(scrolling.offset / (vertical ? frame.height : frame.width), 1e-9)); await tester.pump(); - final painter = - tester.widget(_paintOf(cursor)).painter! as ImagePainter; - final output = _Draw(); - painter.paint(output, _viewport); - final hotspot = tester.getTopLeft(_paintOf(cursor)) + - output.position! + - _hotspot * output.factor; - final target = tester.getTopLeft(videoWidget) + pointer; - expect(hotspot.dx, closeTo(target.dx, 1e-9)); - expect(hotspot.dy, closeTo(target.dy, 1e-9)); + _expectAlignment(tester, ffi, videoWidget); } await tester.pumpWidget(const SizedBox.shrink()); } +Future _checkRelayout( + WidgetTester tester, (String, bool, bool) testCase) async { + final (style, texture, refreshBeforeLayout) = testCase; + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = _viewport; + addTearDown(tester.view.reset); + final video = await createTestImage(width: 400, height: 320); + final cursor = await createTestImage(width: 48, height: 64); + final ffi = _FFI(video, cursor, + texture: texture, style: style, position: const Offset(150.25, 120.75)); + final canvas = ffi.canvasModel as _ScrollCanvas; + addTearDown(() { + canvas.scrollHorizontal.dispose(); + canvas.scrollVertical.dispose(); + canvas.dispose(); + ffi.imageModel.dispose(); + ffi.cursorModel.dispose(); + video.dispose(); + cursor.dispose(); + }); + await _mount(tester, ffi, 1); + canvas.performEdgeScroll(Vector2(20, 20)); + await tester.pump(); + final videoWidget = texture ? find.byType(Texture) : _paintOf(video); + _expectAlignment(tester, ffi, videoWidget); + for (final (viewport, dpr) in [ + (const Size(160, 120), 2.0), + (const Size(195, 155), 2.0), // Clamp both existing scroll positions. + (const Size(240, 155), 2.0), // Detach the horizontal scroll controller. + (const Size(240, 200), 2.0), // No scrolling remains. + (_viewport, 1.0), + ]) { + tester.view.devicePixelRatio = dpr; + tester.view.physicalSize = viewport * dpr; + canvas.relayout(viewport, dpr); + // A settings refresh may run before layout or after the first cursor build. + if (refreshBeforeLayout) { + canvas.updateScrollPercent(); + } else { + tester.binding.addPostFrameCallback((_) => canvas.updateScrollPercent()); + } + await _mount(tester, ffi, dpr); + await tester.pumpAndSettle(const Duration(milliseconds: 16), + EnginePhase.sendSemanticsUpdate, const Duration(seconds: 1)); + _expectAlignment(tester, ffi, videoWidget); + } + await tester.pumpWidget(const SizedBox.shrink()); +} + +void _expectAlignment(WidgetTester tester, FFI ffi, Finder videoWidget) { + final cursor = ffi.cursorModel; + final cursorWidget = _paintOf(cursor.image!); + final painter = + tester.widget(cursorWidget).painter! as ImagePainter; + final output = _Draw(); + painter.paint(output, ffi.canvasModel.size); + final hotspot = tester.getTopLeft(cursorWidget) + + output.position! + + _hotspot * output.factor; + var videoOrigin = tester.getTopLeft(videoWidget); + final video = tester.widget(videoWidget); + if (video is CustomPaint) { + final drawnVideo = _Draw(); + video.painter!.paint(drawnVideo, ffi.canvasModel.size); + videoOrigin += drawnVideo.position!; + } + final target = + videoOrigin + Offset(cursor.x, cursor.y) * ffi.canvasModel.scale; + expect(hotspot.dx, closeTo(target.dx, 1e-9)); + expect(hotspot.dy, closeTo(target.dy, 1e-9)); +} + Finder _paintOf(ui.Image image) => find.byWidgetPredicate((widget) => widget is CustomPaint && widget.painter is ImagePainter &&