Browse Source

Hyperlinks in decompiler view activate on plain click

Flips Editor.Options.RequireControlModifierForHyperlinkClick to false in
DecompilerTextView's constructor — that's the value AvaloniaEdit's built-in
LinkElementGenerator forwards onto every VisualLineLinkText it constructs,
so toggling it once at the editor level removes the Ctrl requirement for
every hyperlink in the decompiled output (in-app references, About-page
resource links, future external URLs alike).

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
b969075d01
  1. 31
      ILSpy.Tests/Commands/HelpCommandTests.cs
  2. 16
      ILSpy/Commands/AboutCommand.cs
  3. 43
      ILSpy/Commands/ResourceLinkGenerator.cs
  4. 7
      ILSpy/TextView/DecompilerTextView.axaml.cs

31
ILSpy.Tests/Commands/HelpCommandTests.cs

@ -21,6 +21,7 @@ using System.Linq; @@ -21,6 +21,7 @@ using System.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using Avalonia.VisualTree;
using AwesomeAssertions;
@ -126,6 +127,36 @@ public class HelpCommandTests @@ -126,6 +127,36 @@ public class HelpCommandTests
licenseTab.Text.Should().Contain("Permission is hereby granted");
}
[AvaloniaTest]
public async Task Decompiler_Editor_Activates_Hyperlinks_Without_Ctrl_Modifier()
{
// AvaloniaEdit defaults RequireControlModifierForHyperlinkClick=true on its
// TextEditorOptions; decompiler output uses hyperlinks for in-app navigation, so a
// plain click is the expected affordance. Verifies the option is flipped on the
// realised editor — the value AvaloniaEdit's LinkElementGenerator (and our own
// ResourceLinkGenerator) both consult when constructing VisualLineLinkText.
// Arrange — boot, select a node so the decompiler tab is realised + the editor inside
// it is actually constructed (the view is data-templated lazily on first activation).
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectedItem = node;
await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Waiters.WaitForAsync(
() => window.GetVisualDescendants().OfType<DecompilerTextView>().Any());
// Act — locate the editor inside the realised DecompilerTextView.
var view = window.GetVisualDescendants().OfType<DecompilerTextView>().First();
var editor = view.GetVisualDescendants().OfType<AvaloniaEdit.TextEditor>().Single();
// Assert — the editor's hyperlink-click option is off.
editor.Options.RequireControlModifierForHyperlinkClick.Should().BeFalse(
"hyperlinks in the decompiler view must activate on a plain click");
}
[AvaloniaTest]
public async Task About_Page_Lands_On_Back_History_And_Round_Trips_Through_Navigation()
{

16
ILSpy/Commands/AboutCommand.cs

@ -136,22 +136,6 @@ namespace ILSpy.Commands @@ -136,22 +136,6 @@ namespace ILSpy.Commands
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>

43
ILSpy/Commands/ResourceLinkGenerator.cs

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// 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.Text.RegularExpressions;
using AvaloniaEdit.Rendering;
namespace ILSpy.Commands
{
/// <summary>
/// LinkElementGenerator that matches a single literal phrase and produces a
/// <see cref="VisualLineLinkText"/> pointing at a fixed <see cref="Uri"/>. Mirrors the
/// "MyLinkElementGenerator" pattern from the legacy WPF host.
/// </summary>
internal sealed class ResourceLinkGenerator : LinkElementGenerator
{
readonly Uri target;
public ResourceLinkGenerator(string phrase, Uri target)
: base(new Regex(Regex.Escape(phrase)))
{
this.target = target ?? throw new ArgumentNullException(nameof(target));
}
protected override Uri GetUriFromMatch(Match match) => target;
}
}

7
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -76,6 +76,13 @@ namespace ILSpy.TextView @@ -76,6 +76,13 @@ namespace ILSpy.TextView
{
InitializeComponent();
// AvaloniaEdit defaults to "Ctrl+Click to follow hyperlink" on its built-in
// LinkElementGenerator and propagates that flag onto every VisualLineLinkText it
// constructs (including those produced by user-added LinkElementGenerator subclasses
// like the About-page resource generator). Decompiler output uses hyperlinks
// extensively for in-app navigation — a plain click is the expected affordance.
Editor.Options.RequireControlModifierForHyperlinkClick = false;
// One generator lives for the lifetime of the view; we only swap its References
// collection per document. The predicate filters out anything that should not be
// clickable — references with a null target slip through unconditionally otherwise.

Loading…
Cancel
Save