diff --git a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs index 929c2f0b0..1ad45a1d9 100644 --- a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs +++ b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs @@ -96,8 +96,8 @@ public class CaretHighlightAdornerTests ((object?)memberDef).Should().NotBeNull( "the decompiled Enumerable class contains at least one member-definition reference"); - // Same code path a real pointer-press on the member name routes through. - generator.OnReferenceClicked(memberDef!); + // Same code path a real stationary click on the member name routes through. + view.OnReferenceClicked(memberDef!); renderers.Should().Contain(r => r is CaretHighlightAdorner, "clicking a member definition must route through DecompilerTextView.OnReferenceClicked " diff --git a/ILSpy.Tests/Editor/ReferenceClickTests.cs b/ILSpy.Tests/Editor/ReferenceClickTests.cs new file mode 100644 index 000000000..58a695e88 --- /dev/null +++ b/ILSpy.Tests/Editor/ReferenceClickTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.VisualTree; + +using AvaloniaEdit; +using AvaloniaEdit.Rendering; + +using AwesomeAssertions; + +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +/// +/// Pins the reference-link click gesture in the decompiled view (WPF parity): navigation +/// happens on mouse-UP and only when the pointer did not drag, so text that belongs to a +/// link can still be selected by press-and-drag. +/// +[TestFixture] +public class ReferenceClickTests +{ + static async Task<(MainWindow Window, DecompilerTextView View, DecompilerTabPageModel Tab)> SetupAsync() + { + var (window, vm) = await TestHarness.BootAsync(); + var coreLibName = typeof(object).Assembly.GetName().Name!; + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + vm.AssemblyTreeModel.SelectNode(stringNode); + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = window.GetVisualDescendants().OfType().First(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + view.Editor.TextArea.TextView.EnsureVisualLines(); + return (window, view, tab); + } + + /// + /// Picks a navigable reference (cross-document, not a definition) within the visible + /// top of the document and returns a window-space point inside its text. + /// + static (ReferenceSegment Segment, Point Point) FindVisibleReference( + MainWindow window, DecompilerTextView view, DecompilerTabPageModel tab) + { + var textView = view.Editor.TextArea.TextView; + var segment = tab.References! + .First(r => r.Reference != null && !r.IsLocal && !r.IsDefinition); + var line = view.Editor.Document.GetLineByOffset(segment.StartOffset); + view.Editor.ScrollTo(line.LineNumber, segment.StartOffset - line.Offset + 1); + window.UpdateLayout(); + textView.EnsureVisualLines(); + var position = new TextViewPosition(line.LineNumber, segment.StartOffset - line.Offset + 2); + var visual = textView.GetVisualPosition(position, VisualYPosition.LineMiddle) - textView.ScrollOffset; + var point = textView.TranslatePoint(visual, window)!.Value; + return (segment, point); + } + + [AvaloniaTest] + public async Task Dragging_Over_A_Link_Selects_Text_Without_Navigating() + { + var (window, view, tab) = await SetupAsync(); + var (_, point) = FindVisibleReference(window, view, tab); + + var navigated = false; + tab.NavigateRequested += _ => navigated = true; + + window.MouseDown(point, MouseButton.Left); + window.MouseMove(point + new Point(60, 0)); + window.MouseUp(point + new Point(60, 0), MouseButton.Left); + + navigated.Should().BeFalse("a press-and-drag over a link is a text selection, not a click"); + view.Editor.TextArea.Selection.IsEmpty.Should().BeFalse( + "dragging across link text must produce a selection"); + } + + [AvaloniaTest] + public async Task Stationary_Click_On_A_Link_Navigates() + { + var (window, view, tab) = await SetupAsync(); + var (_, point) = FindVisibleReference(window, view, tab); + + var navigated = false; + tab.NavigateRequested += _ => navigated = true; + + window.MouseDown(point, MouseButton.Left); + window.MouseUp(point, MouseButton.Left); + + navigated.Should().BeTrue("a click without dragging follows the link"); + } +} diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index fd18ebd93..36180cb94 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -137,11 +137,62 @@ namespace ILSpy.TextView void SetupElementGenerators() { referenceElementGenerator = new ReferenceElementGenerator(static segment => segment.Reference != null); - referenceElementGenerator.ReferenceClicked += OnReferenceClicked; Editor.TextArea.TextView.ElementGenerators.Add(referenceElementGenerator); uiElementGenerator = new UIElementGenerator(); Editor.TextArea.TextView.ElementGenerators.Add(uiElementGenerator); + + // Reference navigation fires on pointer-RELEASE without drag (WPF parity: the WPF + // view used TextArea.PreviewMouseDown/Up the same way), so a press-and-drag over a + // link starts a text selection instead of navigating away. Tunnel routing mirrors + // WPF's Preview events and sees the gesture before AvaloniaEdit's own handlers. + Editor.TextArea.AddHandler(InputElement.PointerPressedEvent, + OnTextAreaPointerPressedForReferenceClick, + RoutingStrategies.Tunnel, + handledEventsToo: true); + Editor.TextArea.AddHandler(InputElement.PointerReleasedEvent, + OnTextAreaPointerReleasedForReferenceClick, + RoutingStrategies.Tunnel, + handledEventsToo: true); + } + + // Position of the last left-button press, in this control's coordinates; null while no + // press is in flight. The release compares against it to tell a click from a drag. + Point? referenceClickStart; + + // WPF used SystemParameters.MinimumHorizontal/VerticalDragDistance, which default to 4. + const double MinimumDragDistance = 4; + + void OnTextAreaPointerPressedForReferenceClick(object? sender, PointerPressedEventArgs e) + { + referenceClickStart = e.GetCurrentPoint(this).Properties.IsLeftButtonPressed + ? e.GetPosition(this) + : null; + } + + void OnTextAreaPointerReleasedForReferenceClick(object? sender, PointerReleasedEventArgs e) + { + var start = referenceClickStart; + referenceClickStart = null; + if (start == null || e.InitialPressMouseButton != MouseButton.Left) + return; + var delta = e.GetPosition(this) - start.Value; + if (Math.Abs(delta.X) >= MinimumDragDistance || Math.Abs(delta.Y) >= MinimumDragDistance) + return; + + var segment = GetReferenceSegmentAtPointer(e); + if (segment?.Reference == null) + { + // A click on empty space dismisses the local-reference highlight. + ClearLocalReferenceMarks(); + return; + } + // Cancel the caret-click selection AvaloniaEdit started on press, and stop its + // release processing, so the navigation's caret move doesn't grow a selection + // between the old anchor and the new position. + Editor.TextArea.ClearSelection(); + e.Handled = true; + OnReferenceClicked(segment); } // Background renderers that live for the view's lifetime: the local-reference highlight (marks @@ -743,7 +794,18 @@ namespace ILSpy.TextView e.Handled = true; } - void OnReferenceClicked(ReferenceSegment segment) + ReferenceSegment? GetReferenceSegmentAtPointer(PointerEventArgs e) + { + if (DataContext is not DecompilerTabPageModel model || model.References == null) + return null; + var pos = GetPositionFromPointer(e); + if (pos == null) + return null; + var offset = Editor.Document.GetOffset(pos.Value.Line, pos.Value.Column); + return model.References.FindSegmentsContaining(offset).FirstOrDefault(); + } + + internal void OnReferenceClicked(ReferenceSegment segment) { if (DataContext is not DecompilerTabPageModel model || segment.Reference == null) return; @@ -823,13 +885,9 @@ namespace ILSpy.TextView // pointer. if (!TryCloseExistingPopup(mouseClick: false)) return; - if (DataContext is not DecompilerTabPageModel model || model.References == null) + if (DataContext is not DecompilerTabPageModel model) return; - var pos = GetPositionFromPointer(e); - if (pos == null) - return; - var offset = Editor.Document.GetOffset(pos.Value.Line, pos.Value.Column); - var segment = model.References.FindSegmentsContaining(offset).FirstOrDefault(); + var segment = GetReferenceSegmentAtPointer(e); if (segment?.Reference == null) return; diff --git a/ILSpy/TextView/ReferenceElementGenerator.cs b/ILSpy/TextView/ReferenceElementGenerator.cs index 46a8b8932..e9040bddb 100644 --- a/ILSpy/TextView/ReferenceElementGenerator.cs +++ b/ILSpy/TextView/ReferenceElementGenerator.cs @@ -33,11 +33,6 @@ namespace ILSpy.TextView public TextSegmentCollection? References { get; set; } - /// Raised when a clickable reference span is activated. - public event Action? ReferenceClicked; - - internal void OnReferenceClicked(ReferenceSegment segment) => ReferenceClicked?.Invoke(segment); - public ReferenceElementGenerator(Predicate isLink) { this.isLink = isLink ?? throw new ArgumentNullException(nameof(isLink)); diff --git a/ILSpy/TextView/VisualLineReferenceText.cs b/ILSpy/TextView/VisualLineReferenceText.cs index 99961be83..537694ed0 100644 --- a/ILSpy/TextView/VisualLineReferenceText.cs +++ b/ILSpy/TextView/VisualLineReferenceText.cs @@ -24,8 +24,9 @@ namespace ILSpy.TextView { /// /// A clickable piece of text that maps back to a . Sets a hand - /// cursor for cross-document references and an arrow for in-document ones. The actual click - /// is handled by the parent generator, which routes the segment to the document's navigator. + /// cursor for cross-document references and an arrow for in-document ones. Clicks are NOT + /// handled here: navigation fires on pointer-release without drag (see the reference-click + /// handlers in ), so a press-and-drag can select link text. /// sealed class VisualLineReferenceText : VisualLineText { @@ -57,13 +58,5 @@ namespace ILSpy.TextView // suppresses PointerHoverLogic's tracking of pointer position over reference // segments. Without this, hover events fire with stale args from outside the segment. } - - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - if (e.Handled || !e.GetCurrentPoint(null).Properties.IsLeftButtonPressed) - return; - parent.OnReferenceClicked(referenceSegment); - e.Handled = true; - } } }