diff --git a/ILSpy.Tests/Editor/DocumentationLinkTests.cs b/ILSpy.Tests/Editor/DocumentationLinkTests.cs
new file mode 100644
index 000000000..00c51739b
--- /dev/null
+++ b/ILSpy.Tests/Editor/DocumentationLinkTests.cs
@@ -0,0 +1,140 @@
+// 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;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.CSharp.OutputVisitor;
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ILSpy.AssemblyTree;
+using ILSpy.TextView;
+using ILSpy.TreeNodes;
+using ILSpy.Util;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.TextView;
+
+///
+/// Pins the hyperlink behaviour of documentation tooltips: a resolvable
+/// <see cref="..."/> must render as a clickable link that requests
+/// navigation to the referenced entity (via with
+/// , the same channel the analyzer uses) and
+/// must notify the hosting popup through
+/// so it can close.
+///
+[TestFixture]
+public class DocumentationLinkTests
+{
+ [AvaloniaTest]
+ public async Task SeeCref_Renders_As_A_Link_That_Navigates_On_Click()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var stringType = (IEntity)vm.AssemblyTreeModel
+ .FindNode(coreLibName, "System", "System.String").Member!;
+
+ var renderer = new DocumentationRenderer(
+ new CSharpAmbience(),
+ new FontFamily("Consolas, Menlo, Monospace"),
+ 12);
+ renderer.AddXmlDocumentation(
+ """Implemented by instances.""",
+ declaringEntity: null,
+ resolver: id => id == "T:System.String" ? stringType : null);
+
+ var window = new Window { Content = renderer.CreateView() };
+ window.Show();
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ Avalonia.Threading.Dispatcher.UIThread.RunJobs();
+ window.UpdateLayout();
+
+ var link = window.GetVisualDescendants().OfType()
+ .FirstOrDefault(tb => tb.Classes.Contains("doc-link"));
+ link.Should().NotBeNull("a resolvable cref must render as a clickable link");
+ link!.Cursor.Should().NotBeNull("links show the hand cursor as a click affordance");
+
+ object? navigated = null;
+ var linkClickedRaised = false;
+ renderer.LinkClicked += (_, _) => linkClickedRaised = true;
+ EventHandler capture = (_, e) => navigated = e.Reference;
+ MessageBus.Subscribers += capture;
+ try
+ {
+ var centre = link.TranslatePoint(
+ new Point(link.Bounds.Width / 2, link.Bounds.Height / 2), window)!.Value;
+ window.MouseDown(centre, MouseButton.Left);
+ window.MouseUp(centre, MouseButton.Left);
+ }
+ finally
+ {
+ MessageBus.Subscribers -= capture;
+ }
+
+ navigated.Should().BeSameAs(stringType,
+ "clicking the link must request navigation to the referenced entity");
+ linkClickedRaised.Should().BeTrue(
+ "the hosting popup closes itself when a link is followed");
+ }
+
+ [AvaloniaTest]
+ public void Unresolvable_Cref_Falls_Back_To_Plain_Text()
+ {
+ var renderer = new DocumentationRenderer(
+ new CSharpAmbience(),
+ new FontFamily("Consolas, Menlo, Monospace"),
+ 12);
+ renderer.AddXmlDocumentation(
+ """See .""",
+ declaringEntity: null,
+ resolver: _ => null);
+
+ var window = new Window { Content = renderer.CreateView() };
+ window.Show();
+ window.UpdateLayout();
+
+ window.GetVisualDescendants().OfType()
+ .Should().NotContain(tb => tb.Classes.Contains("doc-link"),
+ "an unresolvable cref renders as plain text, not a dead link");
+ }
+
+ [AvaloniaTest]
+ public async Task FindEntityInRelevantAssemblies_Resolves_A_Type_IdString()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var list = vm.AssemblyTreeModel.AssemblyList!;
+
+ var entity = AssemblyTreeModel.FindEntityInRelevantAssemblies(
+ "T:System.String", list.GetAssemblies());
+
+ entity.Should().NotBeNull("the doc-comment cref resolver feeds on id strings");
+ entity!.FullName.Should().Be("System.String");
+ }
+}
diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs
index 9cc3fcb22..4565b501b 100644
--- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs
+++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs
@@ -733,7 +733,7 @@ namespace ILSpy.AssemblyTree
}
}
- static IEntity? FindEntityInRelevantAssemblies(string navigateTo, IEnumerable relevantAssemblies)
+ internal static IEntity? FindEntityInRelevantAssemblies(string navigateTo, IEnumerable relevantAssemblies)
{
ITypeReference typeRef;
IMemberReference? memberRef = null;
diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs
index 05dfc79a7..6f573498d 100644
--- a/ILSpy/TextView/DecompilerTextView.axaml.cs
+++ b/ILSpy/TextView/DecompilerTextView.axaml.cs
@@ -886,9 +886,15 @@ namespace ILSpy.TextView
void OnTextViewPointerExited(object? sender, PointerEventArgs e)
{
// Don't close the rich popup if the pointer just moved from the editor onto the
- // popup itself — the user is reaching for it. Plain tooltips always close.
- if (richPopup.IsOpen && richPopup.Child is { IsPointerOver: true })
+ // popup itself — the user is reaching for it. The overlay popup delivers the
+ // editor's exit BEFORE the popup child's IsPointerOver flips, so the flag alone
+ // would close the popup right under the pointer; the distance check covers that
+ // ordering. Plain tooltips always close.
+ if (richPopup.IsOpen
+ && (richPopup.Child is { IsPointerOver: true } || GetDistanceToPopup(e) <= MaxMovementAwayFromPopup))
+ {
return;
+ }
TryCloseExistingPopup(mouseClick: false);
}
@@ -904,10 +910,19 @@ namespace ILSpy.TextView
void OpenRichPopup(Control content)
{
richPopup.Child = content;
+ // While the pointer is over the popup, the editor no longer receives the moves
+ // that drive the distance corridor — the popup's lifetime is governed by leaving
+ // its own content instead.
+ content.PointerExited += OnRichPopupContentPointerExited;
richPopup.Open();
distanceToPopupLimit = double.PositiveInfinity;
}
+ void OnRichPopupContentPointerExited(object? sender, PointerEventArgs e)
+ {
+ TryCloseExistingPopup(mouseClick: false);
+ }
+
double GetDistanceToPopup(PointerEventArgs e)
{
if (richPopup.Child is not Control child)
@@ -936,7 +951,11 @@ namespace ILSpy.TextView
void CloseRichPopup()
{
if (richPopup.IsOpen)
+ {
+ if (richPopup.Child is { } child)
+ child.PointerExited -= OnRichPopupContentPointerExited;
richPopup.Close();
+ }
}
void OnRichPopupClosed(object? sender, EventArgs e)
@@ -954,10 +973,7 @@ namespace ILSpy.TextView
var rich = language.GetRichTextTooltip(entity);
if (rich == null || string.IsNullOrEmpty(rich.Text))
return null;
- var renderer = new DocumentationRenderer(
- new CSharpAmbience(),
- new FontFamily("Consolas, Menlo, Monospace"),
- 12);
+ var renderer = CreateTooltipRenderer();
renderer.AddSignatureBlock(rich);
AppendXmlDocumentation(renderer, entity);
return new HoverContent(renderer.CreateView(), IsRich: true);
@@ -968,12 +984,9 @@ namespace ILSpy.TextView
?.GetDocumentation("F:System.Reflection.Emit.OpCodes." + op.EncodedName);
if (opDocs != null)
{
- var opRenderer = new DocumentationRenderer(
- new CSharpAmbience(),
- new FontFamily("Consolas, Menlo, Monospace"),
- 12);
+ var opRenderer = CreateTooltipRenderer();
opRenderer.AddSignatureBlock(new RichText($"{op.Name} (0x{op.Code:x})"));
- opRenderer.AddXmlDocumentation(opDocs, declaringEntity: null, resolver: null);
+ opRenderer.AddXmlDocumentation(opDocs, declaringEntity: null, resolver: ResolveDocReference);
return new HoverContent(opRenderer.CreateView(), IsRich: true);
}
var opBlock = new SelectableTextBlock {
@@ -989,7 +1002,19 @@ namespace ILSpy.TextView
}
}
- static void AppendXmlDocumentation(DocumentationRenderer renderer, IEntity entity)
+ // Hover tooltips share one renderer setup: monospace signature font, and any followed
+ // documentation link closes the popup so the navigation is visible underneath.
+ DocumentationRenderer CreateTooltipRenderer()
+ {
+ var renderer = new DocumentationRenderer(
+ new CSharpAmbience(),
+ new FontFamily("Consolas, Menlo, Monospace"),
+ 12);
+ renderer.LinkClicked += (_, _) => CloseRichPopup();
+ return renderer;
+ }
+
+ void AppendXmlDocumentation(DocumentationRenderer renderer, IEntity entity)
{
try
{
@@ -1001,9 +1026,7 @@ namespace ILSpy.TextView
var documentation = XmlDocLoader.LoadDocumentation(metadata)?.GetDocumentation(entity.GetIdString());
if (documentation == null)
return;
- // First-cut: no cref resolver, so falls back to printing the
- // raw cref text. Wiring resolution against the visible assembly list is a follow-up.
- renderer.AddXmlDocumentation(documentation, entity, resolver: null);
+ renderer.AddXmlDocumentation(documentation, entity, resolver: ResolveDocReference);
}
catch (XmlException)
{
@@ -1011,6 +1034,18 @@ namespace ILSpy.TextView
}
}
+ ///
+ /// Resolves a doc-comment cref id string (e.g. T:System.String) against the
+ /// assembly list the current tab decompiled from.
+ ///
+ IEntity? ResolveDocReference(string idString)
+ {
+ if (DataContext is not DecompilerTabPageModel model || model.CurrentNode is not { } node)
+ return null;
+ var list = node.AncestorsAndSelf().OfType().FirstOrDefault()?.AssemblyList;
+ return list == null ? null : AssemblyTree.AssemblyTreeModel.FindEntityInRelevantAssemblies(idString, list.GetAssemblies());
+ }
+
object? ResolveEntity(DecompilerTabPageModel model, object? reference)
{
switch (reference)
diff --git a/ILSpy/TextView/DocumentationRenderer.cs b/ILSpy/TextView/DocumentationRenderer.cs
index e30177ea6..e4f3b6873 100644
--- a/ILSpy/TextView/DocumentationRenderer.cs
+++ b/ILSpy/TextView/DocumentationRenderer.cs
@@ -27,6 +27,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Primitives;
+using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
@@ -36,6 +37,8 @@ using ICSharpCode.Decompiler.Documentation;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
+using ILSpy.Util;
+
namespace ILSpy.TextView
{
///
@@ -45,8 +48,6 @@ namespace ILSpy.TextView
///
public sealed class DocumentationRenderer
{
- // Hyperlink colour for non-clickable renderings. Plain accent + underline gives
- // users the visual cue that this is a reference; click navigation is a follow-up change.
static readonly IBrush HyperlinkBrush = new SolidColorBrush(Color.FromRgb(0x00, 0x66, 0xCC));
readonly IAmbience ambience;
@@ -97,6 +98,12 @@ namespace ILSpy.TextView
public string? ParameterName { get; set; }
+ ///
+ /// Raised after the user follows any link in the rendered documentation, so a
+ /// hosting popup can close itself while the navigation takes effect.
+ ///
+ public event EventHandler? LinkClicked;
+
///
/// Wraps the accumulated content in a chrome border + scroll viewer and returns the
/// final control tree for use in a popup.
@@ -377,12 +384,70 @@ namespace ILSpy.TextView
Inline ConvertReference(IEntity referencedEntity)
{
- // First-cut rendering: styled like a hyperlink (accent + underline) but not yet
- // clickable. Wiring NavigateRequested on the host TabPageModel is a follow-up.
- return new Run(ambience.ConvertSymbol(referencedEntity)) {
+ var linkText = CreateLinkTextBlock();
+ linkText.Text = ambience.ConvertSymbol(referencedEntity);
+ return CreateLink(linkText, () => MessageBus.Send(this, new NavigateToReferenceEventArgs(referencedEntity)));
+ }
+
+ // Inlines (Run/Span) are not InputElements in Avalonia, so a clickable link is an
+ // embedded TextBlock inside an InlineUIContainer.
+ static TextBlock CreateLinkTextBlock()
+ {
+ return new TextBlock {
Foreground = HyperlinkBrush,
TextDecorations = TextDecorations.Underline,
+ Cursor = new Cursor(StandardCursorType.Hand),
+ // A null background makes the TextBlock hit-test invisible — clicks would
+ // fall through to the surrounding paragraph.
+ Background = Brushes.Transparent,
+ Classes = { "doc-link" },
+ };
+ }
+
+ Inline CreateLink(TextBlock linkText, Action onClick)
+ {
+ // Press is handled so the surrounding SelectableTextBlock doesn't start a text
+ // selection (its capture would also swallow the release); the click fires on
+ // release over the link, like a button.
+ bool pressed = false;
+ linkText.PointerPressed += (_, e) => {
+ if (!e.GetCurrentPoint(linkText).Properties.IsLeftButtonPressed)
+ return;
+ pressed = true;
+ e.Handled = true;
+ };
+ linkText.PointerReleased += (_, e) => {
+ if (!pressed)
+ return;
+ pressed = false;
+ e.Handled = true;
+ onClick();
+ LinkClicked?.Invoke(this, EventArgs.Empty);
};
+ return new InlineUIContainer(linkText) { BaselineAlignment = BaselineAlignment.TextBottom };
+ }
+
+ ///
+ /// Renders into a clickable link (used by
+ /// <see> elements that carry their own display text).
+ ///
+ void AddLinkSpan(Action onClick, IList children)
+ {
+ var linkText = CreateLinkTextBlock();
+ linkText.Inlines = new InlineCollection();
+ AddInline(CreateLink(linkText, onClick));
+ var oldInline = inlineCollection;
+ try
+ {
+ inlineCollection = linkText.Inlines;
+ foreach (var child in children)
+ AddDocumentationElement(child);
+ FlushAddedText(false);
+ }
+ finally
+ {
+ inlineCollection = oldInline;
+ }
}
void AddParam(string? name, IEnumerable children)
@@ -420,11 +485,9 @@ namespace ILSpy.TextView
{
if (element.Children.Count > 0)
{
- var link = new Span {
- Foreground = HyperlinkBrush,
- TextDecorations = TextDecorations.Underline,
- };
- AddSpan(link, element.Children);
+ AddLinkSpan(
+ () => MessageBus.Send(this, new NavigateToReferenceEventArgs(referencedEntity)),
+ element.Children);
}
else
{
@@ -437,20 +500,17 @@ namespace ILSpy.TextView
}
else if (element.GetAttribute("href") is { } href)
{
- if (Uri.TryCreate(href, UriKind.Absolute, out _))
+ if (Uri.TryCreate(href, UriKind.Absolute, out var uri))
{
if (element.Children.Count > 0)
{
- AddSpan(
- new Span { Foreground = HyperlinkBrush, TextDecorations = TextDecorations.Underline },
- element.Children);
+ AddLinkSpan(() => OpenExternalUri(uri), element.Children);
}
else
{
- AddInline(new Run(href) {
- Foreground = HyperlinkBrush,
- TextDecorations = TextDecorations.Underline,
- });
+ var linkText = CreateLinkTextBlock();
+ linkText.Text = href;
+ AddInline(CreateLink(linkText, () => OpenExternalUri(uri)));
}
}
}
@@ -461,6 +521,14 @@ namespace ILSpy.TextView
}
}
+ void OpenExternalUri(Uri uri)
+ {
+ // The rendered view lives in a popup whose root is a TopLevel once shown, so
+ // the launcher is resolvable from any control of the tree.
+ if (TopLevel.GetTopLevel(rootStack) is { } topLevel)
+ topLevel.Launcher.LaunchUriAsync(uri).HandleExceptions();
+ }
+
public void AddInline(Inline inline)
{
FlushAddedText(false);