Browse Source

Render About-page links via LinkElementGenerator (regex)

Replaces the previous WriteReference(string, Uri) approach with a
LinkElementGenerator subclass that scans the rendered document for the
target phrases ("MIT License" / "third-party notices"), matching the
WPF host's MyLinkElementGenerator pattern. The custom generator collection
travels through AvaloniaEditTextOutput → DecompilerTabPageModel.
CustomElementGenerators → DecompilerTextView (which installs and tears
them down per ApplyDocument).

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
831b21ac5a
  1. 30
      ILSpy.Tests/Commands/HelpCommandTests.cs
  2. 76
      ILSpy/Commands/AboutCommand.cs
  3. 19
      ILSpy/TextView/AvaloniaEditTextOutput.cs
  4. 30
      ILSpy/TextView/DecompilerTabPageModel.cs
  5. 33
      ILSpy/TextView/DecompilerTextView.axaml.cs
  6. 7
      ILSpy/TextView/ISmartTextOutput.cs

30
ILSpy.Tests/Commands/HelpCommandTests.cs

@ -80,11 +80,13 @@ public class HelpCommandTests @@ -80,11 +80,13 @@ public class HelpCommandTests
[AvaloniaTest]
public async Task About_Page_MIT_License_Link_Opens_License_Tab_On_Click()
{
// "MIT License" inside the About blurb must be a clickable reference. Activating it
// (the same path DecompilerTextView's pointer click takes — RaiseNavigateRequested)
// must open the embedded LICENSE text in a new tab.
// "MIT License" inside the About blurb must be rendered as an AvaloniaEdit hyperlink
// pointing at "resource:license.txt", and activating it must open the embedded LICENSE
// text in a new tab. Tests the full pipeline: the tab carries a custom element
// generator (matching the WPF host's LinkElementGenerator pattern), and the tab's
// OpenUriRequested handler resolves the resource: URI to a new tab.
// Arrange — boot the window and open the About page.
// Arrange — boot, open the About page.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
@ -99,18 +101,20 @@ public class HelpCommandTests @@ -99,18 +101,20 @@ public class HelpCommandTests
await Waiters.WaitForAsync(
() => documents.ActiveDockable is DecompilerTabPageModel { Text.Length: > 0 });
var aboutTab = (DecompilerTabPageModel)documents.ActiveDockable!;
// Assert (mid-test) — the tab carries the custom hyperlink generators that
// DecompilerTextView installs alongside the document.
aboutTab.CustomElementGenerators.Should().NotBeNull();
aboutTab.CustomElementGenerators!.Should().NotBeEmpty(
"the About page must contribute LinkElementGenerator(s) for MIT License + third-party notices");
var beforeCount = documents.VisibleDockables?.Count ?? 0;
// Act — find the MIT License reference segment and fire NavigateRequested (same path
// the live pointer-click handler takes).
aboutTab.References.Should().NotBeNull();
var licenseSegment = aboutTab.References!
.Single(r => r.Reference is Uri u && u.OriginalString.Contains("license", StringComparison.OrdinalIgnoreCase));
var matched = aboutTab.Text.Substring(licenseSegment.StartOffset, licenseSegment.Length);
matched.Should().Be("MIT License");
aboutTab.RaiseNavigateRequested(licenseSegment);
// Act — fire the same OpenUri routed event that AvaloniaEdit would raise on click.
aboutTab.RaiseOpenUriRequested(new Uri("resource:license.txt"))
.Should().BeTrue("the About tab must claim the resource: URI as handled");
// Assert — a new tab landed in the document dock containing the license body.
// Assert — a new tab opens with the license body.
await Waiters.WaitForAsync(
() => (documents.VisibleDockables?.Count ?? 0) > beforeCount
&& documents.ActiveDockable is DecompilerTabPageModel licenseTab

76
ILSpy/Commands/AboutCommand.cs

@ -21,7 +21,9 @@ using System.Collections.Generic; @@ -21,7 +21,9 @@ using System.Collections.Generic;
using System.Composition;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using AvaloniaEdit.Rendering;
using ICSharpCode.Decompiler;
using ICSharpCode.ILSpy.Properties;
@ -38,7 +40,7 @@ namespace ILSpy.Commands @@ -38,7 +40,7 @@ namespace ILSpy.Commands
// Phrases inside the About blurb that should render as clickable hyperlinks. The Uri
// uses the custom "resource:" scheme; the click handler reads the matching name as an
// embedded manifest resource on this assembly.
static readonly (string Text, Uri Uri)[] Links = {
static readonly (string Phrase, Uri Uri)[] Links = {
("MIT License", new Uri("resource:license.txt")),
("third-party notices", new Uri("resource:third-party-notices.txt")),
};
@ -71,46 +73,14 @@ namespace ILSpy.Commands @@ -71,46 +73,14 @@ namespace ILSpy.Commands
{
using var reader = new StreamReader(stream);
while (reader.ReadLine() is { } line)
WriteLineWithLinks(output, line);
output.WriteLine(line);
}
}
OpenInNewTab(Resources.About, output, ".txt");
}
foreach (var (phrase, uri) in Links)
output.AddVisualLineElementGenerator(new ResourceLinkGenerator(phrase, uri));
static void WriteLineWithLinks(AvaloniaEditTextOutput output, string line)
{
int cursor = 0;
while (cursor < line.Length)
{
int nearestStart = -1;
(string Text, Uri Uri) nearest = default;
foreach (var link in Links)
{
int idx = line.IndexOf(link.Text, cursor, StringComparison.Ordinal);
if (idx < 0)
continue;
if (nearestStart < 0 || idx < nearestStart)
{
nearestStart = idx;
nearest = link;
}
}
if (nearestStart < 0)
{
output.Write(line.Substring(cursor));
break;
}
if (nearestStart > cursor)
output.Write(line.Substring(cursor, nearestStart - cursor));
// nearestStart >= 0 here implies the foreach assigned `nearest = link` at least
// once, so neither tuple field is the default-null left behind by `nearest = default`.
Debug.Assert(nearest.Text != null);
Debug.Assert(nearest.Uri != null);
output.WriteReference(nearest.Text, nearest.Uri);
cursor = nearestStart + nearest.Text.Length;
}
output.WriteLine();
OpenInNewTab(Resources.About, output, ".txt");
}
void OpenInNewTab(string title, AvaloniaEditTextOutput output, string syntaxExtension)
@ -124,17 +94,20 @@ namespace ILSpy.Commands @@ -124,17 +94,20 @@ namespace ILSpy.Commands
DefinitionLookup = output.DefinitionLookup,
UIElements = output.UIElements,
Foldings = output.Foldings,
CustomElementGenerators = output.ElementGenerators.Count > 0
? new List<VisualLineElementGenerator>(output.ElementGenerators)
: null,
};
tab.NavigateRequested += OnLinkClicked;
tab.OpenUriRequested += OnOpenUri;
dockWorkspace.OpenNewTab(tab);
}
void OnLinkClicked(ReferenceSegment segment)
bool OnOpenUri(Uri uri)
{
if (segment.Reference is not Uri uri
|| !string.Equals(uri.Scheme, "resource", StringComparison.OrdinalIgnoreCase))
return;
if (!string.Equals(uri.Scheme, "resource", StringComparison.OrdinalIgnoreCase))
return false;
OpenEmbeddedResource(uri.AbsolutePath);
return true;
}
void OpenEmbeddedResource(string resourceName)
@ -157,6 +130,23 @@ namespace ILSpy.Commands @@ -157,6 +130,23 @@ namespace ILSpy.Commands
return FileVersionInfo.GetVersionInfo(location).ProductVersion ?? "UNKNOWN";
return typeof(object).Assembly.GetName().Version?.ToString() ?? "UNKNOWN";
}
// Specialised LinkElementGenerator that matches a single literal phrase and produces a
// VisualLineLinkText pointing at a fixed Uri. Mirrors the "MyLinkElementGenerator"
// pattern from the legacy WPF host.
sealed class ResourceLinkGenerator : LinkElementGenerator
{
readonly Uri target;
public ResourceLinkGenerator(string phrase, Uri target)
: base(new Regex(Regex.Escape(phrase)))
{
this.target = target;
RequireControlModifierForClick = false;
}
protected override Uri GetUriFromMatch(Match match) => target;
}
}
/// <summary>

