From 37b381b87ae616716bbfebc7f3b38500fbaca28d Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 11 Jun 2026 17:34:20 +0200 Subject: [PATCH] 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 --- ILSpy.Tests/Editor/DocumentationLinkTests.cs | 140 +++++++++++++++++++ ILSpy/AssemblyTree/AssemblyTreeModel.cs | 2 +- ILSpy/TextView/DecompilerTextView.axaml.cs | 65 +++++++-- ILSpy/TextView/DocumentationRenderer.cs | 104 +++++++++++--- 4 files changed, 277 insertions(+), 34 deletions(-) create mode 100644 ILSpy.Tests/Editor/DocumentationLinkTests.cs 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);