From 48fb85960e2adce0367ba925d3f2bf1f6b0384f9 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 27 Jul 2026 10:00:12 +0200 Subject: [PATCH] Fix #781: click highlights member references, Ctrl+Click navigates Clicking a local variable highlights its occurrences, but clicking a member or type always navigated away, so there was no way to see all uses of a member within the current view. With the new display setting enabled (off by default, as discussed in the issue), a single click on a member or type reference paints every occurrence in the view using the local-reference highlight infrastructure, and Ctrl+Click performs the navigation. Opcode references keep navigating on plain click. Matching occurrences are compared by definition token and module rather than by Equals, because a use site carries a specialized member instance while the declaration carries the definition; this also fixes the analyzer-driven highlight for specialized members. Unresolved entity references from the IL and metadata views are compared structurally to avoid building a type system per click. The hand cursor promises navigation, so it is shown only when a click would actually navigate: cursor queries factor in the setting and the Ctrl state, and Ctrl presses/releases while hovering a reference repaint the cursor via top-level key handlers, since keyboard focus is usually elsewhere while hovering. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy.Tests/Editor/AreSameReferenceTests.cs | 100 +++++++ .../Editor/MemberReferenceHighlightTests.cs | 277 ++++++++++++++++++ ILSpy/Options/DisplaySettingReactions.cs | 2 + ILSpy/Options/DisplaySettings.cs | 5 + ILSpy/Options/DisplaySettingsPanel.axaml | 2 + ILSpy/Properties/Resources.Designer.cs | 9 + ILSpy/Properties/Resources.resx | 3 + ILSpy/TextView/DecompilerTextView.axaml.cs | 128 +++++++- ILSpy/TextView/ReferenceElementGenerator.cs | 8 + ILSpy/TextView/VisualLineReferenceText.cs | 13 +- 10 files changed, 540 insertions(+), 7 deletions(-) create mode 100644 ILSpy.Tests/Editor/AreSameReferenceTests.cs create mode 100644 ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs diff --git a/ILSpy.Tests/Editor/AreSameReferenceTests.cs b/ILSpy.Tests/Editor/AreSameReferenceTests.cs new file mode 100644 index 000000000..a433c00e0 --- /dev/null +++ b/ILSpy.Tests/Editor/AreSameReferenceTests.cs @@ -0,0 +1,100 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Reflection.Metadata; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.TextView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +[TestFixture] +public class AreSameReferenceTests +{ + static DecompilerTypeSystem typeSystem = null!; + + [OneTimeSetUp] + public void LoadTypeSystem() + { + var file = new PEFile(typeof(AreSameReferenceTests).Assembly.Location); + var resolver = new UniversalAssemblyResolver(file.FileName, throwOnError: false, file.DetectTargetFrameworkId()); + typeSystem = new DecompilerTypeSystem(file, resolver); + } + + [Test] + public void GeneratedMembersWithoutMetadataTokensAreNotConflated() + { + // Generated members have a nil MetadataToken; two distinct ones from the same + // module must not be treated as the same reference by the token comparison. + var a = new NilTokenMember(typeSystem.MainModule); + var b = new NilTokenMember(typeSystem.MainModule); + + Assert.That(DecompilerTextView.AreSameReference(a, b), Is.False); + Assert.That(DecompilerTextView.AreSameReference(a, a), Is.True); + } + + [Test] + public void MembersWithRealTokensStillCompareByDefinition() + { + var type = typeSystem.MainModule.Compilation.FindType(new FullTypeName(typeof(AreSameReferenceTests).FullName!)).GetDefinition()!; + var method = type.GetMethods(m => m.Name == nameof(GeneratedMembersWithoutMetadataTokensAreNotConflated)).Single(); + var other = type.GetMethods(m => m.Name == nameof(MembersWithRealTokensStillCompareByDefinition)).Single(); + + Assert.That(DecompilerTextView.AreSameReference(method, method.MemberDefinition), Is.True); + Assert.That(DecompilerTextView.AreSameReference(method, other), Is.False); + } + + // A minimal stand-in for a type-system-generated member: nil token, real module. + sealed class NilTokenMember(IModule module) : IMember + { + public EntityHandle MetadataToken => default; + public IMember MemberDefinition => this; + public IModule ParentModule => module; + public SymbolKind SymbolKind => SymbolKind.Method; + public string Name => "Generated"; + public string FullName => Name; + public string Namespace => string.Empty; + public string ReflectionName => Name; + public ICompilation Compilation => module.Compilation; + public bool Equals(IMember? obj, TypeVisitor typeNormalization) => ReferenceEquals(this, obj); + + public IType ReturnType => throw new NotImplementedException(); + public IEnumerable ExplicitlyImplementedInterfaceMembers => throw new NotImplementedException(); + public bool IsExplicitInterfaceImplementation => false; + public bool IsVirtual => false; + public bool IsOverride => false; + public bool IsOverridable => false; + public TypeParameterSubstitution Substitution => TypeParameterSubstitution.Identity; + public IMember Specialize(TypeParameterSubstitution substitution) => throw new NotImplementedException(); + public ITypeDefinition? DeclaringTypeDefinition => null; + public IType DeclaringType => null!; + public Accessibility Accessibility => Accessibility.Public; + public bool IsStatic => false; + public bool IsAbstract => false; + public bool IsSealed => false; + public IEnumerable GetAttributes() => throw new NotImplementedException(); + public bool HasAttribute(KnownAttribute attribute) => false; + public IAttribute? GetAttribute(KnownAttribute attribute) => null; + } +} diff --git a/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs b/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs new file mode 100644 index 000000000..8154cf0e1 --- /dev/null +++ b/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs @@ -0,0 +1,277 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.VisualTree; + +using AvaloniaEdit; +using AvaloniaEdit.Rendering; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.ViewModels; +using ICSharpCode.ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +/// +/// Sample type decompiled by : a field with +/// multiple uses, a generic method whose call site carries a specialized instance while +/// the declaration carries the definition, and two differently-parameterized uses of the +/// same generic type. +/// +public class MemberHighlightSample +{ + public int Field; + public List? Other; + + public List Make(T item) + { + return new List { item }; + } + + public int UseAll() + { + Field = 1; + Other = Make(2); + return Field + Other.Count; + } +} + +/// +/// Pins the member click-highlight behavior (issue #781): with the setting enabled, a +/// single click on a member or type reference highlights all its occurrences in the view +/// and Ctrl+Click navigates; with the setting disabled (the default), plain click keeps +/// navigating. +/// +[TestFixture] +public class MemberReferenceHighlightTests +{ + static async Task<(MainWindow Window, DecompilerTextView View, DecompilerTabPageModel Tab)> SetupAsync(bool highlightMemberReferences) + { + var (window, vm) = await TestHarness.BootAsync(); + AppComposition.Current.GetExport().DisplaySettings.HighlightMemberReferences = highlightMemberReferences; + await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location); + var typeNode = vm.AssemblyTreeModel.FindNode( + "ILSpy.Tests", + "ICSharpCode.ILSpy.Tests.TextView", + "ICSharpCode.ILSpy.Tests.TextView.MemberHighlightSample"); + vm.AssemblyTreeModel.SelectNode(typeNode); + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var view = window.GetVisualDescendants().OfType().First(); + return (window, view, tab); + } + + static List SegmentsOf(DecompilerTabPageModel tab, string memberName) + { + return tab.References! + .Where(r => r.Kind == ReferenceMode.Link && r.Reference is IMember m && m.Name == memberName) + .ToList(); + } + + [AvaloniaTest] + public async Task Clicking_A_Member_Navigates_When_The_Setting_Is_Off() + { + var (_, view, tab) = await SetupAsync(highlightMemberReferences: false); + + var fieldSegments = SegmentsOf(tab, nameof(MemberHighlightSample.Field)); + var use = fieldSegments.First(r => !r.IsDefinition); + view.OnReferenceClicked(use); + + view.LocalReferenceMarks.Should().BeEmpty( + "with the setting off, a plain click must keep navigating instead of highlighting"); + } + + [AvaloniaTest] + public async Task Clicking_A_Member_Highlights_All_Occurrences_When_Enabled() + { + var (_, view, tab) = await SetupAsync(highlightMemberReferences: true); + + var fieldSegments = SegmentsOf(tab, nameof(MemberHighlightSample.Field)); + // Besides the definition and the two uses, punctuation tokens of the declaration + // carry the member reference as well; all of them belong to the highlight group. + fieldSegments.Count(r => r.IsDefinition).Should().Be(1); + fieldSegments.Should().HaveCountGreaterThanOrEqualTo(3, "the field has one definition and two uses"); + bool navigated = false; + tab.NavigateRequested += _ => navigated = true; + + var use = fieldSegments.First(r => !r.IsDefinition); + view.OnReferenceClicked(use); + + view.LocalReferenceMarks.Select(m => m.StartOffset).Should().BeEquivalentTo( + fieldSegments.Select(s => s.StartOffset), + "clicking a use must highlight the definition and every use"); + navigated.Should().BeFalse("a highlighting click must not navigate"); + } + + [AvaloniaTest] + public async Task Highlight_Matches_Specialized_Member_Uses() + { + var (_, view, tab) = await SetupAsync(highlightMemberReferences: true); + + var makeSegments = SegmentsOf(tab, nameof(MemberHighlightSample.Make)); + // The identifier segments plus the call parentheses, which carry the member + // reference as well. + makeSegments.Count(r => r.IsDefinition).Should().Be(1); + makeSegments.Should().HaveCountGreaterThanOrEqualTo(2, "the generic method has one definition and one call"); + // The call site carries a specialized method instance, the declaration the definition. + var use = makeSegments.First(r => !r.IsDefinition && r.Length > 1); + view.OnReferenceClicked(use); + + view.LocalReferenceMarks.Select(m => m.StartOffset).Should().BeEquivalentTo( + makeSegments.Select(s => s.StartOffset), + "clicking the specialized call site must also highlight the definition"); + } + + [AvaloniaTest] + public async Task Clicking_A_Type_Highlights_All_Its_Parameterizations() + { + var (_, view, tab) = await SetupAsync(highlightMemberReferences: true); + + var listTypeSegments = tab.References! + .Where(r => r.Kind == ReferenceMode.Link && r.Reference is IType { Name: "List" }) + .ToList(); + listTypeSegments.Should().HaveCountGreaterThanOrEqualTo(2, + "List and List both occur in the view"); + + view.OnReferenceClicked(listTypeSegments[0]); + + view.LocalReferenceMarks.Select(m => m.StartOffset).Should().BeEquivalentTo( + listTypeSegments.Select(s => s.StartOffset), + "clicking a type reference must highlight all its occurrences regardless of type arguments"); + } + + [AvaloniaTest] + public async Task Ctrl_Click_Navigates_When_Enabled() + { + var (_, view, tab) = await SetupAsync(highlightMemberReferences: true); + + var fieldSegments = SegmentsOf(tab, nameof(MemberHighlightSample.Field)); + var use = fieldSegments.First(r => !r.IsDefinition); + view.OnReferenceClicked(use, ctrlHeld: true); + + view.LocalReferenceMarks.Should().BeEmpty( + "Ctrl+Click must navigate even with the setting enabled"); + } + + [AvaloniaTest] + public async Task Pointer_Click_Highlights_And_Ctrl_Click_Navigates() + { + // Mirrors ReferenceClickTests.SetupAsync/FindVisibleReference: the System.String + // view and its first visible link are the proven coordinate path for pointer + // gestures; Stationary_Click_On_A_Link_Navigates pins that this very click + // navigates when the setting is off. + var (window, vm) = await TestHarness.BootAsync(); + AppComposition.Current.GetExport().DisplaySettings.HighlightMemberReferences = true; + var coreLibName = typeof(object).Assembly.GetName().Name!; + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + vm.AssemblyTreeModel.SelectNode(stringNode); + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var view = window.GetVisualDescendants().OfType().First(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var textView = view.Editor.TextArea.TextView; + var segment = tab.References! + .First(r => r.Reference != null && r.Kind == ReferenceMode.Link && !r.IsDefinition); + var line = view.Editor.Document.GetLineByOffset(segment.StartOffset); + view.Editor.ScrollTo(line.LineNumber, segment.StartOffset - line.Offset + 1); + window.UpdateLayout(); + textView.EnsureVisualLines(); + var position = new TextViewPosition(line.LineNumber, segment.StartOffset - line.Offset + 2); + var visual = textView.GetVisualPosition(position, VisualYPosition.LineMiddle) - textView.ScrollOffset; + var point = textView.TranslatePoint(visual, window)!.Value; + + bool navigated = false; + tab.NavigateRequested += _ => navigated = true; + + window.MouseDown(point, MouseButton.Left); + window.MouseUp(point, MouseButton.Left); + + view.LocalReferenceMarks.Should().NotBeEmpty("a plain click must highlight, not navigate"); + navigated.Should().BeFalse(); + + window.MouseDown(point, MouseButton.Left, RawInputModifiers.Control); + window.MouseUp(point, MouseButton.Left, RawInputModifiers.Control); + + navigated.Should().BeTrue("Ctrl+Click must navigate to the definition"); + } + + [AvaloniaTest] + public async Task Cursor_Shows_Hand_Only_When_A_Click_Would_Navigate() + { + var (window, vm) = await TestHarness.BootAsync(); + AppComposition.Current.GetExport().DisplaySettings.HighlightMemberReferences = true; + var coreLibName = typeof(object).Assembly.GetName().Name!; + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + vm.AssemblyTreeModel.SelectNode(stringNode); + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var view = window.GetVisualDescendants().OfType().First(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var textView = view.Editor.TextArea.TextView; + var segment = tab.References! + .First(r => r.Reference != null && r.Kind == ReferenceMode.Link && !r.IsDefinition); + var line = view.Editor.Document.GetLineByOffset(segment.StartOffset); + view.Editor.ScrollTo(line.LineNumber, segment.StartOffset - line.Offset + 1); + window.UpdateLayout(); + textView.EnsureVisualLines(); + var position = new TextViewPosition(line.LineNumber, segment.StartOffset - line.Offset + 2); + var visual = textView.GetVisualPosition(position, VisualYPosition.LineMiddle) - textView.ScrollOffset; + var point = textView.TranslatePoint(visual, window)!.Value; + + // Without Ctrl a click highlights, so no hand cursor; with Ctrl it navigates. + window.MouseMove(point); + textView.Cursor?.ToString().Should().NotBe("Hand", + "with the setting enabled, a plain click highlights instead of navigating"); + + window.MouseMove(point, RawInputModifiers.Control); + textView.Cursor?.ToString().Should().Be("Hand", + "holding Ctrl makes the click navigate, so the link affordance must show"); + } + + [AvaloniaTest] + public async Task Clicking_Empty_Space_Clears_The_Member_Highlight() + { + var (_, view, tab) = await SetupAsync(highlightMemberReferences: true); + + var use = SegmentsOf(tab, nameof(MemberHighlightSample.Field)).First(r => !r.IsDefinition); + view.OnReferenceClicked(use); + view.LocalReferenceMarks.Should().NotBeEmpty(); + + // A subsequent navigating click (Ctrl) on another member clears the previous marks. + view.OnReferenceClicked(use, ctrlHeld: true); + view.LocalReferenceMarks.Should().BeEmpty(); + } +} diff --git a/ILSpy/Options/DisplaySettingReactions.cs b/ILSpy/Options/DisplaySettingReactions.cs index 5d730bae5..11a3cb6e4 100644 --- a/ILSpy/Options/DisplaySettingReactions.cs +++ b/ILSpy/Options/DisplaySettingReactions.cs @@ -83,6 +83,8 @@ namespace ICSharpCode.ILSpy.Options [nameof(DisplaySettings.HighlightMatchingBraces)] = DisplaySettingReaction.EditorLive, // The text view shows/hides its own omnibar from this; tree and output are unaffected. [nameof(DisplaySettings.EnableOmnibar)] = DisplaySettingReaction.EditorLive, + // Read at click time inside the text view; nothing needs re-decompilation. + [nameof(DisplaySettings.HighlightMemberReferences)] = DisplaySettingReaction.EditorLive, // No model-side reaction. [nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None, diff --git a/ILSpy/Options/DisplaySettings.cs b/ILSpy/Options/DisplaySettings.cs index 99515b0eb..ca74d7280 100644 --- a/ILSpy/Options/DisplaySettings.cs +++ b/ILSpy/Options/DisplaySettings.cs @@ -102,6 +102,9 @@ namespace ICSharpCode.ILSpy.Options [ObservableProperty] bool enableOmnibar; + [ObservableProperty] + bool highlightMemberReferences; + public XName SectionName => "DisplaySettings"; public void LoadFromXml(XElement section) @@ -129,6 +132,7 @@ namespace ICSharpCode.ILSpy.Options StyleWindowTitleBar = (bool?)section.Attribute(nameof(StyleWindowTitleBar)) ?? false; DecodeCustomAttributeBlobs = (bool?)section.Attribute(nameof(DecodeCustomAttributeBlobs)) ?? false; EnableOmnibar = (bool?)section.Attribute(nameof(EnableOmnibar)) ?? false; + HighlightMemberReferences = (bool?)section.Attribute(nameof(HighlightMemberReferences)) ?? false; } public XElement SaveToXml() @@ -157,6 +161,7 @@ namespace ICSharpCode.ILSpy.Options section.SetAttributeValue(nameof(StyleWindowTitleBar), StyleWindowTitleBar); section.SetAttributeValue(nameof(DecodeCustomAttributeBlobs), DecodeCustomAttributeBlobs); section.SetAttributeValue(nameof(EnableOmnibar), EnableOmnibar); + section.SetAttributeValue(nameof(HighlightMemberReferences), HighlightMemberReferences); return section; } } diff --git a/ILSpy/Options/DisplaySettingsPanel.axaml b/ILSpy/Options/DisplaySettingsPanel.axaml index 5606e3bd7..b923c4dde 100644 --- a/ILSpy/Options/DisplaySettingsPanel.axaml +++ b/ILSpy/Options/DisplaySettingsPanel.axaml @@ -71,6 +71,8 @@ Content="{x:Static res:Resources.HighlightMatchingBraces}" /> + + /// Looks up a localized string similar to Single-click highlights member references (Ctrl+Click navigates). + /// + public static string HighlightMemberReferences { + get { + return ResourceManager.GetString("HighlightMemberReferences", resourceCulture); + } + } + /// /// Looks up a localized string similar to ILSpyAboutPage.txt. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 7e3470336..0ff12daff 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -762,6 +762,9 @@ Are you sure you want to continue? Highlight matching braces + + Single-click highlights member references (Ctrl+Click navigates) + ILSpyAboutPage.txt diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 7345d0d6e..1d94dfaa5 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -185,7 +185,9 @@ namespace ICSharpCode.ILSpy.TextView // with a null target slip through unconditionally otherwise. void SetupElementGenerators() { - referenceElementGenerator = new ReferenceElementGenerator(static segment => segment.Reference != null); + referenceElementGenerator = new ReferenceElementGenerator(static segment => segment.Reference != null) { + QueryCursor = OnReferenceQueryCursor + }; Editor.TextArea.TextView.ElementGenerators.Add(referenceElementGenerator); uiElementGenerator = new UIElementGenerator(); @@ -249,7 +251,7 @@ namespace ICSharpCode.ILSpy.TextView // bubbling further now that it has been consumed as a link click. Editor.TextArea.ClearSelection(); e.Handled = true; - OnReferenceClicked(segment); + OnReferenceClicked(segment, e.KeyModifiers.HasFlag(KeyModifiers.Control)); } // Background renderers that live for the view's lifetime: the local-reference highlight (marks @@ -360,14 +362,26 @@ namespace ICSharpCode.ILSpy.TextView { base.OnAttachedToVisualTree(e); ICSharpCode.ILSpy.Themes.ThemeManager.Current.ThemeChanged += OnThemeChangedRebuildHighlighting; + // Ctrl toggles between highlight and navigate for reference clicks; listen at the + // top level because the keyboard focus is usually elsewhere while hovering. + cursorKeyEventSource = global::Avalonia.Controls.TopLevel.GetTopLevel(this); + cursorKeyEventSource?.AddHandler(KeyDownEvent, OnTopLevelKeyDownForReferenceCursor, RoutingStrategies.Tunnel, handledEventsToo: true); + cursorKeyEventSource?.AddHandler(KeyUpEvent, OnTopLevelKeyUpForReferenceCursor, RoutingStrategies.Tunnel, handledEventsToo: true); } protected override void OnDetachedFromVisualTree(global::Avalonia.VisualTreeAttachmentEventArgs e) { ICSharpCode.ILSpy.Themes.ThemeManager.Current.ThemeChanged -= OnThemeChangedRebuildHighlighting; + cursorKeyEventSource?.RemoveHandler(KeyDownEvent, OnTopLevelKeyDownForReferenceCursor); + cursorKeyEventSource?.RemoveHandler(KeyUpEvent, OnTopLevelKeyUpForReferenceCursor); + cursorKeyEventSource = null; + cursorQueryElement = null; + cursorQuerySegment = null; base.OnDetachedFromVisualTree(e); } + global::Avalonia.Controls.TopLevel? cursorKeyEventSource; + // A theme switch re-colours the shared named HighlightingColors in place, but the // semantic RichTextModel cloned them at decompile time (RichTextModel.SetHighlighting // clones). Rebuild the model from the captured spans -- which still reference the live, @@ -1197,7 +1211,54 @@ namespace ICSharpCode.ILSpy.TextView return model.References.FindSegmentsContaining(offset).FirstOrDefault(); } - internal void OnReferenceClicked(ReferenceSegment segment) + /// + /// True when a plain click on paints the occurrence + /// highlight instead of navigating: the member-highlight setting is enabled, Ctrl is + /// not held, and the reference is a member, type or unresolved entity reference. + /// + bool ShouldHighlightInsteadOfNavigate(ReferenceSegment segment, bool ctrlHeld) + { + return !ctrlHeld + && segment.Kind == ReferenceMode.Link + && currentDisplaySettings is { HighlightMemberReferences: true } + && segment.Reference is IMember or IType or EntityReference; + } + + InputElement? cursorQueryElement; + ReferenceSegment? cursorQuerySegment; + + void OnReferenceQueryCursor(InputElement element, ReferenceSegment segment, KeyModifiers modifiers) + { + cursorQueryElement = element; + cursorQuerySegment = segment; + ApplyReferenceCursor(modifiers.HasFlag(KeyModifiers.Control)); + } + + // The hand cursor promises navigation: show it only when a click would actually + // navigate. Re-evaluated on pointer moves and on Ctrl presses/releases while a + // reference is under the pointer. + void ApplyReferenceCursor(bool ctrlHeld) + { + if (cursorQueryElement == null || cursorQuerySegment == null) + return; + bool navigates = cursorQuerySegment.Kind == ReferenceMode.Link + && !ShouldHighlightInsteadOfNavigate(cursorQuerySegment, ctrlHeld); + cursorQueryElement.Cursor = new Cursor(navigates ? StandardCursorType.Hand : StandardCursorType.Arrow); + } + + void OnTopLevelKeyDownForReferenceCursor(object? sender, KeyEventArgs e) + { + if (e.Key is Key.LeftCtrl or Key.RightCtrl) + ApplyReferenceCursor(ctrlHeld: true); + } + + void OnTopLevelKeyUpForReferenceCursor(object? sender, KeyEventArgs e) + { + if (e.Key is Key.LeftCtrl or Key.RightCtrl) + ApplyReferenceCursor(ctrlHeld: false); + } + + internal void OnReferenceClicked(ReferenceSegment segment, bool ctrlHeld = false) { if (DataContext is not DecompilerTabPageModel model || segment.Reference == null) return; @@ -1215,6 +1276,15 @@ namespace ICSharpCode.ILSpy.TextView HighlightLocalReferences(model, segment.Reference); return; } + // With the member-highlight setting enabled, a plain click on a member or type + // reference paints all its occurrences in this view instead of navigating; + // Ctrl+Click keeps the navigation behavior. Opcode references always navigate: + // highlighting every occurrence of an IL opcode would be noise. + if (ShouldHighlightInsteadOfNavigate(segment, ctrlHeld)) + { + HighlightLocalReferences(model, segment.Reference); + return; + } ClearLocalReferenceMarks(); // In-document jumps win when the definition is in this same view. @@ -1245,7 +1315,7 @@ namespace ICSharpCode.ILSpy.TextView return; foreach (var r in model.References) { - if (!ReferenceEquals(reference, r.Reference) && !reference.Equals(r.Reference)) + if (!AreSameReference(reference, r.Reference)) continue; var mark = textMarkerService.Create(r.StartOffset, r.Length); mark.BackgroundColor = r.IsDefinition ? LocalDefinitionBackground : LocalMatchBackground; @@ -1253,6 +1323,42 @@ namespace ICSharpCode.ILSpy.TextView } } + internal static bool AreSameReference(object reference, object? candidate) + { + if (candidate == null) + return false; + if (ReferenceEquals(reference, candidate) || reference.Equals(candidate)) + return true; + // Member and type references compare by definition: a use site carries a + // specialized instance (e.g. List.Add) while the declaration carries + // the unspecialized definition, and their Equals treats them as different. + var a = NormalizeToEntity(reference); + var b = NormalizeToEntity(candidate); + if (a != null && b != null) + { + // Generated members carry a nil token; falling through to the token comparison + // would conflate any two of them from the same module. + return !a.MetadataToken.IsNil + && a.MetadataToken == b.MetadataToken + && a.ParentModule?.MetadataFile != null + && a.ParentModule.MetadataFile == b.ParentModule?.MetadataFile; + } + // IL and metadata views carry unresolved entity references; compare them + // structurally instead of resolving, which would build a type system per call. + if (reference is EntityReference unresolvedA && candidate is EntityReference unresolvedB) + return unresolvedA.Module == unresolvedB.Module && unresolvedA.Handle == unresolvedB.Handle; + return false; + + static IEntity? NormalizeToEntity(object reference) + { + return reference switch { + IMember member => member.MemberDefinition, + IType type => type.GetDefinition(), + _ => null, + }; + } + } + void ClearLocalReferenceMarks() { foreach (var mark in localReferenceMarks) @@ -1373,6 +1479,15 @@ namespace ICSharpCode.ILSpy.TextView void OnTextViewPointerMoved(object? sender, PointerEventArgs e) { + // The reference elements report QueryCursor only while the pointer is over them; + // once it moves elsewhere inside the text view, drop the cached query so Ctrl + // transitions cannot repaint a reference the pointer has already left. + if (cursorQueryElement is { IsPointerOver: false }) + { + cursorQueryElement = null; + cursorQuerySegment = null; + } + if (!richPopup.IsOpen) return; // While the rich popup is open, PointerMoved drives the WPF "distance corridor" — @@ -1387,6 +1502,11 @@ namespace ICSharpCode.ILSpy.TextView void OnTextViewPointerExited(object? sender, PointerEventArgs e) { + // The pointer is no longer over a reference, so Ctrl transitions must not + // repaint the last hovered element's cursor. + cursorQueryElement = null; + cursorQuerySegment = null; + // Don't close the rich popup if the pointer just moved from the editor onto the // 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 diff --git a/ILSpy/TextView/ReferenceElementGenerator.cs b/ILSpy/TextView/ReferenceElementGenerator.cs index cfc2f8a4e..f29c51fe8 100644 --- a/ILSpy/TextView/ReferenceElementGenerator.cs +++ b/ILSpy/TextView/ReferenceElementGenerator.cs @@ -18,6 +18,8 @@ using System; +using Avalonia.Input; + using AvaloniaEdit.Document; using AvaloniaEdit.Rendering; @@ -33,6 +35,12 @@ namespace ICSharpCode.ILSpy.TextView public TextSegmentCollection? References { get; set; } + /// + /// Lets the hosting view decide the cursor for a reference under the pointer, + /// factoring in key modifiers and settings. Falls back to hand-for-links when unset. + /// + public Action? QueryCursor { get; set; } + public ReferenceElementGenerator(Predicate isLink) { this.isLink = isLink ?? throw new ArgumentNullException(nameof(isLink)); diff --git a/ILSpy/TextView/VisualLineReferenceText.cs b/ILSpy/TextView/VisualLineReferenceText.cs index 95b7f1b3c..16ee6f52b 100644 --- a/ILSpy/TextView/VisualLineReferenceText.cs +++ b/ILSpy/TextView/VisualLineReferenceText.cs @@ -49,9 +49,16 @@ namespace ICSharpCode.ILSpy.TextView { if (e.Source is InputElement inputElement) { - inputElement.Cursor = new Cursor(referenceSegment.Kind == ReferenceMode.Link - ? StandardCursorType.Hand - : StandardCursorType.Arrow); + if (parent.QueryCursor != null) + { + parent.QueryCursor(inputElement, referenceSegment, e.KeyModifiers); + } + else + { + inputElement.Cursor = new Cursor(referenceSegment.Kind == ReferenceMode.Link + ? StandardCursorType.Hand + : StandardCursorType.Arrow); + } } // Do NOT set e.Handled = true — AvaloniaEdit's TextView.OnPointerMoved invokes // OnQueryCursor with the live PointerEventArgs, and marking it handled there