From c7fa4870f0c9235274af0eef594bafa604c389dc Mon Sep 17 00:00:00 2001 From: pieroviano Date: Tue, 6 Oct 2026 07:32:08 +0300 Subject: [PATCH] Add option to highlight usages of the symbol at the caret While the caret rests on a symbol in the code view, every occurrence of it in the document is marked, so reading a method no longer needs a click per symbol. Off by default and independent of the click highlight's marks. --- .../Editor/CaretUsageHighlightTests.cs | 156 ++++++++++++++++++ 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 + .../DecompilerTextView.CaretUsageHighlight.cs | 121 ++++++++++++++ ILSpy/TextView/DecompilerTextView.axaml.cs | 2 + 8 files changed, 300 insertions(+) create mode 100644 ILSpy.Tests/Editor/CaretUsageHighlightTests.cs create mode 100644 ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs diff --git a/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs new file mode 100644 index 00000000000..f0d1430eda4 --- /dev/null +++ b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Piero Viano +// +// 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 System.Xml.Linq; + +using Avalonia.Headless.NUnit; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Options; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +/// +/// Highlight usages at caret (ReSharper-style): while the caret rests on a symbol, every +/// occurrence of that symbol in the document is marked. Independent of the click highlight +/// (), which keeps its own marks. +/// +[TestFixture] +public class CaretUsageHighlightTests +{ + static async Task<(DecompilerTextView View, DecompilerTabPageModel Tab, DisplaySettings Settings)> SetupAsync(bool enabled = true) + { + var (window, vm) = await TestHarness.BootAsync(); + var settings = AppComposition.Current.GetExport().DisplaySettings; + settings.HighlightUsagesAtCaret = enabled; + 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 (view, tab, settings); + } + + static List MemberSegments(DecompilerTabPageModel tab, string name) + => tab.References! + .Where(r => r.Kind == ReferenceMode.Link && r.Reference is IMember m && m.Name == name) + .ToList(); + + [Test] + public void Setting_Defaults_To_Off_And_Round_Trips() + { + new DisplaySettings().HighlightUsagesAtCaret.Should().BeFalse(); + + var loaded = new DisplaySettings(); + loaded.LoadFromXml(new XElement("DisplaySettings")); + loaded.HighlightUsagesAtCaret.Should().BeFalse("a missing attribute keeps the default"); + + var on = new DisplaySettings { HighlightUsagesAtCaret = true }; + var reloaded = new DisplaySettings(); + reloaded.LoadFromXml(on.SaveToXml()); + reloaded.HighlightUsagesAtCaret.Should().BeTrue(); + } + + [AvaloniaTest] + public async Task Caret_On_A_Member_Use_Marks_Every_Occurrence() + { + var (view, tab, _) = await SetupAsync(); + var fieldSegments = MemberSegments(tab, nameof(MemberHighlightSample.Field)); + var use = fieldSegments.First(r => !r.IsDefinition); + + view.Editor.TextArea.Caret.Offset = use.StartOffset + 1; + + view.CaretUsageMarks.Select(m => m.StartOffset).Should().BeEquivalentTo( + fieldSegments.Select(s => s.StartOffset), + "the definition and every use of the field under the caret are marked"); + view.LocalReferenceMarks.Should().BeEmpty("the caret highlight must not touch the click-highlight marks"); + } + + [AvaloniaTest] + public async Task Caret_At_The_End_Of_An_Identifier_Still_Counts_As_On_It() + { + var (view, tab, _) = await SetupAsync(); + var fieldSegments = MemberSegments(tab, nameof(MemberHighlightSample.Field)); + var use = fieldSegments.First(r => !r.IsDefinition && r.Length > 1); + + view.Editor.TextArea.Caret.Offset = use.EndOffset; + + view.CaretUsageMarks.Should().NotBeEmpty(); + } + + [AvaloniaTest] + public async Task Caret_On_A_Parameter_Marks_Its_Local_Occurrences() + { + var (view, tab, _) = await SetupAsync(); + var itemSegments = tab.References! + .Where(r => r.Kind == ReferenceMode.LocalHighlight && tab.Text.Substring(r.StartOffset, r.Length) == "item") + .ToList(); + itemSegments.Should().HaveCountGreaterThanOrEqualTo(2, "the parameter is declared and used once"); + + view.Editor.TextArea.Caret.Offset = itemSegments[0].StartOffset; + + view.CaretUsageMarks.Select(m => m.StartOffset).Should().BeEquivalentTo( + itemSegments.Select(s => s.StartOffset)); + } + + [AvaloniaTest] + public async Task Moving_The_Caret_Off_A_Symbol_Clears_The_Marks() + { + var (view, tab, _) = await SetupAsync(); + var use = MemberSegments(tab, nameof(MemberHighlightSample.Field)).First(r => !r.IsDefinition); + view.Editor.TextArea.Caret.Offset = use.StartOffset; + view.CaretUsageMarks.Should().NotBeEmpty(); + + // Offset 0 is the start of the leading comment/using block, which carries no reference. + tab.References!.FindSegmentsContaining(0).Should().BeEmpty(); + view.Editor.TextArea.Caret.Offset = 0; + + view.CaretUsageMarks.Should().BeEmpty(); + } + + [AvaloniaTest] + public async Task Disabled_Setting_Marks_Nothing_And_Turning_It_Off_Clears_Marks() + { + var (view, tab, settings) = await SetupAsync(enabled: false); + var use = MemberSegments(tab, nameof(MemberHighlightSample.Field)).First(r => !r.IsDefinition); + + view.Editor.TextArea.Caret.Offset = use.StartOffset; + view.CaretUsageMarks.Should().BeEmpty("the option is off"); + + settings.HighlightUsagesAtCaret = true; + view.CaretUsageMarks.Should().NotBeEmpty("turning the option on highlights the symbol already under the caret"); + + settings.HighlightUsagesAtCaret = false; + view.CaretUsageMarks.Should().BeEmpty("turning the option off clears the marks"); + } +} diff --git a/ILSpy/Options/DisplaySettingReactions.cs b/ILSpy/Options/DisplaySettingReactions.cs index b8a66f9a77b..d08bfb7fa16 100644 --- a/ILSpy/Options/DisplaySettingReactions.cs +++ b/ILSpy/Options/DisplaySettingReactions.cs @@ -86,6 +86,8 @@ public static class DisplaySettingReactions [nameof(DisplaySettings.EnableOmnibar)] = DisplaySettingReaction.EditorLive, // Read at click time inside the text view; nothing needs re-decompilation. [nameof(DisplaySettings.HighlightMemberReferences)] = DisplaySettingReaction.EditorLive, + // The text view re-evaluates the caret highlight itself; nothing needs re-decompilation. + [nameof(DisplaySettings.HighlightUsagesAtCaret)] = DisplaySettingReaction.EditorLive, // No model-side reaction. [nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None, diff --git a/ILSpy/Options/DisplaySettings.cs b/ILSpy/Options/DisplaySettings.cs index 932c15185dd..ac3e9a842e9 100644 --- a/ILSpy/Options/DisplaySettings.cs +++ b/ILSpy/Options/DisplaySettings.cs @@ -111,6 +111,9 @@ public sealed partial class DisplaySettings : ObservableObject, ISettingsSection [ObservableProperty] bool highlightMemberReferences; + [ObservableProperty] + bool highlightUsagesAtCaret; + public XName SectionName => "DisplaySettings"; public void LoadFromXml(XElement section) @@ -140,6 +143,7 @@ public void LoadFromXml(XElement section) DecodeCustomAttributeBlobs = (bool?)section.Attribute(nameof(DecodeCustomAttributeBlobs)) ?? false; EnableOmnibar = (bool?)section.Attribute(nameof(EnableOmnibar)) ?? false; HighlightMemberReferences = (bool?)section.Attribute(nameof(HighlightMemberReferences)) ?? false; + HighlightUsagesAtCaret = (bool?)section.Attribute(nameof(HighlightUsagesAtCaret)) ?? false; } public XElement SaveToXml() @@ -170,6 +174,7 @@ public XElement SaveToXml() section.SetAttributeValue(nameof(DecodeCustomAttributeBlobs), DecodeCustomAttributeBlobs); section.SetAttributeValue(nameof(EnableOmnibar), EnableOmnibar); section.SetAttributeValue(nameof(HighlightMemberReferences), HighlightMemberReferences); + section.SetAttributeValue(nameof(HighlightUsagesAtCaret), HighlightUsagesAtCaret); return section; } } diff --git a/ILSpy/Options/DisplaySettingsPanel.axaml b/ILSpy/Options/DisplaySettingsPanel.axaml index ecf99f963cd..2a4fdc01d71 100644 --- a/ILSpy/Options/DisplaySettingsPanel.axaml +++ b/ILSpy/Options/DisplaySettingsPanel.axaml @@ -79,6 +79,8 @@ Content="{x:Static res:Resources.HighlightCurrentLine}" /> + + /// Looks up a localized string similar to Highlight usages of the symbol under the caret. + /// + public static string HighlightUsagesAtCaret { + get { + return ResourceManager.GetString("HighlightUsagesAtCaret", resourceCulture); + } + } + /// /// Looks up a localized string similar to ILSpyAboutPage.txt. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 94e510454b3..8e1c735fb4e 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -771,6 +771,9 @@ Are you sure you want to continue? Single-click highlights member references (Ctrl+Click navigates) + + Highlight usages of the symbol under the caret + ILSpyAboutPage.txt diff --git a/ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs b/ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs new file mode 100644 index 00000000000..2802deaad5d --- /dev/null +++ b/ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Piero Viano +// +// 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.ComponentModel; +using System.Linq; + +using Avalonia.Media; + +using ICSharpCode.Decompiler.TypeSystem; + +using ICSharpCode.ILSpy.Options; + +namespace ICSharpCode.ILSpy.TextView +{ + /// + /// Highlight usages at caret: while the caret rests on a symbol, every occurrence of that + /// symbol in the document is marked. Independent of the click highlight, which keeps its own + /// marks. + /// + public partial class DecompilerTextView + { + // Translucent so the caret-usage marks stay readable on top of the click-highlight marks. + static readonly Color CaretUsageBackground = Color.FromArgb(0x50, 0x87, 0xCE, 0xFA); + static readonly Color CaretUsageDefinitionBackground = Color.FromArgb(0x80, 0x87, 0xCE, 0xFA); + + readonly List caretUsageMarks = new(); + // The segment the current caret-usage marks were computed for; re-computing is skipped while + // the caret moves within that same segment. + ReferenceSegment? caretUsageSegment; + + /// Marks painted by the highlight-usages-at-caret feature (exposed for tests). + internal IReadOnlyList CaretUsageMarks => caretUsageMarks; + + void SetupCaretUsageHighlight() + { + Editor.TextArea.Caret.PositionChanged += (_, _) => UpdateCaretUsageHighlight(); + if (currentDisplaySettings is INotifyPropertyChanged settings) + { + settings.PropertyChanged += (_, e) => { + if (e.PropertyName == nameof(DisplaySettings.HighlightUsagesAtCaret)) + UpdateCaretUsageHighlight(); + }; + } + } + + /// + /// The reference segment under the caret: one containing the caret offset, or ending right + /// before it (caret just after an identifier). Hover-only segments are skipped. + /// + ReferenceSegment? GetReferenceSegmentAtCaret() + { + if (DataContext is not DecompilerTabPageModel { References: { } references }) + return null; + int offset = Editor.TextArea.Caret.Offset; + return Pick(references.FindSegmentsContaining(offset)) + ?? (offset > 0 ? Pick(references.FindSegmentsContaining(offset - 1)) : null); + + static ReferenceSegment? Pick(IEnumerable segments) + => segments.FirstOrDefault(s => s.Reference != null && s.Kind != ReferenceMode.HoverOnly); + } + + void UpdateCaretUsageHighlight() + { + if (currentDisplaySettings is not { HighlightUsagesAtCaret: true } + || DataContext is not DecompilerTabPageModel { References: { } references } + || GetReferenceSegmentAtCaret() is not { } segment + || !IsCaretHighlightable(segment)) + { + ClearCaretUsageMarks(); + return; + } + if (ReferenceEquals(segment, caretUsageSegment) && caretUsageMarks.Count > 0) + return; + + ClearCaretUsageMarks(); + caretUsageSegment = segment; + int textLength = Editor.Document.TextLength; + foreach (var r in references) + { + if (r.Kind == ReferenceMode.HoverOnly || !AreSameReference(segment.Reference!, r.Reference)) + continue; + if (r.StartOffset < 0 || r.EndOffset > textLength) + continue; + var mark = textMarkerService.Create(r.StartOffset, r.Length); + mark.BackgroundColor = r.IsDefinition ? CaretUsageDefinitionBackground : CaretUsageBackground; + caretUsageMarks.Add(mark); + } + } + + // Locals and parameters, members, types and unresolved entity references. Opcodes and other + // link targets are excluded: marking every occurrence of an IL opcode would be noise. + static bool IsCaretHighlightable(ReferenceSegment segment) + => segment.Kind == ReferenceMode.LocalHighlight + || segment.Reference is IMember or IType or EntityReference; + + void ClearCaretUsageMarks() + { + caretUsageSegment = null; + foreach (var mark in caretUsageMarks) + textMarkerService.Remove(mark); + caretUsageMarks.Clear(); + } + } +} diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 3293f14daca..bd86032f699 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -151,6 +151,7 @@ public DecompilerTextView() Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged; SetupZoomAndCopy(); + SetupCaretUsageHighlight(); // Ctrl+L focuses the omnibar into search mode (browser address-bar gesture). Tunnel so // it wins before AvaloniaEdit's own key handling while focus is anywhere in the editor. @@ -1153,6 +1154,7 @@ void ApplyDocument(DecompilerTabPageModel model, bool restoreViewState = true) // force-close even if the popup currently wants to stay (mouseClick: true). TryCloseExistingPopup(mouseClick: true); ClearLocalReferenceMarks(); + ClearCaretUsageMarks(); ClearDebugStepMarks(); Editor.SyntaxHighlighting = HighlightingService.GetByExtension(model.SyntaxExtension); Editor.Document.Text = model.Text;