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