Browse Source

Merge pull request #4044 from icsharpcode/fix/4040-search-result-icon-overlays

Fix #4040: Compose accessibility and static overlays onto search result icons
pull/4045/head
Christoph Wille 3 weeks ago committed by GitHub
parent
commit
542d3b0407
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 179
      ILSpy.Tests/Search/SearchResultFactoryIconTests.cs
  2. 5
      ILSpy/Analyzers/AnalyzedEventTreeNode.cs
  3. 5
      ILSpy/Analyzers/AnalyzedFieldTreeNode.cs
  4. 17
      ILSpy/Analyzers/AnalyzedMethodTreeNode.cs
  5. 5
      ILSpy/Analyzers/AnalyzedPropertyTreeNode.cs
  6. 17
      ILSpy/Analyzers/AnalyzedTypeTreeNode.cs
  7. 19
      ILSpy/Search/AvaloniaSearchResultFactory.cs
  8. 18
      ILSpy/TreeNodes/ComparisonEntryTreeNode.cs
  9. 4
      ILSpy/TreeNodes/DerivedTypesEntryNode.cs
  10. 6
      ILSpy/TreeNodes/EventTreeNode.cs
  11. 30
      ILSpy/TreeNodes/TypeTreeNode.cs

179
ILSpy.Tests/Search/SearchResultFactoryIconTests.cs

@ -0,0 +1,179 @@ @@ -0,0 +1,179 @@
// Copyright (c) 2026 Christoph Wille
//
// 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.Linq;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.Search;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Search;
// Search results must carry the same composed icons as the assembly tree: base symbol by
// entity kind, plus accessibility and static overlays. The factory therefore has to go
// through Images.GetIcon (via the tree nodes' GetIcon helpers), not hand out bare base
// images — a bare Images.Field for a private static field loses both mini-overlays.
[TestFixture]
public class SearchResultFactoryIconTests
{
AvaloniaSearchResultFactory factory = null!;
ITypeDefinition fixtureType = null!;
[OneTimeSetUp]
public void LoadFixtureTypeFromOwnAssembly()
{
// Read the fixture entities from this test assembly's own metadata, so the
// accessibility/static flags come from a real type system, not mocks.
var path = typeof(SearchResultFactoryIconTests).Assembly.Location;
var file = new PEFile(path);
var resolver = new UniversalAssemblyResolver(path, throwOnError: false,
targetFramework: file.DetectTargetFrameworkId());
var typeSystem = new DecompilerTypeSystem(file, resolver);
fixtureType = typeSystem.MainModule.TypeDefinitions
.Single(t => t.Name == nameof(SearchIconFixture));
factory = new AvaloniaSearchResultFactory(new CSharpLanguage());
}
[AvaloniaTest]
public void Private_Static_Field_Result_Composes_Static_And_Private_Overlays()
{
var field = fixtureType.Fields.Single(f => f.Name == "privateStaticField");
var result = factory.Create(field);
var icon = result.Image.Should().BeOfType<LayeredImage>().Subject;
icon.BaseImage.Should().BeSameAs(Images.Field);
icon.BaseScale.Should().Be(0.8);
icon.Overlays.Should().Equal(Images.OverlayStatic, Images.OverlayPrivate);
}
[AvaloniaTest]
public void Nested_Interface_Result_Uses_Interface_Base_Icon()
{
var nested = fixtureType.NestedTypes
.Single(t => t.Name == nameof(SearchIconFixture.INested)).GetDefinition()!;
var icon = (LayeredImage)factory.Create(nested).Image;
icon.BaseImage.Should().BeSameAs(Images.Interface);
}
[AvaloniaTest]
public void Nested_Enum_Result_Uses_Enum_Base_Icon()
{
var nested = fixtureType.NestedTypes
.Single(t => t.Name == "NestedEnum").GetDefinition()!;
var icon = (LayeredImage)factory.Create(nested).Image;
icon.BaseImage.Should().BeSameAs(Images.Enum);
}
[AvaloniaTest]
public void Protected_Internal_Nested_Type_Gets_Plain_Protected_Overlay()
{
var nested = fixtureType.NestedTypes
.Single(t => t.Name == "NestedProtectedInternal").GetDefinition()!;
var icon = (LayeredImage)factory.Create(nested).Image;
// Types map protected-internal to the plain protected badge; only members show
// the combined protected-internal badge. Matches the WPF frontend's type-only
// overlay mapping.
icon.Overlays.Should().Equal(Images.OverlayProtected);
}
[AvaloniaTest]
public void Location_Image_Reflects_Declaring_Type_Icon()
{
var field = fixtureType.Fields.Single(f => f.Name == "privateStaticField");
var result = factory.Create(field);
// The declaring type (SearchIconFixture) is a top-level internal class.
var location = result.LocationImage.Should().BeOfType<LayeredImage>().Subject;
location.BaseImage.Should().BeSameAs(Images.Class);
location.Overlays.Should().Equal(Images.OverlayInternal);
}
[AvaloniaTest]
public void Internal_Static_Method_Result_Composes_Static_And_Internal_Overlays()
{
var method = fixtureType.Methods.Single(m => m.Name == "InternalStaticMethod");
var icon = (LayeredImage)factory.Create(method).Image;
icon.BaseImage.Should().BeSameAs(Images.Method);
icon.Overlays.Should().Equal(Images.OverlayStatic, Images.OverlayInternal);
}
[AvaloniaTest]
public void Protected_Property_Result_Composes_Protected_Overlay()
{
var property = fixtureType.Properties.Single(p => p.Name == "ProtectedProperty");
var icon = (LayeredImage)factory.Create(property).Image;
icon.BaseImage.Should().BeSameAs(Images.Property);
icon.Overlays.Should().Equal(Images.OverlayProtected);
}
[AvaloniaTest]
public void Private_Event_Result_Composes_Private_Overlay()
{
var @event = fixtureType.Events.Single(e => e.Name == "privateEvent");
var icon = (LayeredImage)factory.Create(@event).Image;
icon.BaseImage.Should().BeSameAs(Images.Event);
icon.Overlays.Should().Equal(Images.OverlayPrivate);
}
[AvaloniaTest]
public void Top_Level_Type_Result_Falls_Back_To_Namespace_Location_Image()
{
// A top-level type has no declaring type, so the location column shows its namespace.
factory.Create(fixtureType).LocationImage.Should().BeSameAs(Images.Namespace);
}
}
#pragma warning disable CS0169, CS0067 // members exist only as metadata probes for the tests above
class SearchIconFixture
{
static int privateStaticField;
internal interface INested { }
enum NestedEnum { None }
protected internal class NestedProtectedInternal { }
internal static void InternalStaticMethod() { }
protected int ProtectedProperty { get; }
event System.EventHandler? privateEvent;
}
#pragma warning restore CS0169, CS0067

