Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions ILSpy.Tests/Editor/CaretUsageHighlightTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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
/// (<see cref="MemberReferenceHighlightTests"/>), which keeps its own marks.
/// </summary>
[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<SettingsService>().DisplaySettings;
settings.HighlightUsagesAtCaret = enabled;
await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"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<DecompilerTextView>().First();
return (view, tab, settings);
}

static List<ReferenceSegment> 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");
}
}
2 changes: 2 additions & 0 deletions ILSpy/Options/DisplaySettingReactions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions ILSpy/Options/DisplaySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions ILSpy/Options/DisplaySettingsPanel.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
Content="{x:Static res:Resources.HighlightCurrentLine}" />
<CheckBox IsChecked="{Binding Settings.HighlightMemberReferences, Mode=TwoWay}"
Content="{x:Static res:Resources.HighlightMemberReferences}" />
<CheckBox IsChecked="{Binding Settings.HighlightUsagesAtCaret, Mode=TwoWay}"
Content="{x:Static res:Resources.HighlightUsagesAtCaret}" />
<CheckBox IsChecked="{Binding Settings.ExpandXmlDocumentationComments, Mode=TwoWay}"
Content="{x:Static res:Resources.ExpandXmlDocumentationCommentsAfterDecompilation}" />
<CheckBox IsChecked="{Binding Settings.ExpandMemberDefinitions, Mode=TwoWay}"
Expand Down
9 changes: 9 additions & 0 deletions ILSpy/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions ILSpy/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,9 @@ Are you sure you want to continue?</value>
<data name="HighlightMemberReferences" xml:space="preserve">
<value>Single-click highlights member references (Ctrl+Click navigates)</value>
</data>
<data name="HighlightUsagesAtCaret" xml:space="preserve">
<value>Highlight usages of the symbol under the caret</value>
</data>
<data name="ILSpyAboutPageTxt" xml:space="preserve">
<value>ILSpyAboutPage.txt</value>
</data>
Expand Down
121 changes: 121 additions & 0 deletions ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<TextMarker> 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;

/// <summary>Marks painted by the highlight-usages-at-caret feature (exposed for tests).</summary>
internal IReadOnlyList<TextMarker> CaretUsageMarks => caretUsageMarks;

void SetupCaretUsageHighlight()
{
Editor.TextArea.Caret.PositionChanged += (_, _) => UpdateCaretUsageHighlight();
if (currentDisplaySettings is INotifyPropertyChanged settings)
{
settings.PropertyChanged += (_, e) => {
if (e.PropertyName == nameof(DisplaySettings.HighlightUsagesAtCaret))
UpdateCaretUsageHighlight();
};
}
}

/// <summary>
/// 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.
/// </summary>
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<ReferenceSegment> 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();
}
}
}
2 changes: 2 additions & 0 deletions ILSpy/TextView/DecompilerTextView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down