From 69ecd315302b3e590e5c3dbf86904be5e10ef029 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 10 Jun 2026 11:18:19 +0000 Subject: [PATCH] Listen to layout changes for ancestor visuals in text selection canvas (#21536) * listen to layout changes for ancestor visuals in text selection canvas * add tests --- .../Primitives/TextSelectionCanvas.cs | 56 ++++++- .../TextSelectionCanvasTests.cs | 152 ++++++++++++++++++ 2 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 tests/Avalonia.Controls.UnitTests/TextSelectionCanvasTests.cs diff --git a/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs index 8875d8de00..bae5823b80 100644 --- a/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs +++ b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs @@ -5,9 +5,12 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Media.TextFormatting; +using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.VisualTree; +using static Avalonia.Reactive.Disposable; namespace Avalonia.Controls.Primitives { @@ -97,9 +100,16 @@ namespace Avalonia.Controls.Primitives } private void LayoutListener_Invalidated(object? sender, EventArgs e) + { + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) { if (ShowHandles) MoveHandlesToSelection(); + + return base.MeasureOverride(availableSize); } private void Caret_ContextCanceled(object? sender, RoutedEventArgs e) @@ -610,15 +620,48 @@ namespace Avalonia.Controls.Primitives private TextPresenter? _presenter; public event EventHandler? Invalidated; + + private CompositeDisposable? _disposables; public void Attach(TextPresenter presenter) { if (_presenter != null) throw new InvalidOperationException("Listener is already attached to a TextPresenter"); + _disposables?.Dispose(); + _disposables = new CompositeDisposable(); _presenter = presenter; - presenter.SizeChanged += Presenter_SizeChanged; - presenter.EffectiveViewportChanged += Visual_EffectiveViewportChanged; + + Visual? current = presenter; + do + { + AttachEvents(current); + current = current?.VisualParent; + } + while (current != null); + } + + private void AttachEvents(Visual? visual) + { + if (visual != null) + { + if (visual is Layoutable layoutable) + { + layoutable.EffectiveViewportChanged += Visual_EffectiveViewportChanged; + _disposables?.Add(new AnonymousDisposable(() => + { + layoutable.EffectiveViewportChanged -= Visual_EffectiveViewportChanged; + })); + } + if (visual is Control control) + { + control.SizeChanged += Visual_SizeChanged; + _disposables?.Add(new AnonymousDisposable(() => + { + control.SizeChanged -= Visual_SizeChanged; + })); + } + } } private void Visual_EffectiveViewportChanged(object? sender, Layout.EffectiveViewportChangedEventArgs e) @@ -626,18 +669,15 @@ namespace Avalonia.Controls.Primitives OnInvalidated(); } - private void Presenter_SizeChanged(object? sender, SizeChangedEventArgs e) + private void Visual_SizeChanged(object? sender, SizeChangedEventArgs e) { OnInvalidated(); } public void Detach() { - if (_presenter is { } presenter) - { - presenter.SizeChanged -= Presenter_SizeChanged; - presenter.EffectiveViewportChanged -= Visual_EffectiveViewportChanged; - } + _disposables?.Dispose(); + _disposables = null; _presenter = null; } diff --git a/tests/Avalonia.Controls.UnitTests/TextSelectionCanvasTests.cs b/tests/Avalonia.Controls.UnitTests/TextSelectionCanvasTests.cs new file mode 100644 index 0000000000..6e1c9dcce3 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/TextSelectionCanvasTests.cs @@ -0,0 +1,152 @@ +using System.Linq; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Harfbuzz; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Platform; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Moq; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class TextSelectionCanvasTests : ScopedTestBase + { + [Fact] + public void Text_Selection_Handle_Moves_With_Scrolling() + { + using (UnitTestApplication.Start(Services)) + { + var touchHelper = new TouchTestHelper(); + Border rootBorder = new Border() + { + Width = 200, + Height = 600 + }; + var visualLayerManager = new VisualLayerManager() + { + Child = rootBorder, + EnableTextSelectorLayer = true + }; + var impl = CreateMockTopLevelImpl(); + var topLevel = new TestTopLevel(impl.Object) + { + Template = CreateTopLevelTemplate(), + Content = visualLayerManager + }; + + var focusedTextBox = CreateTextBox(); + + var panel = new StackPanel() + { + Orientation = Layout.Orientation.Vertical, + Spacing = 20, + Children = + { + focusedTextBox, + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + CreateTextBox(), + } + }; + + var scrollViewer = new ScrollViewer + { + Template = new FuncControlTemplate(ScrollViewerTests.CreateTemplate), + Content = panel + }; + + rootBorder.Child = scrollViewer; + + topLevel.LayoutManager.ExecuteInitialLayoutPass(); + + touchHelper.Tap(focusedTextBox); + + topLevel.LayoutManager.ExecuteLayoutPass(); + + var presenter = focusedTextBox.FindDescendantOfType()!; + var canvas = presenter.TextSelectionHandleCanvas; + + Assert.NotNull(canvas); + + var handle = canvas.Children.FirstOrDefault() as TextSelectionHandle; + + Assert.NotNull(handle); + + Assert.Equal(new Point(25.5, 14.5), handle.GetTopLeft()); + + scrollViewer.Offset = new Vector(0, 50); + + topLevel.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(new Point(25.5, -35.5), handle.GetTopLeft()); + } + + TextBox CreateTextBox() + { + return new TextBox + { + Template = TextBoxTests.CreateTemplate(), + Text = "Test", + Width = 150 + }; + } + } + + private class TestTopLevel(ITopLevelImpl impl) : TopLevel(impl) + { + + } + + static Mock CreateMockTopLevelImpl() + { + var topLevel = new Mock(); + topLevel.Setup(x => x.RenderScaling).Returns(1); + topLevel.Setup(x => x.Compositor).Returns(RendererMocks.CreateDummyCompositor()); + return topLevel; + } + + private static FuncControlTemplate CreateTopLevelTemplate() + { + return new FuncControlTemplate((x, scope) => + new ContentPresenter + { + Name = "PART_ContentPresenter", + [!ContentPresenter.ContentProperty] = x[!ContentControl.ContentProperty], + }.RegisterInNameScope(scope)); + } + + private static TestServices Services => TestServices.MockThreadingInterface.With( + standardCursorFactory: Mock.Of(), + renderInterface: new HeadlessPlatformRenderInterface(), + textShaperImpl: new HarfBuzzTextShaper(), + fontManagerImpl: new TestFontManager(), + keyboardDevice: () => new KeyboardDevice(), + keyboardNavigation: () => new KeyboardNavigationHandler(), + inputManager: new InputManager(), + assetLoader: new StandardAssetLoader()); + } +}