Merge pull request #4216 from pieroviano/nav-caret-usage-highlight

Add option to highlight usages of the symbol at the caret
This commit is contained in:
Siegfried Pammer
2026-10-11 19:51:45 +02:00
committed by GitHub
11 changed files with 358 additions and 149 deletions
@@ -0,0 +1,167 @@
// 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 symbols under caret (ReSharper-style): while the caret rests on a symbol, every
/// occurrence of that symbol in the document is marked.
/// </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.HighlightSymbolsUnderCaret = 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_On_And_Round_Trips()
{
new DisplaySettings().HighlightSymbolsUnderCaret.Should().BeTrue();
var loaded = new DisplaySettings();
loaded.LoadFromXml(new XElement("DisplaySettings"));
loaded.HighlightSymbolsUnderCaret.Should().BeTrue("a missing attribute keeps the default");
var on = new DisplaySettings { HighlightSymbolsUnderCaret = false };
var reloaded = new DisplaySettings();
reloaded.LoadFromXml(on.SaveToXml());
reloaded.HighlightSymbolsUnderCaret.Should().BeFalse();
}
[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 external reference-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.HighlightSymbolsUnderCaret = true;
view.CaretUsageMarks.Should().NotBeEmpty("turning the option on highlights the symbol already under the caret");
settings.HighlightSymbolsUnderCaret = false;
view.CaretUsageMarks.Should().BeEmpty("turning the option off clears the marks");
}
[AvaloniaTest]
public async Task Highlight_Uses_Theme_Resource_Brushes()
{
var (view, tab, _) = await SetupAsync();
var definition = MemberSegments(tab, nameof(MemberHighlightSample.Field)).First(r => r.IsDefinition);
view.Editor.TextArea.Caret.Offset = definition.StartOffset + 1;
view.CaretUsageMarks.Should().Contain(m => m.BackgroundResourceKey == "ILSpy.SymbolDefinitionHighlightBrush");
view.CaretUsageMarks.Should().Contain(m => m.BackgroundResourceKey == "ILSpy.SymbolHighlightBrush");
}
}
@@ -48,14 +48,14 @@ public class GenericLocalFunctionSample
}
/// <summary>
/// Pins the local-reference highlighting for local functions: clicking any occurrence of a
/// generic local function must paint the definition and every use site as one group.
/// Pins the local-reference highlighting for local functions: placing the caret on any occurrence
/// of a generic local function must paint the definition and every use site as one group.
/// </summary>
[TestFixture]
public class LocalReferenceHighlightTests
{
[AvaloniaTest]
public async Task Clicking_A_Generic_Local_Function_Use_Highlights_Definition_And_All_Uses()
public async Task Caret_On_A_Generic_Local_Function_Use_Highlights_Definition_And_All_Uses()
{
var (window, vm) = await TestHarness.BootAsync();
await vm.OpenAssemblyAsync(typeof(GenericLocalFunctionSample).Assembly.Location);
@@ -74,10 +74,11 @@ public class LocalReferenceHighlightTests
localFunctionSegments.Count(r => r.IsDefinition).Should().Be(1);
var use = localFunctionSegments.First(r => !r.IsDefinition);
view.OnReferenceClicked(use);
view.Editor.TextArea.Caret.Offset = use.StartOffset + 1;
view.LocalReferenceMarks.Select(m => m.StartOffset).Should().BeEquivalentTo(
view.CaretUsageMarks.Select(m => m.StartOffset).Should().BeEquivalentTo(
localFunctionSegments.Select(s => s.StartOffset),
"clicking a use must highlight the definition and every use");
"placing the caret on a use must highlight the definition and every use");
view.LocalReferenceMarks.Should().BeEmpty("local symbol highlights are now driven by the caret");
}
}
@@ -67,18 +67,16 @@ public class MemberHighlightSample
}
/// <summary>
/// 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.
/// Pins reference click navigation: plain clicks navigate by default, and the display setting
/// can require Ctrl+Click for navigation.
/// </summary>
[TestFixture]
public class MemberReferenceHighlightTests
{
static async Task<(MainWindow Window, DecompilerTextView View, DecompilerTabPageModel Tab)> SetupAsync(bool highlightMemberReferences)
static async Task<(MainWindow Window, DecompilerTextView View, DecompilerTabPageModel Tab)> SetupAsync(bool navigateOnCtrlClick)
{
var (window, vm) = await TestHarness.BootAsync();
AppComposition.Current.GetExport<SettingsService>().DisplaySettings.HighlightMemberReferences = highlightMemberReferences;
AppComposition.Current.GetExport<SettingsService>().DisplaySettings.NavigateOnCtrlClick = navigateOnCtrlClick;
await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"ILSpy.Tests",
@@ -98,99 +96,58 @@ public class MemberReferenceHighlightTests
}
[AvaloniaTest]
public async Task Clicking_A_Member_Navigates_When_The_Setting_Is_Off()
public async Task Clicking_A_Member_Navigates_By_Default()
{
var (_, view, tab) = await SetupAsync(highlightMemberReferences: false);
var (_, view, tab) = await SetupAsync(navigateOnCtrlClick: false);
var fieldSegments = SegmentsOf(tab, nameof(MemberHighlightSample.Field));
var definition = fieldSegments.First(r => r.IsDefinition);
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");
"plain clicks no longer create member reference highlights");
view.Editor.TextArea.Caret.Offset.Should().BeInRange(definition.StartOffset, definition.EndOffset,
"plain clicks navigate to an in-document definition by default");
}
[AvaloniaTest]
public async Task Clicking_A_Member_Highlights_All_Occurrences_When_Enabled()
public async Task Plain_Click_Does_Not_Navigate_When_Ctrl_Click_Is_Required()
{
var (_, view, tab) = await SetupAsync(highlightMemberReferences: true);
var (_, view, tab) = await SetupAsync(navigateOnCtrlClick: 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 originalOffset = view.Editor.TextArea.Caret.Offset;
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");
view.Editor.TextArea.Caret.Offset.Should().Be(originalOffset);
view.LocalReferenceMarks.Should().BeEmpty("plain clicks do not create member reference highlights");
}
[AvaloniaTest]
public async Task Highlight_Matches_Specialized_Member_Uses()
public async Task Ctrl_Click_Navigates_When_Ctrl_Click_Is_Required()
{
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<T> and List<int> 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 (_, view, tab) = await SetupAsync(navigateOnCtrlClick: true);
var fieldSegments = SegmentsOf(tab, nameof(MemberHighlightSample.Field));
var definition = fieldSegments.First(r => r.IsDefinition);
var use = fieldSegments.First(r => !r.IsDefinition);
view.OnReferenceClicked(use, DecompilerTextView.ReferenceClickKind.Navigate);
view.LocalReferenceMarks.Should().BeEmpty(
"Ctrl+Click must navigate even with the setting enabled");
"Ctrl+Click navigates instead of highlighting");
view.Editor.TextArea.Caret.Offset.Should().BeInRange(definition.StartOffset, definition.EndOffset);
}
[AvaloniaTest]
public async Task Pointer_Click_Highlights_And_Ctrl_Click_Navigates()
public async Task Pointer_Click_Respects_Ctrl_Click_Requirement()
{
// 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.
// gestures.
var (window, vm) = await TestHarness.BootAsync();
AppComposition.Current.GetExport<SettingsService>().DisplaySettings.HighlightMemberReferences = true;
AppComposition.Current.GetExport<SettingsService>().DisplaySettings.NavigateOnCtrlClick = true;
var coreLibName = typeof(object).Assembly.GetName().Name!;
var stringNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String");
vm.AssemblyTreeModel.SelectNode(stringNode);
@@ -217,20 +174,20 @@ public class MemberReferenceHighlightTests
window.MouseDown(point, MouseButton.Left);
window.MouseUp(point, MouseButton.Left);
view.LocalReferenceMarks.Should().NotBeEmpty("a plain click must highlight, not navigate");
navigated.Should().BeFalse();
view.LocalReferenceMarks.Should().BeEmpty("plain clicks do not create member reference highlights");
navigated.Should().BeFalse("plain clicks do not navigate when Ctrl+Click is required");
window.MouseDown(point, MouseButton.Left, RawInputModifiers.Control);
window.MouseUp(point, MouseButton.Left, RawInputModifiers.Control);
navigated.Should().BeTrue("Ctrl+Click must navigate to the definition");
navigated.Should().BeTrue("Ctrl+Click must navigate");
}
[AvaloniaTest]
public async Task Cursor_Shows_Hand_Only_When_A_Click_Would_Navigate()
public async Task Cursor_Shows_Hand_Only_When_Click_Would_Navigate()
{
var (window, vm) = await TestHarness.BootAsync();
AppComposition.Current.GetExport<SettingsService>().DisplaySettings.HighlightMemberReferences = true;
AppComposition.Current.GetExport<SettingsService>().DisplaySettings.NavigateOnCtrlClick = true;
var coreLibName = typeof(object).Assembly.GetName().Name!;
var stringNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String");
vm.AssemblyTreeModel.SelectNode(stringNode);
@@ -251,27 +208,13 @@ public class MemberReferenceHighlightTests
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.
// Without Ctrl a click does not navigate, 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");
"with the setting enabled, a plain click does not navigate");
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, DecompilerTextView.ReferenceClickKind.Navigate);
view.LocalReferenceMarks.Should().BeEmpty();
}
}
+4
View File
@@ -44,6 +44,8 @@
TextArea style leaves SelectionForeground unset). -->
<SolidColorBrush x:Key="ILSpy.EditorSelectionBrush" Color="#007ACC" Opacity="0.3" />
<SolidColorBrush x:Key="ILSpy.EditorSearchResultsBrush" Color="#FFD54F" />
<SolidColorBrush x:Key="ILSpy.SymbolHighlightBrush" Color="#87CEFA" Opacity="0.31" />
<SolidColorBrush x:Key="ILSpy.SymbolDefinitionHighlightBrush" Color="#87CEFA" Opacity="0.50" />
<SolidColorBrush x:Key="ILSpy.EditorWaitAdornerBackground" Color="#80FFFFFF" />
<!-- Clickable text: About-page and decompiler-view hyperlinks (AvaloniaEdit's
TextView.LinkTextForegroundBrush, see the style below) and the metadata
@@ -118,6 +120,8 @@
dark editor background. -->
<SolidColorBrush x:Key="ILSpy.EditorSelectionBrush" Color="#3794FF" Opacity="0.35" />
<SolidColorBrush x:Key="ILSpy.EditorSearchResultsBrush" Color="#665500" />
<SolidColorBrush x:Key="ILSpy.SymbolHighlightBrush" Color="#2F6F9F" Opacity="0.50" />
<SolidColorBrush x:Key="ILSpy.SymbolDefinitionHighlightBrush" Color="#3794FF" Opacity="0.45" />
<SolidColorBrush x:Key="ILSpy.EditorWaitAdornerBackground" Color="#A0000000" />
<SolidColorBrush x:Key="ILSpy.LinkForeground" Color="#4DA6FF" />
<SolidColorBrush x:Key="ILSpy.ToolbarBackground" Color="#2D2D30" />
+3 -1
View File
@@ -85,7 +85,9 @@ namespace ICSharpCode.ILSpy.Options
// 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,
[nameof(DisplaySettings.NavigateOnCtrlClick)] = DisplaySettingReaction.EditorLive,
// The text view re-evaluates the caret highlight itself; nothing needs re-decompilation.
[nameof(DisplaySettings.HighlightSymbolsUnderCaret)] = DisplaySettingReaction.EditorLive,
// No model-side reaction.
[nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None,
+8 -3
View File
@@ -109,7 +109,10 @@ namespace ICSharpCode.ILSpy.Options
bool enableOmnibar;
[ObservableProperty]
bool highlightMemberReferences;
bool navigateOnCtrlClick;
[ObservableProperty]
bool highlightSymbolsUnderCaret = true;
public XName SectionName => "DisplaySettings";
@@ -139,7 +142,8 @@ 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;
NavigateOnCtrlClick = (bool?)section.Attribute(nameof(NavigateOnCtrlClick)) ?? false;
HighlightSymbolsUnderCaret = (bool?)section.Attribute(nameof(HighlightSymbolsUnderCaret)) ?? true;
}
public XElement SaveToXml()
@@ -169,7 +173,8 @@ namespace ICSharpCode.ILSpy.Options
section.SetAttributeValue(nameof(StyleWindowTitleBar), StyleWindowTitleBar);
section.SetAttributeValue(nameof(DecodeCustomAttributeBlobs), DecodeCustomAttributeBlobs);
section.SetAttributeValue(nameof(EnableOmnibar), EnableOmnibar);
section.SetAttributeValue(nameof(HighlightMemberReferences), HighlightMemberReferences);
section.SetAttributeValue(nameof(NavigateOnCtrlClick), NavigateOnCtrlClick);
section.SetAttributeValue(nameof(HighlightSymbolsUnderCaret), HighlightSymbolsUnderCaret);
return section;
}
}
+4 -2
View File
@@ -77,8 +77,10 @@
Content="{x:Static res:Resources.HighlightMatchingBraces}" />
<CheckBox IsChecked="{Binding Settings.HighlightCurrentLine, Mode=TwoWay}"
Content="{x:Static res:Resources.HighlightCurrentLine}" />
<CheckBox IsChecked="{Binding Settings.HighlightMemberReferences, Mode=TwoWay}"
Content="{x:Static res:Resources.HighlightMemberReferences}" />
<CheckBox IsChecked="{Binding Settings.NavigateOnCtrlClick, Mode=TwoWay}"
Content="{x:Static res:Resources.NavigateOnCtrlClick}" />
<CheckBox IsChecked="{Binding Settings.HighlightSymbolsUnderCaret, Mode=TwoWay}"
Content="{x:Static res:Resources.HighlightSymbolsUnderCaret}" />
<CheckBox IsChecked="{Binding Settings.ExpandXmlDocumentationComments, Mode=TwoWay}"
Content="{x:Static res:Resources.ExpandXmlDocumentationCommentsAfterDecompilation}" />
<CheckBox IsChecked="{Binding Settings.ExpandMemberDefinitions, Mode=TwoWay}"
+13 -4
View File
@@ -2218,14 +2218,14 @@ namespace ICSharpCode.ILSpy.Properties {
}
/// <summary>
/// Looks up a localized string similar to Single-click highlights member references (Ctrl+Click navigates).
/// Looks up a localized string similar to Highlight symbols under caret.
/// </summary>
public static string HighlightMemberReferences {
public static string HighlightSymbolsUnderCaret {
get {
return ResourceManager.GetString("HighlightMemberReferences", resourceCulture);
return ResourceManager.GetString("HighlightSymbolsUnderCaret", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to ILSpyAboutPage.txt.
/// </summary>
@@ -2388,6 +2388,15 @@ namespace ICSharpCode.ILSpy.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Navigate on Ctrl+Click.
/// </summary>
public static string NavigateOnCtrlClick {
get {
return ResourceManager.GetString("NavigateOnCtrlClick", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Navigation.
/// </summary>
+5 -2
View File
@@ -768,8 +768,8 @@ Are you sure you want to continue?</value>
<data name="HighlightMatchingBraces" xml:space="preserve">
<value>Highlight matching braces</value>
</data>
<data name="HighlightMemberReferences" xml:space="preserve">
<value>Single-click highlights member references (Ctrl+Click navigates)</value>
<data name="HighlightSymbolsUnderCaret" xml:space="preserve">
<value>Highlight symbols under caret</value>
</data>
<data name="ILSpyAboutPageTxt" xml:space="preserve">
<value>ILSpyAboutPage.txt</value>
@@ -831,6 +831,9 @@ Are you sure you want to continue?</value>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="NavigateOnCtrlClick" xml:space="preserve">
<value>Navigate on Ctrl+Click</value>
</data>
<data name="Navigation" xml:space="preserve">
<value>Navigation</value>
</data>
+89 -41
View File
@@ -68,11 +68,9 @@ namespace ICSharpCode.ILSpy.TextView
// pointer-distance corridor; plain content uses Avalonia's ToolTip attached property.
internal readonly record struct HoverContent(Control Control, bool IsRich);
// Local-reference highlight colours: light-yellow "GreenYellow" for matches and a
// softer green for the actual definition.
static readonly Color LocalMatchBackground = Colors.GreenYellow;
static readonly Color LocalDefinitionBackground = Color.FromArgb(0x80, 0xA0, 0xFF, 0xA0);
static readonly Color DebugStepBackground = Color.FromArgb(0x80, 0xFF, 0xD7, 0x66);
const string SymbolHighlightBrushKey = "ILSpy.SymbolHighlightBrush";
const string SymbolDefinitionHighlightBrushKey = "ILSpy.SymbolDefinitionHighlightBrush";
// Stay-open corridor for the rich popup: as long as the pointer is closer than
// `distanceToPopupLimit` to the popup edges, the popup stays. The limit shrinks toward
@@ -87,7 +85,11 @@ namespace ICSharpCode.ILSpy.TextView
TextMarkerService textMarkerService = null!;
BracketHighlightRenderer bracketHighlightRenderer = null!;
readonly List<TextMarker> localReferenceMarks = new();
readonly List<TextMarker> caretUsageMarks = new();
readonly List<TextMarker> debugStepMarks = 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;
int debugStepHighlightVersion;
readonly List<AvaloniaEdit.Rendering.VisualLineElementGenerator> activeCustomGenerators = new();
RichTextColorizer? activeColorizer;
@@ -132,8 +134,8 @@ namespace ICSharpCode.ILSpy.TextView
// 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.
// like the About-page resource generator). Decompiler references are handled below
// so NavigateOnCtrlClick can be applied consistently.
Editor.Options.RequireControlModifierForHyperlinkClick = false;
// The view is read-only, so Tab has nothing to indent; with AcceptsTab the text area marks
// the key handled and the focus could never leave the editor. Leaving it unhandled lets
@@ -155,6 +157,7 @@ namespace ICSharpCode.ILSpy.TextView
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.
@@ -311,11 +314,7 @@ namespace ICSharpCode.ILSpy.TextView
var segment = GetReferenceSegmentAtPointer(e);
if (segment?.Reference == null)
{
// A click on empty space dismisses the local-reference highlight.
ClearLocalReferenceMarks();
return;
}
// AvaloniaEdit's own release handler has already reset its selection mode and released
// the pointer capture (it runs first in the bubble phase). Clear the empty caret-click
// selection it leaves behind, then navigate; marking the release handled stops it from
@@ -329,7 +328,7 @@ namespace ICSharpCode.ILSpy.TextView
// cleared/rebuilt per click) and the bracket-pair outline (repainted on every caret move).
void SetupBackgroundRenderers()
{
textMarkerService = new TextMarkerService(Editor.TextArea.TextView);
textMarkerService = new TextMarkerService(Editor.TextArea.TextView, this);
Editor.TextArea.TextView.BackgroundRenderers.Add(textMarkerService);
bracketHighlightRenderer = new BracketHighlightRenderer(Editor.TextArea.TextView);
}
@@ -424,6 +423,72 @@ namespace ICSharpCode.ILSpy.TextView
/// <summary>The currently painted local-reference highlight marks (test observability).</summary>
internal IReadOnlyList<TextMarker> LocalReferenceMarks => localReferenceMarks;
/// <summary>The currently painted symbol-under-caret highlight marks (test observability).</summary>
internal IReadOnlyList<TextMarker> CaretUsageMarks => caretUsageMarks;
void SetupCaretUsageHighlight()
{
Editor.TextArea.Caret.PositionChanged += (_, _) => 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 { HighlightSymbolsUnderCaret: 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.BackgroundResourceKey = r.IsDefinition ? SymbolDefinitionHighlightBrushKey : SymbolHighlightBrushKey;
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();
}
// ThemeManager.Current is a process-lived singleton, so subscribing to its ThemeChanged in
// the constructor and never detaching would root every DecompilerTextView for the lifetime of
// the process -- one leaked view per decompiler tab. Bind the handler to the visual-tree
@@ -731,6 +796,12 @@ namespace ICSharpCode.ILSpy.TextView
// live without a re-decompile, per text view.
Omnibar.IsVisible = s.EnableOmnibar;
break;
case nameof(DisplaySettings.NavigateOnCtrlClick):
ApplyReferenceCursor(forceNavigation: false);
break;
case nameof(DisplaySettings.HighlightSymbolsUnderCaret):
UpdateCaretUsageHighlight();
break;
}
}
@@ -1175,6 +1246,7 @@ namespace ICSharpCode.ILSpy.TextView
// 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;
@@ -1323,19 +1395,6 @@ namespace ICSharpCode.ILSpy.TextView
return model.References.FindSegmentsContaining(offset).FirstOrDefault();
}
/// <summary>
/// True when a plain click on <paramref name="segment"/> 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.
/// </summary>
bool ShouldHighlightInsteadOfNavigate(ReferenceSegment segment, bool forceNavigation)
{
return !forceNavigation
&& segment.Kind == ReferenceMode.Link
&& currentDisplaySettings is { HighlightMemberReferences: true }
&& segment.Reference is IMember or IType or EntityReference;
}
InputElement? cursorQueryElement;
ReferenceSegment? cursorQuerySegment;
@@ -1354,7 +1413,7 @@ namespace ICSharpCode.ILSpy.TextView
if (cursorQueryElement == null || cursorQuerySegment == null)
return;
bool navigates = cursorQuerySegment.Kind == ReferenceMode.Link
&& !ShouldHighlightInsteadOfNavigate(cursorQuerySegment, forceNavigation);
&& (forceNavigation || currentDisplaySettings is not { NavigateOnCtrlClick: true });
cursorQueryElement.Cursor = new Cursor(navigates ? StandardCursorType.Hand : StandardCursorType.Arrow);
}
@@ -1380,23 +1439,12 @@ namespace ICSharpCode.ILSpy.TextView
if (segment.Kind == ReferenceMode.HoverOnly)
return;
// Local references stay inside this document — paint every match and let the user
// scrub through them. Cross-document references clear any existing marks since the
// view is about to refresh anyway.
if (segment.Kind == ReferenceMode.LocalHighlight)
{
HighlightLocalReferences(model, segment.Reference);
if (segment.Kind != ReferenceMode.Link)
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 (clickKind == ReferenceClickKind.Default && ShouldHighlightInsteadOfNavigate(segment, forceNavigation: false))
{
HighlightLocalReferences(model, segment.Reference);
if (clickKind == ReferenceClickKind.Default && currentDisplaySettings is { NavigateOnCtrlClick: true })
return;
}
ClearLocalReferenceMarks();
// In-document jumps win when the definition is in this same view.
@@ -1430,7 +1478,7 @@ namespace ICSharpCode.ILSpy.TextView
if (!AreSameReference(reference, r.Reference))
continue;
var mark = textMarkerService.Create(r.StartOffset, r.Length);
mark.BackgroundColor = r.IsDefinition ? LocalDefinitionBackground : LocalMatchBackground;
mark.BackgroundResourceKey = r.IsDefinition ? SymbolDefinitionHighlightBrushKey : SymbolHighlightBrushKey;
localReferenceMarks.Add(mark);
}
}
+28 -3
View File
@@ -21,6 +21,7 @@ using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
@@ -39,11 +40,13 @@ namespace ICSharpCode.ILSpy.TextView
sealed class TextMarkerService : IBackgroundRenderer
{
readonly AvaloniaEdit.Rendering.TextView textView;
readonly Control resourceHost;
TextSegmentCollection<TextMarker>? markers;
public TextMarkerService(AvaloniaEdit.Rendering.TextView textView)
public TextMarkerService(AvaloniaEdit.Rendering.TextView textView, Control resourceHost)
{
this.textView = textView ?? throw new ArgumentNullException(nameof(textView));
this.resourceHost = resourceHost ?? throw new ArgumentNullException(nameof(resourceHost));
textView.DocumentChanged += OnDocumentChanged;
OnDocumentChanged(null, EventArgs.Empty);
}
@@ -101,15 +104,26 @@ namespace ICSharpCode.ILSpy.TextView
int viewEnd = visualLines.Last().LastDocumentLine.EndOffset;
foreach (var marker in markers.FindOverlappingSegments(viewStart, viewEnd - viewStart))
{
if (marker.BackgroundColor is not { } bg)
if (GetBackgroundBrush(marker) is not { } brush)
continue;
var geo = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 3 };
geo.AddSegment(textView, marker);
var geometry = geo.CreateGeometry();
if (geometry != null)
drawingContext.DrawGeometry(new SolidColorBrush(bg), null, geometry);
drawingContext.DrawGeometry(brush, null, geometry);
}
}
IBrush? GetBackgroundBrush(TextMarker marker)
{
if (marker.BackgroundResourceKey != null
&& resourceHost.TryFindResource(marker.BackgroundResourceKey, resourceHost.ActualThemeVariant, out var resource)
&& resource is IBrush brush)
{
return brush;
}
return marker.BackgroundColor is { } bg ? new SolidColorBrush(bg) : null;
}
}
/// <summary>
@@ -120,6 +134,7 @@ namespace ICSharpCode.ILSpy.TextView
{
readonly TextMarkerService service;
Color? backgroundColor;
string? backgroundResourceKey;
public TextMarker(TextMarkerService service, int startOffset, int length)
{
@@ -137,5 +152,15 @@ namespace ICSharpCode.ILSpy.TextView
service.Redraw(this);
}
}
public string? BackgroundResourceKey {
get => backgroundResourceKey;
set {
if (backgroundResourceKey == value)
return;
backgroundResourceKey = value;
service.Redraw(this);
}
}
}
}