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();
}