5
ILSpy/Analyzers/AnalyzedEventTreeNode.cs

@ -24,6 +24,8 @@ using AvaloniaEdit.Highlighting; @@ -24,6 +24,8 @@ using AvaloniaEdit.Highlighting;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.TreeNodes;
namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
{
internal sealed class AnalyzedEventTreeNode : AnalyzerEntityTreeNode
@ -47,8 +49,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes @@ -47,8 +49,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
protected override RichText? BuildRichText() => CreateMemberRichText(prefix, MemberSignatureFlags);
public override object Icon => Images.GetIcon(Images.Event,
Images.GetOverlay(analyzedEvent.Accessibility), analyzedEvent.IsStatic);
public override object Icon => EventTreeNode.GetIcon(analyzedEvent);
protected override void LoadChildren()
{

5
ILSpy/Analyzers/AnalyzedFieldTreeNode.cs

@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting; @@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.TreeNodes;
namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
{
internal sealed class AnalyzedFieldTreeNode : AnalyzerEntityTreeNode
@ -44,8 +46,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes @@ -44,8 +46,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
protected override RichText? BuildRichText() => CreateMemberRichText("", MemberSignatureFlags);
public override object Icon => Images.GetIcon(Images.Field,
Images.GetOverlay(analyzedField.Accessibility), analyzedField.IsStatic);
public override object Icon => FieldTreeNode.GetIcon(analyzedField);
protected override void LoadChildren() => AddAnalyzerChildren(analyzedField);
}

17
ILSpy/Analyzers/AnalyzedMethodTreeNode.cs

@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting; @@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.TreeNodes;
namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
{
internal class AnalyzedMethodTreeNode : AnalyzerEntityTreeNode
@ -46,20 +48,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes @@ -46,20 +48,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
protected override RichText? BuildRichText() => CreateMemberRichText(prefix, MemberSignatureFlags);
public override object Icon => ResolveIcon(analyzedMethod);
internal static object ResolveIcon(IMethod method)
{
var baseImage = method.IsConstructor
? Images.Constructor
: method.IsOperator
? Images.Operator
: Images.Method;
return Images.GetIcon(baseImage,
Images.GetOverlay(method.Accessibility),
method.IsStatic,
method.IsExtensionMethod);
}
public override object Icon => MethodTreeNode.GetIcon(analyzedMethod);
protected override void LoadChildren() => AddAnalyzerChildren(analyzedMethod);
}

5
ILSpy/Analyzers/AnalyzedPropertyTreeNode.cs

@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting; @@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.TreeNodes;
namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
{
internal sealed class AnalyzedPropertyTreeNode : AnalyzerEntityTreeNode
@ -46,8 +48,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes @@ -46,8 +48,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
protected override RichText? BuildRichText() => CreateMemberRichText(prefix, MemberSignatureFlags);
public override object Icon => Images.GetIcon(Images.Property,
Images.GetOverlay(analyzedProperty.Accessibility), analyzedProperty.IsStatic);
public override object Icon => PropertyTreeNode.GetIcon(analyzedProperty);
protected override void LoadChildren()
{

17
ILSpy/Analyzers/AnalyzedTypeTreeNode.cs

@ -22,6 +22,8 @@ using AvaloniaEdit.Highlighting; @@ -22,6 +22,8 @@ using AvaloniaEdit.Highlighting;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.TreeNodes;
namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
{
internal sealed class AnalyzedTypeTreeNode : AnalyzerEntityTreeNode
@ -41,20 +43,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes @@ -41,20 +43,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes
protected override RichText? BuildRichText() => CreateMemberRichText("", TypeSignatureFlags);
public override object Icon => ResolveIcon(analyzedType);
static object ResolveIcon(ITypeDefinition type)
{
var baseImage = type.Kind switch {
TypeKind.Interface => Images.Interface,
TypeKind.Struct or TypeKind.Void => Images.Struct,
TypeKind.Delegate => Images.Delegate,
TypeKind.Enum => Images.Enum,
_ => Images.Class,
};
return Images.GetIcon(baseImage,
Images.GetOverlay(type.Accessibility), type.IsStatic);
}
public override object Icon => TypeTreeNode.GetIcon(analyzedType);
protected override void LoadChildren() => AddAnalyzerChildren(analyzedType);
}

19
ILSpy/Search/AvaloniaSearchResultFactory.cs

@ -26,6 +26,7 @@ using ICSharpCode.ILSpyX.Abstractions; @@ -26,6 +26,7 @@ using ICSharpCode.ILSpyX.Abstractions;
using ICSharpCode.ILSpyX.Search;
using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.TreeNodes;
namespace ICSharpCode.ILSpy.Search
{
@ -56,7 +57,7 @@ namespace ICSharpCode.ILSpy.Search @@ -56,7 +57,7 @@ namespace ICSharpCode.ILSpy.Search
Assembly = entity.ParentModule?.FullAssemblyName ?? string.Empty,
ToolTip = entity.ParentModule?.MetadataFile?.FileName,
Image = GetIcon(entity),
LocationImage = declaringType != null ? Images.Class : Images.Namespace,
LocationImage = declaringType != null ? TypeTreeNode.GetIcon(declaringType) : Images.Namespace,
AssemblyImage = Images.Assembly,
};
}
@ -66,9 +67,9 @@ namespace ICSharpCode.ILSpy.Search @@ -66,9 +67,9 @@ namespace ICSharpCode.ILSpy.Search
return new ResourceSearchResult {
Resource = resource,
Fitness = 1.0f / Math.Max(1, resource.Name.Length),
Image = Images.Library,
Image = node.Icon ?? Images.Resource,
Name = resource.Name,
LocationImage = Images.Library,
LocationImage = parent.Icon ?? Images.Library,
Location = (parent.Text as string) ?? string.Empty,
Assembly = module.FullName,
ToolTip = module.FileName,
@ -123,12 +124,14 @@ namespace ICSharpCode.ILSpy.Search @@ -123,12 +124,14 @@ namespace ICSharpCode.ILSpy.Search
_ => member.Name,
};
// Delegate to the tree nodes' icon helpers so search results carry the same composed
// icons (kind-specific base + accessibility/static overlays) as the assembly tree.
static object GetIcon(IEntity member) => member switch {
ITypeDefinition => Images.Class,
IField => Images.Field,
IProperty => Images.Property,
IMethod => Images.Method,
IEvent => Images.Event,
ITypeDefinition t => TypeTreeNode.GetIcon(t),
IField f => FieldTreeNode.GetIcon(f),
IProperty p => PropertyTreeNode.GetIcon(p),
IMethod m => MethodTreeNode.GetIcon(m),
IEvent e => EventTreeNode.GetIcon(e),
_ => Images.Library,
};
}

18
ILSpy/TreeNodes/ComparisonEntryTreeNode.cs

@ -82,24 +82,16 @@ namespace ICSharpCode.ILSpy.Compare @@ -82,24 +82,16 @@ namespace ICSharpCode.ILSpy.Compare
};
public override object Icon => entry.Entity switch {
ITypeDefinition t => TypeIconForKind(t),
IMethod => ICSharpCode.ILSpy.Images.Method,
IField => ICSharpCode.ILSpy.Images.Field,
IProperty => ICSharpCode.ILSpy.Images.Property,
IEvent => ICSharpCode.ILSpy.Images.Event,
ITypeDefinition t => TypeTreeNode.GetIcon(t),
IMethod m => MethodTreeNode.GetIcon(m),
IField f => FieldTreeNode.GetIcon(f),
IProperty p => PropertyTreeNode.GetIcon(p),
IEvent e => EventTreeNode.GetIcon(e),
INamespace => ICSharpCode.ILSpy.Images.Namespace,
IModule => ICSharpCode.ILSpy.Images.Assembly,
_ => ICSharpCode.ILSpy.Images.Class,
};
static object TypeIconForKind(ITypeDefinition t) => t.Kind switch {
TypeKind.Interface => ICSharpCode.ILSpy.Images.Interface,
TypeKind.Struct => ICSharpCode.ILSpy.Images.Struct,
TypeKind.Enum => ICSharpCode.ILSpy.Images.Enum,
TypeKind.Delegate => ICSharpCode.ILSpy.Images.Delegate,
_ => ICSharpCode.ILSpy.Images.Class,
};
public override void Decompile(Language language, ICSharpCode.Decompiler.ITextOutput output, DecompilationOptions options)
{
// Compare nodes aren't decompilable on their own — they're a diff overlay.

4
ILSpy/TreeNodes/DerivedTypesEntryNode.cs

@ -53,9 +53,7 @@ namespace ICSharpCode.ILSpy.TreeNodes @@ -53,9 +53,7 @@ namespace ICSharpCode.ILSpy.TreeNodes
public override object? NavigationText => $"{Text} ({ICSharpCode.ILSpy.Properties.Resources.DerivedTypes})";
public override object Icon => type.Kind == TypeKind.Interface
? Images.Interface
: Images.Class;
public override object Icon => TypeTreeNode.GetIcon(type);
protected override void LoadChildren()
{

6
ILSpy/TreeNodes/EventTreeNode.cs

@ -49,8 +49,10 @@ namespace ICSharpCode.ILSpy.TreeNodes @@ -49,8 +49,10 @@ namespace ICSharpCode.ILSpy.TreeNodes
public override object NavigationText => Language.EntityToString(EventDefinition, ConversionFlags.ShowDeclaringType);
public override object Icon => Images.GetIcon(Images.Event,
Images.GetOverlay(EventDefinition.Accessibility), EventDefinition.IsStatic);
public override object Icon => GetIcon(EventDefinition);
public static Avalonia.Media.IImage GetIcon(IEvent @event) => Images.GetIcon(Images.Event,
Images.GetOverlay(@event.Accessibility), @event.IsStatic);
public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
=> language.DecompileEvent(EventDefinition, output, options);

30
ILSpy/TreeNodes/TypeTreeNode.cs

@ -64,18 +64,24 @@ namespace ICSharpCode.ILSpy.TreeNodes @@ -64,18 +64,24 @@ namespace ICSharpCode.ILSpy.TreeNodes
public override object Text
=> Language.TypeToString(typeDefinition, ConversionFlags.None) + GetSuffixString(handle);
public override object Icon {
get {
var baseImage = typeDefinition.Kind switch {
TypeKind.Interface => Images.Interface,
TypeKind.Struct or TypeKind.Void => Images.Struct,
TypeKind.Delegate => Images.Delegate,
TypeKind.Enum => Images.Enum,
_ => Images.Class,
};
return Images.GetIcon(baseImage,
Images.GetOverlay(typeDefinition.Accessibility), typeDefinition.IsStatic);
}
public override object Icon => GetIcon(typeDefinition);
public static Avalonia.Media.IImage GetIcon(ITypeDefinition type)
{
var baseImage = type.Kind switch {
TypeKind.Interface => Images.Interface,
TypeKind.Struct or TypeKind.Void => Images.Struct,
TypeKind.Delegate => Images.Delegate,
TypeKind.Enum => Images.Enum,
_ => Images.Class,
};
// Types map protected-internal to the plain protected badge; only members show
// the combined protected-internal badge. Matches the WPF frontend's type-only
// overlay mapping.
var overlay = type.Accessibility == Accessibility.ProtectedOrInternal
? AccessOverlayIcon.Protected
: Images.GetOverlay(type.Accessibility);
return Images.GetIcon(baseImage, overlay, type.IsStatic);
}
public override bool CanExpandRecursively => true;

Loading…
Cancel
Save