Browse Source

Make documentation tooltip references clickable hyperlinks

Doc-comment crefs in hover tooltips rendered as link-styled but inert
text, and cref resolution was never wired up. Crefs now resolve against
the current tab's assembly list and navigate through the same
NavigateToReferenceEventArgs channel the analyzer uses; href links open
via the TopLevel launcher. Avalonia inlines are not input elements, so
links are hit-testable TextBlocks embedded via InlineUIContainer.

Interactive content also requires the popup to survive the pointer
travelling onto it: the editor's exit handler now tolerates the
overlay-popup enter/exit ordering via the distance corridor, and the
popup closes when the pointer leaves its own content instead.

Assisted-by: Claude:claude-fable-5:Claude Code
pull/3776/head
Siegfried Pammer 4 weeks ago committed by Siegfried Pammer
parent
commit
37b381b87a
  1. 140
      ILSpy.Tests/Editor/DocumentationLinkTests.cs
  2. 2
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  3. 65
      ILSpy/TextView/DecompilerTextView.axaml.cs
  4. 104
      ILSpy/TextView/DocumentationRenderer.cs

140
ILSpy.Tests/Editor/DocumentationLinkTests.cs

@ -0,0 +1,140 @@ @@ -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;
/// <summary>
/// Pins the hyperlink behaviour of documentation tooltips: a resolvable
/// <c>&lt;see cref="..."/&gt;</c> must render as a clickable link that requests
/// navigation to the referenced entity (via <see cref="MessageBus{T}"/> with
/// <see cref="NavigateToReferenceEventArgs"/>, the same channel the analyzer uses) and
/// must notify the hosting popup through <see cref="DocumentationRenderer.LinkClicked"/>
/// so it can close.
/// </summary>
[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<TypeTreeNode>(coreLibName, "System", "System.String").Member!;
var renderer = new DocumentationRenderer(
new CSharpAmbience(),
new FontFamily("Consolas, Menlo, Monospace"),
12);
renderer.AddXmlDocumentation(
"""<summary>Implemented by <see cref="T:System.String"/> instances.</summary>""",
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<TextBlock>()
.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<NavigateToReferenceEventArgs> capture = (_, e) => navigated = e.Reference;
MessageBus<NavigateToReferenceEventArgs>.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<NavigateToReferenceEventArgs>.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(
"""<summary>See <see cref="T:Does.Not.Exist"/>.</summary>""",
declaringEntity: null,
resolver: _ => null);
var window = new Window { Content = renderer.CreateView() };
window.Show();
window.UpdateLayout();
window.GetVisualDescendants().OfType<TextBlock>()
.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");
}
}

2
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -733,7 +733,7 @@ namespace ILSpy.AssemblyTree @@ -733,7 +733,7 @@ namespace ILSpy.AssemblyTree
}
}
static IEntity? FindEntityInRelevantAssemblies(string navigateTo, IEnumerable<LoadedAssembly> relevantAssemblies)
internal static IEntity? FindEntityInRelevantAssemblies(string navigateTo, IEnumerable<LoadedAssembly> relevantAssemblies)
{
ITypeReference typeRef;
IMemberReference? memberRef = null;

65
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -886,9 +886,15 @@ namespace ILSpy.TextView @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 <see cref="..."/> 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 @@ -1011,6 +1034,18 @@ namespace ILSpy.TextView
}
}
/// <summary>
/// Resolves a doc-comment cref id string (e.g. <c>T:System.String</c>) against the
/// assembly list the current tab decompiled from.
/// </summary>
IEntity? ResolveDocReference(string idString)
{
if (DataContext is not DecompilerTabPageModel model || model.CurrentNode is not { } node)
return null;
var list = node.AncestorsAndSelf().OfType<TreeNodes.AssemblyListTreeNode>().FirstOrDefault()?.AssemblyList;
return list == null ? null : AssemblyTree.AssemblyTreeModel.FindEntityInRelevantAssemblies(idString, list.GetAssemblies());
}
object? ResolveEntity(DecompilerTabPageModel model, object? reference)
{
switch (reference)

104
ILSpy/TextView/DocumentationRenderer.cs

@ -27,6 +27,7 @@ using Avalonia; @@ -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; @@ -36,6 +37,8 @@ using ICSharpCode.Decompiler.Documentation;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
using ILSpy.Util;
namespace ILSpy.TextView
{
/// <summary>
@ -45,8 +48,6 @@ namespace ILSpy.TextView @@ -45,8 +48,6 @@ namespace ILSpy.TextView
/// </summary>
public sealed class DocumentationRenderer
{
// Hyperlink colour for non-clickable <see cref> 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 @@ -97,6 +98,12 @@ namespace ILSpy.TextView
public string? ParameterName { get; set; }
/// <summary>
/// Raised after the user follows any link in the rendered documentation, so a
/// hosting popup can close itself while the navigation takes effect.
/// </summary>
public event EventHandler? LinkClicked;
/// <summary>
/// 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 @@ -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 };
}
/// <summary>
/// Renders <paramref name="children"/> into a clickable link (used by
/// <c>&lt;see&gt;</c> elements that carry their own display text).
/// </summary>
void AddLinkSpan(Action onClick, IList<XmlDocumentationElement> 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<XmlDocumentationElement> children)
@ -420,11 +485,9 @@ namespace ILSpy.TextView @@ -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 @@ -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 @@ -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);

Loading…
Cancel
Save