19
ILSpy/TextView/AvaloniaEditTextOutput.cs

@ -26,6 +26,7 @@ using Avalonia.Controls; @@ -26,6 +26,7 @@ using Avalonia.Controls;
using AvaloniaEdit.Document;
using AvaloniaEdit.Folding;
using AvaloniaEdit.Highlighting;
using AvaloniaEdit.Rendering;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Disassembler;
@ -71,6 +72,15 @@ namespace ILSpy.TextView @@ -71,6 +72,15 @@ namespace ILSpy.TextView
/// <see cref="UIElementGenerator"/> by the text view.</summary>
public IReadOnlyList<KeyValuePair<int, Lazy<Control>>> UIElements => uiElements;
readonly List<VisualLineElementGenerator> elementGenerators = new();
/// <summary>
/// Custom <see cref="VisualLineElementGenerator"/>s contributed by the writer (e.g. a
/// regex-based hyperlink generator). Installed on the text view alongside the
/// document and torn down again when the document is replaced.
/// </summary>
public IReadOnlyList<VisualLineElementGenerator> ElementGenerators => elementGenerators;
public string Title { get; set; } = string.Empty;
/// <summary>
@ -145,9 +155,6 @@ namespace ILSpy.TextView @@ -145,9 +155,6 @@ namespace ILSpy.TextView
public void WriteReference(IMember member, string text, bool isDefinition = false)
=> AddReference(text, member, local: false, isDefinition);
public void WriteReference(string text, Uri target)
=> AddReference(text, target, local: false, isDefinition: false);
public void WriteLocalReference(string text, object reference, bool isDefinition = false)
=> AddReference(text, reference, local: true, isDefinition);
@ -201,6 +208,12 @@ namespace ILSpy.TextView @@ -201,6 +208,12 @@ namespace ILSpy.TextView
uiElements.Add(new KeyValuePair<int, Lazy<Control>>(builder.Length, new Lazy<Control>(element)));
}
public void AddVisualLineElementGenerator(VisualLineElementGenerator generator)
{
ArgumentNullException.ThrowIfNull(generator);
elementGenerators.Add(generator);
}
public void BeginSpan(HighlightingColor highlightingColor)
{
openSpans.Push((builder.Length, highlightingColor));

30
ILSpy/TextView/DecompilerTabPageModel.cs

@ -29,6 +29,7 @@ using Avalonia.Threading; @@ -29,6 +29,7 @@ using Avalonia.Threading;
using AvaloniaEdit.Document;
using AvaloniaEdit.Folding;
using AvaloniaEdit.Highlighting;
using AvaloniaEdit.Rendering;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -100,6 +101,14 @@ namespace ILSpy.TextView @@ -100,6 +101,14 @@ namespace ILSpy.TextView
[ObservableProperty]
private IReadOnlyList<KeyValuePair<int, Lazy<Control>>>? uIElements;
/// <summary>
/// Custom <see cref="VisualLineElementGenerator"/>s the writer attached (e.g. a
/// regex-based hyperlink generator). The text view installs them alongside the
/// document and removes them again when the document changes.
/// </summary>
[ObservableProperty]
private IReadOnlyList<VisualLineElementGenerator>? customElementGenerators;
/// <summary>
/// Fired when the user clicks a cross-document reference. The host (DockWorkspace)
/// resolves the target on the assembly tree side.
@ -109,6 +118,27 @@ namespace ILSpy.TextView @@ -109,6 +118,27 @@ namespace ILSpy.TextView
internal void RaiseNavigateRequested(ReferenceSegment segment)
=> NavigateRequested?.Invoke(segment);
/// <summary>
/// Fired when the user activates an AvaloniaEdit hyperlink. Subscribers return
/// <see langword="true"/> if they handled the URI; the text view then suppresses the
/// default <see cref="System.Diagnostics.Process.Start(System.Diagnostics.ProcessStartInfo)"/>
/// fallback that AvaloniaEdit would otherwise run for the URI.
/// </summary>
public event System.Func<System.Uri, bool>? OpenUriRequested;
internal bool RaiseOpenUriRequested(System.Uri uri)
{
var handlers = OpenUriRequested;
if (handlers == null)
return false;
foreach (System.Func<System.Uri, bool> handler in handlers.GetInvocationList())
{
if (handler(uri))
return true;
}
return false;
}
IReadOnlyList<ILSpyTreeNode> currentNodes = System.Array.Empty<ILSpyTreeNode>();
[RelayCommand]

33
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -65,6 +65,7 @@ namespace ILSpy.TextView @@ -65,6 +65,7 @@ namespace ILSpy.TextView
readonly UIElementGenerator uiElementGenerator;
readonly TextMarkerService textMarkerService;
readonly List<TextMarker> localReferenceMarks = new();
readonly List<AvaloniaEdit.Rendering.VisualLineElementGenerator> activeCustomGenerators = new();
RichTextColorizer? activeColorizer;
FoldingManager? activeFoldingManager;
ReferenceSegment? lastTooltipSegment;
@ -109,6 +110,15 @@ namespace ILSpy.TextView @@ -109,6 +110,15 @@ namespace ILSpy.TextView
Editor.TextArea.TextView.PointerMoved += OnTextViewPointerMoved;
Editor.TextArea.TextView.PointerExited += OnTextViewPointerExited;
// AvaloniaEdit's hyperlinks raise OpenUriEvent (bubbling); the default class handler
// on Window passes the URI to Process.Start. Intercept first so internal schemes
// (the About page's "resource:" URIs) route through the tab model instead.
Editor.TextArea.TextView.AddHandler(
AvaloniaEdit.Rendering.VisualLineLinkText.OpenUriEvent,
OnHyperlinkOpenUri,
RoutingStrategies.Bubble,
handledEventsToo: false);
richPopup = new Popup {
PlacementTarget = Editor.TextArea.TextView,
// Pointer placement: Avalonia anchors the popup at the current cursor location
@ -193,11 +203,34 @@ namespace ILSpy.TextView @@ -193,11 +203,34 @@ namespace ILSpy.TextView
referenceElementGenerator.References = model.References;
uiElementGenerator.UIElements = model.UIElements;
// Swap any tab-contributed visual-line element generators (e.g. About-page hyperlink
// generators). Tracked separately from the always-on referenceElementGenerator and
// uiElementGenerator so the two sets don't collide on tab change.
var generators = Editor.TextArea.TextView.ElementGenerators;
foreach (var g in activeCustomGenerators)
generators.Remove(g);
activeCustomGenerators.Clear();
if (model.CustomElementGenerators is { Count: > 0 } modelGenerators)
{
foreach (var g in modelGenerators)
{
generators.Add(g);
activeCustomGenerators.Add(g);
}
}
Editor.TextArea.TextView.Redraw();
Editor.ScrollToHome();
}
void OnHyperlinkOpenUri(object? sender, AvaloniaEdit.Rendering.OpenUriRoutedEventArgs e)
{
if (DataContext is DecompilerTabPageModel model && model.RaiseOpenUriRequested(e.Uri))
e.Handled = true;
}
void OnReferenceClicked(ReferenceSegment segment)
{
if (DataContext is not DecompilerTabPageModel model || segment.Reference == null)

7
ILSpy/TextView/ISmartTextOutput.cs

@ -21,6 +21,7 @@ using System; @@ -21,6 +21,7 @@ using System;
using Avalonia.Controls;
using AvaloniaEdit.Highlighting;
using AvaloniaEdit.Rendering;
using ICSharpCode.Decompiler;
@ -38,6 +39,12 @@ namespace ILSpy.TextView @@ -38,6 +39,12 @@ namespace ILSpy.TextView
/// </summary>
void AddUIElement(Func<Control> element);
/// <summary>
/// Contributes a <see cref="VisualLineElementGenerator"/> (typically a regex-based
/// hyperlink generator) that the text view installs alongside the document.
/// </summary>
void AddVisualLineElementGenerator(VisualLineElementGenerator generator);
void BeginSpan(HighlightingColor highlightingColor);
void EndSpan();

Loading…
Cancel
Save