diff --git a/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs index f0d1430ed..f2990f0b5 100644 --- a/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs +++ b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs @@ -38,9 +38,8 @@ 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. +/// Highlight symbols under caret (ReSharper-style): while the caret rests on a symbol, every +/// occurrence of that symbol in the document is marked. /// [TestFixture] public class CaretUsageHighlightTests @@ -49,7 +48,7 @@ public class CaretUsageHighlightTests { var (window, vm) = await TestHarness.BootAsync(); var settings = AppComposition.Current.GetExport().DisplaySettings; - settings.HighlightUsagesAtCaret = enabled; + settings.HighlightSymbolsUnderCaret = enabled; await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location); var typeNode = vm.AssemblyTreeModel.FindNode( "ILSpy.Tests", @@ -67,18 +66,18 @@ public class CaretUsageHighlightTests .ToList(); [Test] - public void Setting_Defaults_To_Off_And_Round_Trips() + public void Setting_Defaults_To_On_And_Round_Trips() { - new DisplaySettings().HighlightUsagesAtCaret.Should().BeFalse(); + new DisplaySettings().HighlightSymbolsUnderCaret.Should().BeTrue(); var loaded = new DisplaySettings(); loaded.LoadFromXml(new XElement("DisplaySettings")); - loaded.HighlightUsagesAtCaret.Should().BeFalse("a missing attribute keeps the default"); + loaded.HighlightSymbolsUnderCaret.Should().BeTrue("a missing attribute keeps the default"); - var on = new DisplaySettings { HighlightUsagesAtCaret = true }; + var on = new DisplaySettings { HighlightSymbolsUnderCaret = false }; var reloaded = new DisplaySettings(); reloaded.LoadFromXml(on.SaveToXml()); - reloaded.HighlightUsagesAtCaret.Should().BeTrue(); + reloaded.HighlightSymbolsUnderCaret.Should().BeFalse(); } [AvaloniaTest] @@ -93,7 +92,7 @@ public class CaretUsageHighlightTests 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"); + view.LocalReferenceMarks.Should().BeEmpty("the caret highlight must not touch external reference-highlight marks"); } [AvaloniaTest] @@ -147,10 +146,22 @@ public class CaretUsageHighlightTests view.Editor.TextArea.Caret.Offset = use.StartOffset; view.CaretUsageMarks.Should().BeEmpty("the option is off"); - settings.HighlightUsagesAtCaret = true; + settings.HighlightSymbolsUnderCaret = true; view.CaretUsageMarks.Should().NotBeEmpty("turning the option on highlights the symbol already under the caret"); - settings.HighlightUsagesAtCaret = false; + 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"); + } } diff --git a/ILSpy.Tests/Editor/LocalReferenceHighlightTests.cs b/ILSpy.Tests/Editor/LocalReferenceHighlightTests.cs index 83fc8af6d..491d6a0cb 100644 --- a/ILSpy.Tests/Editor/LocalReferenceHighlightTests.cs +++ b/ILSpy.Tests/Editor/LocalReferenceHighlightTests.cs @@ -48,14 +48,14 @@ public class GenericLocalFunctionSample } /// -/// 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. /// [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"); } } diff --git a/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs b/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs index dd922adf3..2a06cc4cd 100644 --- a/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs +++ b/ILSpy.Tests/Editor/MemberReferenceHighlightTests.cs @@ -67,18 +67,16 @@ public class MemberHighlightSample } /// -/// 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. /// [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().DisplaySettings.HighlightMemberReferences = highlightMemberReferences; + AppComposition.Current.GetExport().DisplaySettings.NavigateOnCtrlClick = navigateOnCtrlClick; await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location); var typeNode = vm.AssemblyTreeModel.FindNode( "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 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 (_, 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().DisplaySettings.HighlightMemberReferences = true; + AppComposition.Current.GetExport().DisplaySettings.NavigateOnCtrlClick = true; var coreLibName = typeof(object).Assembly.GetName().Name!; var stringNode = vm.AssemblyTreeModel.FindNode(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().DisplaySettings.HighlightMemberReferences = true; + AppComposition.Current.GetExport().DisplaySettings.NavigateOnCtrlClick = true; var coreLibName = typeof(object).Assembly.GetName().Name!; var stringNode = vm.AssemblyTreeModel.FindNode(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(); - } } diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml index f457d07a9..5b28e3bb5 100644 --- a/ILSpy/App.axaml +++ b/ILSpy/App.axaml @@ -44,6 +44,8 @@ TextArea style leaves SelectionForeground unset). --> + + + + diff --git a/ILSpy/Options/DisplaySettingReactions.cs b/ILSpy/Options/DisplaySettingReactions.cs index d08bfb7fa..531754b66 100644 --- a/ILSpy/Options/DisplaySettingReactions.cs +++ b/ILSpy/Options/DisplaySettingReactions.cs @@ -85,9 +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.HighlightUsagesAtCaret)] = DisplaySettingReaction.EditorLive, + [nameof(DisplaySettings.HighlightSymbolsUnderCaret)] = DisplaySettingReaction.EditorLive, // No model-side reaction. [nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None, diff --git a/ILSpy/Options/DisplaySettings.cs b/ILSpy/Options/DisplaySettings.cs index ac3e9a842..6f0ee5471 100644 --- a/ILSpy/Options/DisplaySettings.cs +++ b/ILSpy/Options/DisplaySettings.cs @@ -109,10 +109,10 @@ namespace ICSharpCode.ILSpy.Options bool enableOmnibar; [ObservableProperty] - bool highlightMemberReferences; + bool navigateOnCtrlClick; [ObservableProperty] - bool highlightUsagesAtCaret; + bool highlightSymbolsUnderCaret = true; public XName SectionName => "DisplaySettings"; @@ -142,8 +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; - HighlightUsagesAtCaret = (bool?)section.Attribute(nameof(HighlightUsagesAtCaret)) ?? false; + NavigateOnCtrlClick = (bool?)section.Attribute(nameof(NavigateOnCtrlClick)) ?? false; + HighlightSymbolsUnderCaret = (bool?)section.Attribute(nameof(HighlightSymbolsUnderCaret)) ?? true; } public XElement SaveToXml() @@ -173,8 +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(HighlightUsagesAtCaret), HighlightUsagesAtCaret); + section.SetAttributeValue(nameof(NavigateOnCtrlClick), NavigateOnCtrlClick); + section.SetAttributeValue(nameof(HighlightSymbolsUnderCaret), HighlightSymbolsUnderCaret); return section; } } diff --git a/ILSpy/Options/DisplaySettingsPanel.axaml b/ILSpy/Options/DisplaySettingsPanel.axaml index 2a4fdc01d..89d5f08a1 100644 --- a/ILSpy/Options/DisplaySettingsPanel.axaml +++ b/ILSpy/Options/DisplaySettingsPanel.axaml @@ -77,10 +77,10 @@ Content="{x:Static res:Resources.HighlightMatchingBraces}" /> - - + + - /// 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. /// - public static string HighlightMemberReferences { + public static string HighlightSymbolsUnderCaret { get { - return ResourceManager.GetString("HighlightMemberReferences", resourceCulture); + return ResourceManager.GetString("HighlightSymbolsUnderCaret", resourceCulture); } } - - /// - /// 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. /// @@ -2397,6 +2388,15 @@ namespace ICSharpCode.ILSpy.Properties { } } + /// + /// Looks up a localized string similar to Navigate on Ctrl+Click. + /// + public static string NavigateOnCtrlClick { + get { + return ResourceManager.GetString("NavigateOnCtrlClick", resourceCulture); + } + } + /// /// Looks up a localized string similar to Navigation. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 8e1c735fb..30fcbb113 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -768,11 +768,8 @@ Are you sure you want to continue? Highlight matching braces - - Single-click highlights member references (Ctrl+Click navigates) - - - Highlight usages of the symbol under the caret + + Highlight symbols under caret ILSpyAboutPage.txt @@ -834,6 +831,9 @@ Are you sure you want to continue? Name + + Navigate on Ctrl+Click + Navigation diff --git a/ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs b/ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs deleted file mode 100644 index 2802deaad..000000000 --- a/ILSpy/TextView/DecompilerTextView.CaretUsageHighlight.cs +++ /dev/null @@ -1,121 +0,0 @@ -// 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 fad56c392..aa71fddca 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -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 localReferenceMarks = new(); + readonly List caretUsageMarks = new(); readonly List 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 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 @@ -312,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 @@ -330,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); } @@ -425,6 +423,72 @@ namespace ICSharpCode.ILSpy.TextView /// The currently painted local-reference highlight marks (test observability). internal IReadOnlyList LocalReferenceMarks => localReferenceMarks; + /// The currently painted symbol-under-caret highlight marks (test observability). + internal IReadOnlyList CaretUsageMarks => caretUsageMarks; + + void SetupCaretUsageHighlight() + { + Editor.TextArea.Caret.PositionChanged += (_, _) => 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 { 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 @@ -732,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; } } @@ -1325,19 +1395,6 @@ namespace ICSharpCode.ILSpy.TextView return model.References.FindSegmentsContaining(offset).FirstOrDefault(); } - /// - /// 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 forceNavigation) - { - return !forceNavigation - && segment.Kind == ReferenceMode.Link - && currentDisplaySettings is { HighlightMemberReferences: true } - && segment.Reference is IMember or IType or EntityReference; - } - InputElement? cursorQueryElement; ReferenceSegment? cursorQuerySegment; @@ -1356,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); } @@ -1382,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. @@ -1432,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); } } diff --git a/ILSpy/TextView/TextMarkerService.cs b/ILSpy/TextView/TextMarkerService.cs index 4f543dd00..cd30a2486 100644 --- a/ILSpy/TextView/TextMarkerService.cs +++ b/ILSpy/TextView/TextMarkerService.cs @@ -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? 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; + } } /// @@ -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); + } + } } }