From 05778e8176d87dc38597bc13ca5124781375f943 Mon Sep 17 00:00:00 2001 From: caran Date: Tue, 22 Sep 2026 15:58:23 +0200 Subject: [PATCH] Ensured zoom control invalidates adorner layer --- .../ZoomControlAdornerLayerTests.cs | 175 ++++++++++++++++++ .../Controls/ZoomControl/ZoomControl.cs | 37 +++- 2 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlAdornerLayerTests.cs diff --git a/Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlAdornerLayerTests.cs b/Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlAdornerLayerTests.cs new file mode 100644 index 0000000..fd94306 --- /dev/null +++ b/Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlAdornerLayerTests.cs @@ -0,0 +1,175 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Markup.Xaml.Templates; +using Avalonia.Media; +using QuikGraph; +using Westermo.GraphX.Common.Models; +using Westermo.GraphX.Controls.Controls; +using Westermo.GraphX.Controls.Controls.ZoomControl; +using Westermo.GraphX.Controls.Controls.ZoomControl.SupportClasses; +using Westermo.GraphX.Logic.Models; + +namespace Westermo.GraphX.Controls.Avalonia.Tests; + +/// +/// Regression tests for adorners attached to s while the hosting +/// is zoomed or panned. +/// +/// Avalonia's AdornerLayer bakes adornedElement.TransformToVisual(layer) into each +/// adorner's RenderTransform during its own arrange pass, and only re-runs that pass when +/// it observes a RenderTransform/Bounds change on the adorned element or an ancestor. +/// Since the ZoomControl mutates its presenter's scale/translate transforms in place (no property +/// change is raised), it must explicitly invalidate the adorner layer - otherwise adorners stay +/// frozen at their pre-zoom screen position while the vertex moves underneath them. +/// +public class ZoomControlAdornerLayerTests +{ + private const double Tolerance = 0.5; + + private class TVertex(string name) : VertexBase + { + public string Name { get; } = name; + public override string ToString() => Name; + } + + private class TEdge(TVertex s, TVertex t) : EdgeBase(s, t) + { + public override Westermo.GraphX.Measure.Point[]? RoutingPoints { get; set; } + } + + /// + /// Gives the vertex control a minimal template so it can be measured/arranged headlessly. + /// + private static void EnsureVertexTemplate(VertexControl vc) + { + if (vc.Template != null) return; + var content = new Grid(); + var panel = new StackPanel { Name = "PART_vcproot" }; + content.Children.Add(panel); + var ns = new NameScope(); + ns.Register("PART_vcproot", panel); + var functor = new Func(_ => new TemplateResult(content, ns)); + vc.Template = new ControlTemplate { TargetType = typeof(VertexControl), Content = functor }; + vc.ApplyTemplate(); + } + + /// + /// Builds a window containing a ZoomControl hosting a GraphArea with a single vertex placed + /// away from the origin, plus an adorner attached to that vertex via the adorner layer. + /// + private static (Window window, ZoomControl zoom, VertexControl vertex, Control adorner) CreateScene() + { + var g = new BidirectionalGraph(); + var v1 = new TVertex("Root"); + g.AddVertex(v1); + + var lc = new GXLogicCore> { Graph = g }; + var area = new GraphArea> { LogicCore = lc }; + area.PreloadVertexes(); + + var vc = area.VertexList[v1]; + vc.Width = 64; + vc.Height = 64; + EnsureVertexTemplate(vc); + vc.SetPosition(500, 300); + GraphAreaBase.SetFinalX(vc, 500); + GraphAreaBase.SetFinalY(vc, 300); + + var zc = new ZoomControl { Content = area, Mode = ZoomControlModes.Custom }; + // The headless test app has no window theme, so no VisualLayerManager (and therefore no + // adorner layer) is created by the Window's template. Host one explicitly so the scene + // matches a real themed application. + var layerManager = new VisualLayerManager { Child = zc }; + var window = new Window { Width = 800, Height = 600, Content = layerManager }; + window.Show(); + window.Measure(new Size(800, 600)); + window.Arrange(new Rect(0, 0, 800, 600)); + window.UpdateLayout(); + + // An adorner with no explicit size is arranged to exactly cover the adorned element, so + // its local coordinate space coincides with the vertex's: a correctly positioned adorner + // maps every local point to the same root coordinate as the vertex does. + var adorner = new Border { Background = Brushes.Red }; + AdornerLayer.SetAdorner(vc, adorner); + window.UpdateLayout(); + + return (window, zc, vc, adorner); + } + + /// + /// Returns the offset (in root/window coordinates) between the adorner and the vertex for the + /// given point expressed in their shared local coordinate space. A correctly tracking adorner + /// yields (0,0) for every point, at any zoom level and pan offset. + /// + private static Point OffsetInRoot(Visual root, Visual vertex, Visual adorner, Point localPoint) + { + var vertexPoint = vertex.TranslatePoint(localPoint, root) ?? default; + var adornerPoint = adorner.TranslatePoint(localPoint, root) ?? default; + return adornerPoint - vertexPoint; + } + + /// + /// Asserts that the adorner is exactly aligned with the adorned vertex, checking both the + /// origin (catches lost translation) and the far corner (catches lost scale). + /// + private static async Task AssertAdornerAlignedAsync(Window window, VertexControl vc, Control adorner, + string phase) + { + var origin = OffsetInRoot(window, vc, adorner, new Point(0, 0)); + var corner = OffsetInRoot(window, vc, adorner, new Point(vc.Width, vc.Height)); + Console.WriteLine($"{phase}: originOffset={origin} cornerOffset={corner}"); + + await Assert.That(origin.X).IsEqualTo(0).Within(Tolerance); + await Assert.That(origin.Y).IsEqualTo(0).Within(Tolerance); + await Assert.That(corner.X).IsEqualTo(0).Within(Tolerance); + await Assert.That(corner.Y).IsEqualTo(0).Within(Tolerance); + } + + [Test] + public async Task PanningZoomControl_KeepsAdornerAlignedWithVertex() + { + var (window, zc, vc, adorner) = CreateScene(); + + await AssertAdornerAlignedAsync(window, vc, adorner, "before pan"); + + // Pure pan: no content-extent change, so nothing else invalidates the adorner layer. + zc.TranslateX += 75; + zc.TranslateY += 40; + window.UpdateLayout(); + + await AssertAdornerAlignedAsync(window, vc, adorner, "after pan"); + } + + [Test] + public async Task ZoomingZoomControl_KeepsAdornerAlignedWithVertex() + { + var (window, zc, vc, adorner) = CreateScene(); + + await AssertAdornerAlignedAsync(window, vc, adorner, "before zoom"); + + zc.Zoom *= 2; + window.UpdateLayout(); + + await AssertAdornerAlignedAsync(window, vc, adorner, "after zoom"); + } + + [Test] + public async Task ZoomToFill_KeepsAdornerAlignedWithVertex() + { + var (window, zc, vc, adorner) = CreateScene(); + + await AssertAdornerAlignedAsync(window, vc, adorner, "before fill"); + + zc.ZoomToFill(); + window.UpdateLayout(); + await AssertAdornerAlignedAsync(window, vc, adorner, "after first fill"); + + // A second fill is a no-op for the content extent, so only the zoom/translate transform + // mutation can keep the adorner in sync. + zc.ZoomToFill(); + window.UpdateLayout(); + await AssertAdornerAlignedAsync(window, vc, adorner, "after second fill"); + } +} diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs index d37363a..3ffce90 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs @@ -67,6 +67,37 @@ private void ScheduleViewportUpdate() }, DispatcherPriority.Render); } + /// + /// Notifies the visual/layout system that the presenter's render transform was changed. + /// + /// + /// The scale/translate transforms are mutated in place (deliberately - reallocating a + /// on every pan/zoom tick would churn the heap during drag + /// operations). In-place mutation raises neither + /// nor , which are the only signals Avalonia's + /// AdornerHelper listens for when deciding to re-arrange the adorner layer. + /// + /// The adorner layer positions each adorner by baking + /// adornedElement.TransformToVisual(layer) into the adorner's render transform during + /// its own arrange pass, so without an explicit invalidation any adorner attached to a + /// VertexControl would stay frozen at its pre-zoom screen position while the vertex + /// moves and scales underneath it. Invalidating the layer's arrange is far cheaper than + /// invalidating the presenter subtree's layout, and leaves the graph content itself + /// untouched (it only needs a repaint, which covers). + /// + private void InvalidatePresenterTransform(bool invalidateVisual = true) + { + var presenter = _presenter; + if (presenter == null) return; + + if (invalidateVisual) presenter.InvalidateVisual(); + + var adornerLayer = AdornerLayer.GetAdornerLayer(presenter); + if (adornerLayer == null || adornerLayer.Children.Count == 0) return; + adornerLayer.InvalidateMeasure(); + adornerLayer.InvalidateArrange(); + } + /// /// Notifies the contained GraphArea of viewport changes for culling optimization. /// @@ -320,7 +351,7 @@ private static void TranslateX_PropertyChanged(ZoomControl zc, AvaloniaPropertyC zc._translateTransform.X = (double)e.NewValue!; if (!zc._isZooming) zc.Mode = ZoomControlModes.Custom; zc.OnPropertyChanged(nameof(Presenter)); - if (!zc._isZooming) zc.Presenter?.InvalidateVisual(); + zc.InvalidatePresenterTransform(!zc._isZooming); zc.ScheduleViewportUpdate(); } @@ -330,7 +361,7 @@ private static void TranslateY_PropertyChanged(ZoomControl zc, AvaloniaPropertyC zc._translateTransform.Y = (double)e.NewValue!; if (!zc._isZooming) zc.Mode = ZoomControlModes.Custom; zc.OnPropertyChanged(nameof(Presenter)); - if (!zc._isZooming) zc.Presenter?.InvalidateVisual(); + zc.InvalidatePresenterTransform(!zc._isZooming); zc.ScheduleViewportUpdate(); } @@ -371,7 +402,7 @@ private static void Zoom_PropertyChanged(ZoomControl zc, AvaloniaPropertyChanged } zc.OnPropertyChanged(nameof(Presenter)); - zc.Presenter?.InvalidateVisual(); + zc.InvalidatePresenterTransform(); zc.OnPropertyChanged(nameof(Zoom)); zc.HookAfterZoomChanging(); }