diff --git a/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.Renders_OffsetTrackableContent_WithoutClippingLowerOrRightEdges.verified.png b/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.Renders_OffsetTrackableContent_WithoutClippingLowerOrRightEdges.verified.png new file mode 100644 index 0000000..94691ca Binary files /dev/null and b/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.Renders_OffsetTrackableContent_WithoutClippingLowerOrRightEdges.verified.png differ diff --git a/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.Renders_OffsetTrackableContent_WithoutClippingLowerOrRightEdges.verified.txt b/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.Renders_OffsetTrackableContent_WithoutClippingLowerOrRightEdges.verified.txt new file mode 100644 index 0000000..3daf048 --- /dev/null +++ b/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.Renders_OffsetTrackableContent_WithoutClippingLowerOrRightEdges.verified.txt @@ -0,0 +1,65 @@ +{ + Type: Window, + WindowState: Normal, + Content: { + Type: StackPanel, + Spacing: 8.0, + Orientation: Vertical, + Children: [ + { + Type: ZoomControl, + Content: { + Type: WayfinderRenderTests.TrackableCanvas, + Background: Transparent, + Width: 800.0, + Height: 600.0, + Children: [ + { + Type: Rectangle, + Fill: SteelBlue, + Width: 40.0, + Height: 40.0 + }, + { + Type: Rectangle, + Fill: SteelBlue, + Width: 40.0, + Height: 40.0 + }, + { + Type: Rectangle, + Fill: SteelBlue, + Width: 40.0, + Height: 40.0 + }, + { + Type: Rectangle, + Fill: SteelBlue, + Width: 40.0, + Height: 40.0 + }, + { + Type: Rectangle, + Fill: SteelBlue, + Width: 40.0, + Height: 40.0 + } + ] + }, + Width: 400.0, + Height: 240.0 + }, + { + Type: Wayfinder, + Focusable: true, + Width: 200.0, + Height: 120.0, + ClipToBounds: true + } + ] + }, + Background: White, + Width: 420.0, + Height: 400.0, + IsVisible: true +} \ No newline at end of file diff --git a/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.cs b/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.cs index e32f6c4..cba84e6 100644 --- a/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.cs +++ b/Westermo.GraphX.Controls.Avalonia.Tests/WayfinderRenderTests.cs @@ -5,7 +5,9 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; +using Westermo.GraphX.Controls.Controls.Misc; using Westermo.GraphX.Controls.Controls.ZoomControl; +using Westermo.GraphX.Controls.Models; namespace Westermo.GraphX.Controls.Avalonia.Tests; @@ -108,6 +110,64 @@ private static (Window window, ZoomControl zc, Wayfinder wf) BuildScene( return (window, zc, wf); } + private static (Window window, ZoomControl zc, Wayfinder wf) BuildOffsetTrackableScene( + Rect contentRect, + Action populateContent) + { + // GraphArea reports the actual child extent via ITrackableContent. + // Reproduce that here without depending on graph layout algorithms. + var content = new TrackableCanvas(contentRect) + { + Width = contentRect.Width, + Height = contentRect.Height, + Background = Brushes.Transparent + }; + populateContent(content); + + var zc = new ZoomControl + { + Width = 400, + Height = 240, + Content = content + }; + + var wf = new Wayfinder + { + Width = WayfinderWidth, + Height = WayfinderHeight, + ZoomControl = zc, + Background = new SolidColorBrush(Color.FromRgb(0xF0, 0xF0, 0xF0)), + ShadowBrush = null, + ViewportBrush = Brushes.Transparent, + ViewportPen = null + }; + + var root = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = 8, + Children = { zc, wf } + }; + + var window = new Window + { + Width = 420, + Height = 400, + Background = Brushes.White, + Content = root + }; + window.Show(); + + for (var i = 0; i < 3; i++) + { + window.Measure(new Size(420, 400)); + window.Arrange(new Rect(0, 0, 420, 400)); + Dispatcher.UIThread.RunJobs(); + } + + return (window, zc, wf); + } + private static VerifySettings GetSettings([CallerMemberName] string? testName = null) { var settings = new VerifySettings(); @@ -193,6 +253,28 @@ public async Task Viewport_ReachesLeftmostEdge_WhenPannedFully() await Verify(window, GetSettings()); } + /// + /// Trackable graph content may have a positive top-left extent when all + /// vertices live away from the origin. The minimap must still render the + /// bottom/right of that extent instead of clipping it by the offset amount. + /// + [Test] + public async Task Renders_OffsetTrackableContent_WithoutClippingLowerOrRightEdges() + { + var contentRect = new Rect(100, 80, 800, 600); + var (window, _, wf) = BuildOffsetTrackableScene(contentRect, canvas => + { + AddBox(canvas, 0, 0, 40, 40); + AddBox(canvas, 760, 0, 40, 40); + AddBox(canvas, 0, 560, 40, 40); + AddBox(canvas, 760, 560, 40, 40); + AddBox(canvas, 380, 280, 40, 40); + }); + + // Verify.Avalonia takes ownership of closing the window. + await Verify(window, GetSettings()); + } + private static void AddBox(Panel canvas, double x, double y, double w, double h) { var rect = new Rectangle @@ -205,4 +287,15 @@ private static void AddBox(Panel canvas, double x, double y, double w, double h) Canvas.SetTop(rect, y); canvas.Children.Add(rect); } + + private sealed class TrackableCanvas(Rect contentSize) : Canvas, ITrackableContent + { + public event ContentSizeChangedEventHandler? ContentSizeChanged + { + add { } + remove { } + } + + public Rect ContentSize { get; } = contentSize; + } } diff --git a/Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlTests.cs b/Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlTests.cs index 4408049..d9aadb0 100644 --- a/Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlTests.cs +++ b/Westermo.GraphX.Controls.Avalonia.Tests/ZoomControlTests.cs @@ -1,5 +1,6 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Threading; using Westermo.GraphX.Controls.Controls.ZoomControl; using Westermo.GraphX.Controls.Controls.ZoomControl.SupportClasses; @@ -228,6 +229,69 @@ public async Task Mode_SetToFill_TriggersZoomToFill() #endregion + #region Modifier Drag Gesture Tests + + [Test] + public async Task Alt_LeftDrag_Zooms_To_Rectangle() + { + var (zc, window) = CreateZoomControlWithContent(800, 600, 1000, 1000); + try + { + zc.Mode = ZoomControlModes.Custom; + zc.Zoom = 1.0; + + var areaSelectedCount = 0; + zc.AreaSelected += (_, _) => areaSelectedCount++; + + var initialZoom = zc.Zoom; + var beganInteraction = zc.BeginInteractionForTest(KeyModifiers.Alt, new Point(100, 100)); + await Assert.That(beganInteraction).IsTrue(); + zc.MoveInteractionForTest(new Point(300, 300)); + zc.CompleteInteractionForTest(); + + await Assert.That(zc.Zoom).IsGreaterThan(initialZoom); + await Assert.That(areaSelectedCount).IsEqualTo(0); + await Assert.That(zc.ZoomBox).IsEqualTo(default(Rect)); + } + finally + { + window.Close(); + } + } + + [Test] + public async Task ControlAlt_LeftDrag_AreaSelects_Without_Zooming() + { + var (zc, window) = CreateZoomControlWithContent(800, 600, 1000, 1000); + try + { + zc.Mode = ZoomControlModes.Custom; + zc.Zoom = 1.0; + + Rect? selectedRectangle = null; + zc.AreaSelected += (_, args) => selectedRectangle = args.Rectangle; + + var initialZoom = zc.Zoom; + var beganInteraction = + zc.BeginInteractionForTest(KeyModifiers.Control | KeyModifiers.Alt, new Point(100, 100)); + await Assert.That(beganInteraction).IsTrue(); + zc.MoveInteractionForTest(new Point(300, 300)); + zc.CompleteInteractionForTest(); + + await Assert.That(zc.Zoom).IsEqualTo(initialZoom); + await Assert.That(selectedRectangle.HasValue).IsTrue(); + await Assert.That(Math.Abs(selectedRectangle!.Value.Width - 200)).IsLessThan(Tolerance); + await Assert.That(Math.Abs(selectedRectangle.Value.Height - 200)).IsLessThan(Tolerance); + await Assert.That(zc.ZoomBox).IsEqualTo(default(Rect)); + } + finally + { + window.Close(); + } + } + + #endregion + #region ZoomToContent Tests [Test] diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs index 61b2edc..269ff86 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs @@ -104,7 +104,9 @@ public IPen? ViewportPen #endregion private bool _isDragging; + private Point _lastPointerPos; + // Tracks whether the control is currently attached to the visual tree and // therefore has live event subscriptions on the target ZoomControl. Event // wiring is deferred to AttachedToVisualTree / DetachedFromVisualTree so @@ -112,6 +114,7 @@ public IPen? ViewportPen // target ZoomControl (otherwise the ZoomControl would keep the wayfinder // alive via its event handlers). private bool _subscribed; + // The content visual we currently have a LayoutUpdated subscription on. // Tracked separately because ZoomControl.Content can change without the // wayfinder being detached, and we need to move the subscription with it. @@ -233,7 +236,7 @@ private void TargetPropertyChanged(object? sender, AvaloniaPropertyChangedEventA { if (e.Property == ContentControl.ContentProperty) { - if(e.OldValue is ITrackableContent oldTc) + if (e.OldValue is ITrackableContent oldTc) oldTc.ContentSizeChanged -= TargetContentSizeChanged; if (e.NewValue is ITrackableContent newTc) newTc.ContentSizeChanged += TargetContentSizeChanged; @@ -281,13 +284,16 @@ private void RecomputeGeometry() // by -_contentRect.TopLeft so the result is anchored to the same // origin as ContentBounds (top-left of the wayfinder content area). var vpContent = WayfinderGeometry.ComputeViewportRect( - zc.Zoom, zc.TranslateX, zc.TranslateY, + zc.Zoom, + zc.TranslateX, + zc.TranslateY, new Size(zc.Bounds.Width, zc.Bounds.Height), Scale); ViewportRect = new Rect( vpContent.X - _contentRect.X * Scale, vpContent.Y - _contentRect.Y * Scale, - vpContent.Width, vpContent.Height); + vpContent.Width, + vpContent.Height); } else { @@ -308,17 +314,15 @@ private Rect GetContentRect() if (zc == null) return default; if (zc.TrackableContent is { } trackable) return trackable.ContentSize; - if (zc.ContentVisual is { } visual) + if (zc.ContentVisual is not { } visual) return default; + var size = visual.DesiredSize; + if (size.Width <= 0 || size.Height <= 0) { - var size = visual.DesiredSize; - if (size.Width <= 0 || size.Height <= 0) - { - var b = visual.Bounds; - if (b.Width > 0 && b.Height > 0) size = b.Size; - } - return new Rect(default, size); + var b = visual.Bounds; + if (b is { Width: > 0, Height: > 0 }) size = b.Size; } - return default; + + return new Rect(default, size); } protected override Size MeasureOverride(Size availableSize) => availableSize; @@ -344,11 +348,10 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) // whether the press happens inside or outside the viewport rectangle. if (e.ClickCount >= 2) { - if (ContentBounds.Contains(p)) - { - RecenterOnWayfinderPoint(p); - e.Handled = true; - } + if (!ContentBounds.Contains(p)) return; + RecenterOnWayfinderPoint(p); + e.Handled = true; + return; } @@ -431,7 +434,7 @@ public void PanByWayfinderDelta(Vector delta) if (zc == null || Scale <= 0) return; var clamped = WayfinderGeometry.ClampDragDelta(ViewportRect, ContentBounds, delta); - if (clamped.X == 0 && clamped.Y == 0) return; + if (clamped is { X: 0, Y: 0 }) return; // Move ZoomControl translate inversely: shifting the minimap viewport right // means the visible content slides left, i.e. translate decreases. @@ -496,6 +499,7 @@ public void RecenterOnWayfinderPoint(Point wayfinderPoint) if (tx > txMax) tx = txMax; if (tx < txMin) tx = txMin; } + if (contentSpanY < _contentRect.Height) { var tyMax = -_contentRect.Y * zoom; @@ -523,7 +527,7 @@ public override void Render(DrawingContext dc) base.Render(dc); // 1. Background of the content area. - if (Background != null && ContentBounds.Width > 0 && ContentBounds.Height > 0) + if (Background != null && ContentBounds is { Width: > 0, Height: > 0 }) dc.DrawRectangle(Background, null, ContentBounds); // 2. Live snapshot of the actual ZoomControl content via a VisualBrush. @@ -539,7 +543,7 @@ public override void Render(DrawingContext dc) // 3. Shadow over non-viewport area + viewport outline. var bounds = new Rect(Bounds.Size); var vp = ViewportRect; - var intersects = vp.Width > 0 && vp.Height > 0 && + var intersects = vp is { Width: > 0, Height: > 0 } && !(vp.Right <= bounds.X || vp.X >= bounds.Right || vp.Bottom <= bounds.Y || vp.Y >= bounds.Bottom); @@ -553,7 +557,7 @@ public override void Render(DrawingContext dc) // ContentBounds (typical when zoomed-out so the on-screen // viewport is larger than the graph) produces negative-sized // shadow strips that don't draw, leaving the unscoped area - // un-shaded. + // unshaded. var clipped = new Rect( Math.Max(vp.X, contentRect.X), Math.Max(vp.Y, contentRect.Y), @@ -576,7 +580,7 @@ public override void Render(DrawingContext dc) dc.DrawRectangle(ViewportBrush, ViewportPen, vp); } - else if (ShadowBrush != null && ContentBounds.Width > 0 && ContentBounds.Height > 0) + else if (ShadowBrush != null && ContentBounds is { Width: > 0, Height: > 0 }) { // No part of the viewport is visible → entire content area is "off screen". dc.DrawRectangle(ShadowBrush, null, ContentBounds); @@ -594,10 +598,6 @@ private void DrawContentBrush(DrawingContext dc) if (Scale <= 0 || ContentBounds.Width <= 0 || ContentBounds.Height <= 0) return; if (ZoomControl?.ContentVisual is not { } visual) return; if (_contentRect.Width <= 0 || _contentRect.Height <= 0) return; - - // Rebuild the brush iff the source visual changed. The VisualBrush - // walks the live visual tree on every render, so a single instance - // covers all subsequent frames as long as the source visual is stable. if (!ReferenceEquals(_brushSourceVisual, visual) || _contentBrush == null) { _brushSourceVisual = visual; @@ -611,14 +611,20 @@ private void DrawContentBrush(DrawingContext dc) }; } - // Tell the brush to sample exactly the trackable content rectangle — - // not the visual's DesiredSize, which is a 10x10 sentinel for - // GraphAreaBase. RelativeUnit.Absolute treats the rect as raw pixels - // in the source visual's local coordinate space. - _contentBrush.SourceRect = new RelativeRect(_contentRect, RelativeUnit.Absolute); + // TrackableContent.ContentSize is expressed in graph coordinates, but + // the content visual is arranged to the extent size. Positive graph + // offsets must therefore not be used as a VisualBrush source offset: + // doing so skips the top/left of the visual and clips the bottom/right + // by the same amount. + var sourceRect = new Rect( + _contentRect.X > 0 ? 0 : _contentRect.X, + _contentRect.Y > 0 ? 0 : _contentRect.Y, + _contentRect.Width, + _contentRect.Height); + _contentBrush.SourceRect = new RelativeRect(sourceRect, RelativeUnit.Absolute); dc.DrawRectangle(_contentBrush, null, ContentBounds); } #endregion -} +} \ No newline at end of file diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs index 2e9e045..199e352 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs @@ -637,6 +637,13 @@ private void MouseWheelAction(Vector delta, Point mousePosition) private void OnPointerUp(object? sender, PointerReleasedEventArgs e) { PointerMoved -= ZoomControl_PreviewMouseMove; + CompleteInteraction(); + if (Equals(e.Pointer.Captured, this)) + e.Pointer.Capture(null); + } + + private void CompleteInteraction() + { if (_clickTrack) { RaiseEvent(new RoutedEventArgs(ClickEvent)); @@ -655,8 +662,6 @@ private void OnPointerUp(object? sender, PointerReleasedEventArgs e) } ModifierMode = ZoomViewModifierMode.None; - if (Equals(e.Pointer.Captured, this)) - e.Pointer.Capture(null); } private void PanAction(Vector initialPoint, Vector diff) @@ -667,22 +672,27 @@ private void PanAction(Vector initialPoint, Vector diff) } private void ZoomControl_PreviewMouseMove(object? sender, PointerEventArgs e) + { + MoveInteraction(e.GetPosition(this)); + } + + private void MoveInteraction(Point currentPosition) { if (_clickTrack) { - var curPoint = e.GetPosition(this); - if (curPoint != _mouseDownPos) _clickTrack = false; + if (currentPosition != _mouseDownPos) _clickTrack = false; } switch (ModifierMode) { case ZoomViewModifierMode.None: return; - case ZoomViewModifierMode.Pan: PanAction(_startTranslate, e.GetPosition(this) - _mouseDownPos); break; + case ZoomViewModifierMode.Pan: PanAction(_startTranslate, currentPosition - _mouseDownPos); break; case ZoomViewModifierMode.ZoomBox: - var pos = e.GetPosition(this); - var x = Math.Min(_mouseDownPos.X, pos.X); - var y = Math.Min(_mouseDownPos.Y, pos.Y); - ZoomBox = new Rect(x, y, Math.Abs(_mouseDownPos.X - pos.X), Math.Abs(_mouseDownPos.Y - pos.Y)); + var x = Math.Min(_mouseDownPos.X, currentPosition.X); + var y = Math.Min(_mouseDownPos.Y, currentPosition.Y); + var width = Math.Abs(_mouseDownPos.X - currentPosition.X); + var height = Math.Abs(_mouseDownPos.Y - currentPosition.Y); + ZoomBox = new Rect(x, y, width, height); break; case ZoomViewModifierMode.ZoomIn: case ZoomViewModifierMode.ZoomOut: @@ -694,9 +704,18 @@ private void ZoomControl_PreviewMouseMove(object? sender, PointerEventArgs e) private void OnPointerDown(PointerPressedEventArgs e) { - if (ModifierMode != ZoomViewModifierMode.None) return; + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; + if (!BeginInteraction(e.KeyModifiers, e.GetPosition(this))) return; + + e.Pointer.Capture(this); + PointerMoved += ZoomControl_PreviewMouseMove; + } + + private bool BeginInteraction(KeyModifiers keyModifiers, Point position) + { + if (ModifierMode != ZoomViewModifierMode.None) return false; _startedAsAreaSelection = false; - switch (e.KeyModifiers) + switch (keyModifiers) { case KeyModifiers.None: if (IsDragSelectByDefault) @@ -708,24 +727,32 @@ private void OnPointerDown(PointerPressedEventArgs e) break; case KeyModifiers.Alt | KeyModifiers.Control: - case KeyModifiers.Alt: _startedAsAreaSelection = true; ModifierMode = ZoomViewModifierMode.ZoomBox; break; + case KeyModifiers.Alt: + ModifierMode = ZoomViewModifierMode.ZoomBox; + break; case KeyModifiers.Shift: ModifierMode = ZoomViewModifierMode.Pan; break; case KeyModifiers.Control: case KeyModifiers.Meta: - default: return; + default: return false; } _clickTrack = true; - _mouseDownPos = e.GetPosition(this); - if (ModifierMode == ZoomViewModifierMode.None) return; + _mouseDownPos = position; + if (ModifierMode == ZoomViewModifierMode.None) return false; _startTranslate = new Vector(TranslateX, TranslateY); - e.Pointer.Capture(this); - PointerMoved += ZoomControl_PreviewMouseMove; + return true; } + internal bool BeginInteractionForTest(KeyModifiers keyModifiers, Point position) => + BeginInteraction(keyModifiers, position); + + internal void MoveInteractionForTest(Point position) => MoveInteraction(position); + + internal void CompleteInteractionForTest() => CompleteInteraction(); + public static readonly RoutedEvent ClickEvent = RoutedEvent.Register(nameof(Click), RoutingStrategies.Bubble);