From db71c82f19058f26d33dfcc9e67ad91e2604d29d Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Mon, 9 Mar 2026 05:23:25 -0400 Subject: [PATCH 01/16] Add EnumDescriptionConverter This converter transforms Enum values into strings by checking for DisplayAttribute or DescriptionAttribute. It prioritizes the Name property of DisplayAttribute, then the Description property of DescriptionAttribute, and finally falls back to the Enum's string representation. Unit tests are included to verify attribute precedence, whitespace handling, and fallback behavior. --- .../EnumDescriptionConverterTests.cs | 109 ++++++++++++++++++ .../Converters/EnumDescriptionConverter.cs | 51 ++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs create mode 100644 src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs diff --git a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs new file mode 100644 index 0000000000..d3366a0f5c --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs @@ -0,0 +1,109 @@ +using Xunit; + +using CommunityToolkit.Maui.Converters; + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace CommunityToolkit.Maui.UnitTests.Converters; + +public class EnumDescriptionConverterTests +{ + enum TestEnum + { + [Display(Name = "Display Name")] + WithDisplay, + [Description("Description Text")] + WithDescription, + NoAttribute + } + + enum MultiAttributeEnum + { + [Display(Name = "Display Name")] + [Description("Description Text")] + Both, + [Display(Name = "")] + [Description("Description Text")] + EmptyDisplay, + [Display(Name = " ")] + [Description("Description Text")] + WhitespaceDisplay, + [Description("")] + EmptyDescription, + } + + [Fact] + public void ConvertFrom_ThrowsArgumentNullException_WhenNull() + { + var converter = new EnumDescriptionConverter(); + Assert.Throws(() => converter.ConvertFrom(null!)); + } + + [Fact] + public void ConvertFrom_FallbackToValueToString_WhenInvalidEnumValue() + { + var converter = new EnumDescriptionConverter(); + // Cast an int not defined in MultiAttributeEnum + var invalid = (MultiAttributeEnum)999; + var result = converter.ConvertFrom(invalid); + Assert.Equal("999", result); + } + + [Fact] + public void ConvertFrom_ReturnsDisplayName() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(TestEnum.WithDisplay); + Assert.Equal("Display Name", result); + } + + [Fact] + public void ConvertFrom_ReturnsDescriptionText() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(TestEnum.WithDescription); + Assert.Equal("Description Text", result); + } + + [Fact] + public void ConvertFrom_ReturnsEnumName_WhenNoAttribute() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(TestEnum.NoAttribute); + Assert.Equal("NoAttribute", result); + } + + + [Fact] + public void ConvertFrom_DisplayTakesPrecedence_WhenBothAttributes() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(MultiAttributeEnum.Both); + Assert.Equal("Display Name", result); + } + + [Fact] + public void ConvertFrom_FallbackToDescription_WhenDisplayEmpty() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(MultiAttributeEnum.EmptyDisplay); + Assert.Equal("Description Text", result); + } + + [Fact] + public void ConvertFrom_FallbackToDescription_WhenDisplayWhitespace() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(MultiAttributeEnum.WhitespaceDisplay); + Assert.Equal("Description Text", result); + } + + [Fact] + public void ConvertFrom_FallbackToEnumName_WhenDescriptionEmpty() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(MultiAttributeEnum.EmptyDescription); + Assert.Equal("EmptyDescription", result); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs new file mode 100644 index 0000000000..4c7bb0b992 --- /dev/null +++ b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs @@ -0,0 +1,51 @@ + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Reflection; + +namespace CommunityToolkit.Maui.Converters; + +/// +/// Converts an value to its display string using or . +/// +[AcceptEmptyServiceProvider] +public partial class EnumDescriptionConverter : BaseConverterOneWay +{ + /// + public override string DefaultConvertReturnValue { get; set; } = string.Empty; + + /// + /// Converts an value to its display string. + /// + /// The enum value to convert. + /// The culture to use for the conversion (not used). + /// + /// The value of the if defined; + /// otherwise the value of the if defined; + /// otherwise the enum name as a string. + /// + public override string ConvertFrom(Enum value, CultureInfo? culture = null) + { + ArgumentNullException.ThrowIfNull(value); + var fieldInfo = value.GetType().GetField(value.ToString()); + + // Check for DisplayAttribute first (common in data annotations) + var displayAttr = fieldInfo?.GetCustomAttribute(); + if (displayAttr is not null && !string.IsNullOrWhiteSpace(displayAttr.Name)) + { + return displayAttr.Name; + } + + // Fallback to DescriptionAttribute + var descriptionAttr = fieldInfo?.GetCustomAttribute(); + if (descriptionAttr is not null && !string.IsNullOrWhiteSpace(descriptionAttr.Description)) + { + return descriptionAttr.Description; + } + + return value.ToString(); // Fallback to enum name if no attribute found + } + + // ConvertBackTo is sealed in BaseConverterOneWay and cannot be overridden. +} From ee49e0f168d98b81f729f8d976bbb24f5ac56eed Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Tue, 10 Mar 2026 03:52:07 -0400 Subject: [PATCH 02/16] Update EnumDescriptionConverter to support DisplayAttribute localization The converter now uses the GetName method on DisplayAttribute to retrieve localized strings when a ResourceType is provided. It includes fallback logic for cases where the resource is missing, the display name is whitespace, or the GetName method throws an exception. New unit tests verify these localization scenarios and edge cases for both DisplayAttribute and DescriptionAttribute. --- .../EnumDescriptionConverterTests.cs | 82 +++++++++++++++++++ .../Converters/EnumDescriptionConverter.cs | 82 +++++++++++-------- 2 files changed, 131 insertions(+), 33 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs index d3366a0f5c..36a2214f0d 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs @@ -106,4 +106,86 @@ public void ConvertFrom_FallbackToEnumName_WhenDescriptionEmpty() var result = converter.ConvertFrom(MultiAttributeEnum.EmptyDescription); Assert.Equal("EmptyDescription", result); } + + #region DisplayAttribute Localization & Edge Cases + + public class TestResources + { + public static string LocalizedDisplay => "Localized Display"; + public static int NonStringResource => 42; + } + + enum LocalizedEnum + { + [Display(Name = "LocalizedDisplay", ResourceType = typeof(TestResources))] + Localized, + [Display(Name = "MissingResource", ResourceType = typeof(TestResources))] + MissingResource, + [Display(Name = null)] + NullName, + [Display(Name = " ")] + WhitespaceName, + [Display(Name = "NonStringResource", ResourceType = typeof(TestResources))] + NonStringResource, + } + + [Fact] + public void ConvertFrom_ReturnsLocalizedDisplay_WhenResourceTypeSet() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(LocalizedEnum.Localized); + Assert.Equal("Localized Display", result); + } + + [Fact] + public void ConvertFrom_FallbackToName_WhenResourceMissing() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(LocalizedEnum.MissingResource); + Assert.Equal("MissingResource", result); + } + + [Fact] + public void ConvertFrom_FallbackToEnumName_WhenDisplayNameNull() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(LocalizedEnum.NullName); + Assert.Equal("NullName", result); + } + + [Fact] + public void ConvertFrom_FallbackToEnumName_WhenDisplayNameWhitespace() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(LocalizedEnum.WhitespaceName); + Assert.Equal("WhitespaceName", result); + } + + [Fact] + public void ConvertFrom_FallbackToEnumName_WhenGetNameThrows() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(LocalizedEnum.NonStringResource); + Assert.Equal("NonStringResource", result); + } + + #endregion + + #region DescriptionAttribute Edge Cases + + enum DescriptionEdgeEnum + { + [Description("")] + EmptyDescription, + } + + [Fact] + public void ConvertFrom_FallbackToEnumName_WhenDescriptionEmptyEdge() + { + var converter = new EnumDescriptionConverter(); + var result = converter.ConvertFrom(DescriptionEdgeEnum.EmptyDescription); + Assert.Equal("EmptyDescription", result); + } + + #endregion } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs index 4c7bb0b992..2529bacb4e 100644 --- a/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs +++ b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs @@ -1,4 +1,3 @@ - using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -12,40 +11,57 @@ namespace CommunityToolkit.Maui.Converters; [AcceptEmptyServiceProvider] public partial class EnumDescriptionConverter : BaseConverterOneWay { - /// - public override string DefaultConvertReturnValue { get; set; } = string.Empty; + /// + public override string DefaultConvertReturnValue { get; set; } = string.Empty; - /// - /// Converts an value to its display string. - /// - /// The enum value to convert. - /// The culture to use for the conversion (not used). - /// - /// The value of the if defined; - /// otherwise the value of the if defined; - /// otherwise the enum name as a string. - /// - public override string ConvertFrom(Enum value, CultureInfo? culture = null) - { - ArgumentNullException.ThrowIfNull(value); - var fieldInfo = value.GetType().GetField(value.ToString()); + /// + /// Converts an value to its display string. + /// + /// The enum value to convert. + /// The culture to use for the conversion (not used). + /// + /// The value of the if defined; + /// otherwise the value of the if defined; + /// otherwise the enum name as a string. + /// + public override string ConvertFrom(Enum value, CultureInfo? culture = null) + { + ArgumentNullException.ThrowIfNull(value); + var fieldInfo = value.GetType().GetField(value.ToString()); - // Check for DisplayAttribute first (common in data annotations) - var displayAttr = fieldInfo?.GetCustomAttribute(); - if (displayAttr is not null && !string.IsNullOrWhiteSpace(displayAttr.Name)) - { - return displayAttr.Name; - } + // Check for DisplayAttribute first (common in data annotations) + var displayAttr = fieldInfo?.GetCustomAttribute(); + if (displayAttr is not null) + { + string? displayName = null; + try + { + displayName = displayAttr.GetName(); + } + catch + { + // If GetName throws, fallback to Name + displayName = null; + } + if (!string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + if (!string.IsNullOrWhiteSpace(displayAttr.Name)) + { + return displayAttr.Name; + } + } - // Fallback to DescriptionAttribute - var descriptionAttr = fieldInfo?.GetCustomAttribute(); - if (descriptionAttr is not null && !string.IsNullOrWhiteSpace(descriptionAttr.Description)) - { - return descriptionAttr.Description; - } + // Fallback to DescriptionAttribute + var descriptionAttr = fieldInfo?.GetCustomAttribute(); + if (descriptionAttr is not null && !string.IsNullOrWhiteSpace(descriptionAttr.Description)) + { + return descriptionAttr.Description; + } - return value.ToString(); // Fallback to enum name if no attribute found - } + return value.ToString(); // Fallback to enum name if no attribute found + } - // ConvertBackTo is sealed in BaseConverterOneWay and cannot be overridden. -} + // ConvertBackTo is sealed in BaseConverterOneWay and cannot be overridden. +} \ No newline at end of file From 17ecc76253e84a84002deb93c7ac8aba10c5f4e7 Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Tue, 10 Mar 2026 04:03:16 -0400 Subject: [PATCH 03/16] Update Enum converter tests to use BaseOneWayConverterTest Update EnumDescriptionConverterTests to inherit from BaseOneWayConverterTest to align with standard value converter tests. --- .../Converters/EnumDescriptionConverterTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs index 36a2214f0d..7fbd963141 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs @@ -7,7 +7,7 @@ namespace CommunityToolkit.Maui.UnitTests.Converters; -public class EnumDescriptionConverterTests +public class EnumDescriptionConverterTests : BaseOneWayConverterTest { enum TestEnum { From 745cfc3e2b5ae268e27f957002c60a76c8e4f931 Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Tue, 10 Mar 2026 04:29:56 -0400 Subject: [PATCH 04/16] Update Enum converter tests to use ICommunityToolkitValueConverter Update the ConvertFrom_ThrowsArgumentNullException_WhenNull test in EnumDescriptionConverterTests.cs to cast the converter to ICommunityToolkitValueConverter and invoke the Convert method. --- .../Converters/EnumDescriptionConverterTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs index 7fbd963141..901f2859a1 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs @@ -34,11 +34,11 @@ enum MultiAttributeEnum } [Fact] - public void ConvertFrom_ThrowsArgumentNullException_WhenNull() - { - var converter = new EnumDescriptionConverter(); - Assert.Throws(() => converter.ConvertFrom(null!)); - } + public void ConvertFrom_ThrowsArgumentNullException_WhenNull() + { + var converter = (ICommunityToolkitValueConverter)new EnumDescriptionConverter(); + Assert.Throws(() => converter.Convert(null, typeof(string), null, null)); + } [Fact] public void ConvertFrom_FallbackToValueToString_WhenInvalidEnumValue() From 7cf4bf703f8fd76dad5b33786009ba9b708270f7 Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Wed, 11 Mar 2026 08:38:26 -0400 Subject: [PATCH 05/16] Add EnumDescriptionConverter This commit introduces an EnumDescriptionConverter that transforms Enum values into strings. It first checks for the DisplayAttribute, then the DescriptionAttribute. The conversions prioritize localized strings from DisplayAttribute based on a provided ResourceType. If unavailable or blank, it falls back to the DescriptionAttribute's Description property, and finally to the Enum's string representation. Unit tests ensure attribute precedence, whitespace handling, and fallback logic are working correctly. --- .../CommunityToolkit.Maui.Sample.csproj | 15 +- .../EnumDescriptionConverterPage.xaml | 12 + .../EnumDescriptionConverterPage.xaml.cs | 9 + .../Generators/EnumDescriptionGenerator.cs | 301 ++++++++++++++++++ .../EnumDescriptionConverterTests.cs | 8 +- .../Converters/EnumDescriptionConverter.cs | 118 ++++--- 6 files changed, 416 insertions(+), 47 deletions(-) create mode 100644 samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml create mode 100644 samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs create mode 100644 src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index 3d6dc87b16..7fcc66793a 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -30,8 +30,7 @@ IL2026 - + false true @@ -85,6 +84,18 @@ + + + EnumDescriptionConverterPage.xaml + + + + + + MSBuild:Compile + + + win-x64 diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml new file mode 100644 index 0000000000..e7147ed98b --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs new file mode 100644 index 0000000000..e5bf60ec25 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs @@ -0,0 +1,9 @@ +namespace CommunityToolkit.Maui.Sample.Pages.Converters; + +public partial class EnumDescriptionConverterPage : ContentPage +{ + public EnumDescriptionConverterPage() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs new file mode 100644 index 0000000000..a8f71d52d3 --- /dev/null +++ b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs @@ -0,0 +1,301 @@ +// CommunityToolkit.Maui.Analyzers/EnumDescriptionGenerator.cs +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CommunityToolkit.Maui.Analyzers; + +[Generator] +public class EnumDescriptionGenerator : IIncrementalGenerator +{ + // Using fields instead of const to avoid naming rule violations + static readonly string displayAttributeName = "System.ComponentModel.DataAnnotations.DisplayAttribute"; + static readonly string descriptionAttributeName = "System.ComponentModel.DescriptionAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider enumDeclarations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is EnumDeclarationSyntax, + transform: static (ctx, _) => ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol) + .Where(static enumSymbol => enumSymbol?.TypeKind == TypeKind.Enum) + .Where(static enumSymbol => enumSymbol is not null && IsAccessibleFromNamespace(enumSymbol)); + + context.RegisterSourceOutput(enumDeclarations, static (spc, enumSymbol) => + { + if (enumSymbol is null) + { + return; + } + + string code = GenerateCode(enumSymbol); + string hintName = $"{enumSymbol.ToDisplayString().Replace(".", "_")}.g.cs"; + spc.AddSource(hintName, SourceText.From(code, Encoding.UTF8)); + }); + } + + static string GenerateCode(INamedTypeSymbol enumSymbol) + { + string ns = enumSymbol.ContainingNamespace.ToDisplayString(); + string enumName = enumSymbol.Name; + string enumQualifiedName = enumSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + string initializerClassName = $"{enumName}DescriptionInitializer_{GetStableHash(enumQualifiedName)}"; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using CommunityToolkit.Maui.Converters;"); + sb.AppendLine(); + + if (!string.IsNullOrEmpty(ns)) + { + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); + } + + sb.AppendLine($"internal static class {initializerClassName}"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + sb.AppendLine(" internal static void Initialize()"); + sb.AppendLine(" {"); + sb.AppendLine(" var dict = new global::System.Collections.Generic.Dictionary();"); + sb.AppendLine(" var resolvers = new global::System.Collections.Generic.Dictionary>();"); + + foreach (IFieldSymbol member in enumSymbol.GetMembers().OfType()) + { + if (member.ConstantValue is null) + { + continue; + } + + string? descriptionFromDescriptionAttribute = GetDescriptionFromDescriptionAttribute(member); + string fallbackDescription = descriptionFromDescriptionAttribute ?? member.Name; + + if (TryGetDisplayInfo(member, out var displayName, out var resourceType) + && displayName is not null + && !string.IsNullOrWhiteSpace(displayName)) + { + if (resourceType is not null) + { + // displayName is checked for null above + if (TryGetLocalizedDisplayResolverExpression(resourceType, displayName!, enumSymbol.ContainingAssembly, out var localizedExpression)) + { + sb.AppendLine($" resolvers[\"{member.Name}\"] = static culture =>"); + sb.AppendLine(" {"); + sb.AppendLine($" var text = {localizedExpression};"); + sb.AppendLine($" return global::System.String.IsNullOrWhiteSpace(text) ? \"{EscapeString(fallbackDescription)}\" : text;"); + sb.AppendLine(" };"); + continue; + } + + // Cannot safely resolve localized resource access at compile-time + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EscapeString(fallbackDescription)}\";"); + continue; + } + + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EscapeString(displayName!)}\";"); + continue; + } + + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EscapeString(fallbackDescription)}\";"); + } + + sb.AppendLine(); + sb.AppendLine($" EnumDescriptionRegistry.Register(typeof({enumQualifiedName}), dict);"); + sb.AppendLine(" if (resolvers.Count > 0)"); + sb.AppendLine(" {"); + sb.AppendLine($" EnumDescriptionRegistry.Register(typeof({enumQualifiedName}), resolvers);"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + static string? GetDescriptionFromDescriptionAttribute(IFieldSymbol member) + { + // Check for DescriptionAttribute + AttributeData? descAttr = member.GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == descriptionAttributeName); + + if (descAttr != null && descAttr.ConstructorArguments.Length > 0) + { + if (descAttr.ConstructorArguments[0].Value is string description && !string.IsNullOrWhiteSpace(description)) + { + return description; + } + } + + return null; + } + + static bool TryGetDisplayInfo(IFieldSymbol member, out string? displayName, out INamedTypeSymbol? resourceType) + { + displayName = null; + resourceType = null; + + AttributeData? displayAttr = member.GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == displayAttributeName); + + if (displayAttr is null) + { + return false; + } + + foreach (KeyValuePair namedArg in displayAttr.NamedArguments) + { + if (namedArg.Key == "Name" && !namedArg.Value.IsNull && namedArg.Value.Value is string name) + { + displayName = name; + } + else if (namedArg.Key == "ResourceType" && !namedArg.Value.IsNull && namedArg.Value.Value is INamedTypeSymbol namedType) + { + resourceType = namedType; + } + else if (namedArg.Key == "ResourceType" && !namedArg.Value.IsNull && namedArg.Value.Value is ITypeSymbol typeSymbol && typeSymbol is INamedTypeSymbol resourceNamedType) + { + resourceType = resourceNamedType; + } + } + + return true; + } + + static bool TryGetLocalizedDisplayResolverExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) + { + expression = string.Empty; + + if (TryGetResourceManagerExpression(resourceType, resourceKey, targetAssembly, out var resourceManagerExpression)) + { + expression = resourceManagerExpression; + return true; + } + + if (SyntaxFacts.IsValidIdentifier(resourceKey) + && TryGetResourceMemberExpression(resourceType, resourceKey, targetAssembly, out var resourceMemberExpression)) + { + expression = resourceMemberExpression; + return true; + } + + return false; + } + + static bool TryGetResourceManagerExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) + { + expression = string.Empty; + + IPropertySymbol? resourceManagerProperty = resourceType + .GetMembers("ResourceManager") + .OfType() + .FirstOrDefault(static p => p.IsStatic && p.GetMethod is not null); + + if (resourceManagerProperty is null || !IsAccessibleToAssembly(resourceManagerProperty, targetAssembly)) + { + return false; + } + + if (resourceManagerProperty.Type.ToDisplayString() != "System.Resources.ResourceManager") + { + return false; + } + + var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + expression = $"{resourceTypeName}.ResourceManager.GetString(\"{EscapeString(resourceKey)}\", culture ?? global::System.Globalization.CultureInfo.CurrentUICulture)"; + return true; + } + + static bool TryGetResourceMemberExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) + { + expression = string.Empty; + + IPropertySymbol? property = resourceType.GetMembers(resourceKey).OfType() + .FirstOrDefault(static p => p.IsStatic && p.GetMethod is not null); + + if (property is not null) + { + if (property.Type.SpecialType == SpecialType.System_String && IsAccessibleToAssembly(property, targetAssembly)) + { + var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + expression = $"{resourceTypeName}.{resourceKey}"; + return true; + } + + return false; + } + + IFieldSymbol? field = resourceType.GetMembers(resourceKey).OfType() + .FirstOrDefault(static f => f.IsStatic); + + if (field is not null && field.Type.SpecialType == SpecialType.System_String && IsAccessibleToAssembly(field, targetAssembly)) + { + var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + expression = $"{resourceTypeName}.{resourceKey}"; + return true; + } + + return false; + } + + static bool IsAccessibleToAssembly(ISymbol symbol, IAssemblySymbol targetAssembly) + => symbol.DeclaredAccessibility switch + { + Accessibility.Public => true, + Accessibility.Internal or Accessibility.ProtectedOrInternal => SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, targetAssembly), + _ => false + }; + + static string EscapeString(string input) + { + return input.Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + } + + static string GetStableHash(string value) + { + unchecked + { + uint hash = 2166136261; + foreach (char c in value) + { + hash ^= c; + hash *= 16777619; + } + + return hash.ToString("x8", System.Globalization.CultureInfo.InvariantCulture); + } + } + + static bool IsAccessibleFromNamespace(INamedTypeSymbol enumSymbol) + { + if (!IsTypeAccessible(enumSymbol)) + { + return false; + } + + INamedTypeSymbol? containingType = enumSymbol.ContainingType; + while (containingType is not null) + { + if (!IsTypeAccessible(containingType)) + { + return false; + } + + containingType = containingType.ContainingType; + } + + return true; + } + + static bool IsTypeAccessible(INamedTypeSymbol typeSymbol) + => typeSymbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs index 901f2859a1..960ca3a3e1 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs @@ -9,7 +9,7 @@ namespace CommunityToolkit.Maui.UnitTests.Converters; public class EnumDescriptionConverterTests : BaseOneWayConverterTest { - enum TestEnum + public enum TestEnum { [Display(Name = "Display Name")] WithDisplay, @@ -18,7 +18,7 @@ enum TestEnum NoAttribute } - enum MultiAttributeEnum + public enum MultiAttributeEnum { [Display(Name = "Display Name")] [Description("Description Text")] @@ -115,7 +115,7 @@ public class TestResources public static int NonStringResource => 42; } - enum LocalizedEnum + public enum LocalizedEnum { [Display(Name = "LocalizedDisplay", ResourceType = typeof(TestResources))] Localized, @@ -173,7 +173,7 @@ public void ConvertFrom_FallbackToEnumName_WhenGetNameThrows() #region DescriptionAttribute Edge Cases - enum DescriptionEdgeEnum + public enum DescriptionEdgeEnum { [Description("")] EmptyDescription, diff --git a/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs index 2529bacb4e..f5d426011e 100644 --- a/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs +++ b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -6,62 +7,97 @@ namespace CommunityToolkit.Maui.Converters; /// -/// Converts an value to its display string using or . +/// Converts an enum to its description using either: +/// - [Display(Name = "text")] +/// - [Description("text")] /// [AcceptEmptyServiceProvider] public partial class EnumDescriptionConverter : BaseConverterOneWay { - /// + /// public override string DefaultConvertReturnValue { get; set; } = string.Empty; - /// - /// Converts an value to its display string. - /// - /// The enum value to convert. - /// The culture to use for the conversion (not used). - /// - /// The value of the if defined; - /// otherwise the value of the if defined; - /// otherwise the enum name as a string. - /// + /// public override string ConvertFrom(Enum value, CultureInfo? culture = null) { ArgumentNullException.ThrowIfNull(value); - var fieldInfo = value.GetType().GetField(value.ToString()); - // Check for DisplayAttribute first (common in data annotations) - var displayAttr = fieldInfo?.GetCustomAttribute(); - if (displayAttr is not null) + // Zero reflection - pure dictionary lookup + return EnumDescriptionRegistry.GetDescription(value, culture) ?? value.ToString(); + } + + // ConvertBackTo is sealed in BaseConverterOneWay and cannot be overridden. +} + + + +/// +/// Stores pre-computed enum descriptions from source generation +/// +public static class EnumDescriptionRegistry +{ + // Dictionary> + static readonly ConcurrentDictionary> descriptionCache = new(); + static readonly ConcurrentDictionary>> descriptionResolvers = new(); + + // Backward compatibility for previously generated code that assigned directly into the dictionary. + internal static ConcurrentDictionary> Descriptions => descriptionCache; + + /// + /// Registers descriptions for an enum type. + /// + /// Enum type to register the descriptions for. + /// Dictionary mapping enum member names to their description. + public static void Register(System.Type enumType, IReadOnlyDictionary descriptions) + { + ArgumentNullException.ThrowIfNull(enumType); + ArgumentNullException.ThrowIfNull(descriptions); + + descriptionCache[enumType] = new Dictionary(descriptions); + } + + /// + /// Registers culture-aware description resolvers for an enum type. + /// + /// Enum type to register the description resolvers for. + /// Dictionary mapping enum member names to a culture-aware resolver. + public static void Register(System.Type enumType, IReadOnlyDictionary> resolvers) + { + ArgumentNullException.ThrowIfNull(enumType); + ArgumentNullException.ThrowIfNull(resolvers); + + descriptionResolvers[enumType] = new Dictionary>(resolvers); + } + + /// + /// Gets the description for an enum value + /// + public static string? GetDescription(Enum value) + => GetDescription(value, culture: null); + + /// + /// Gets the description for an enum value. + /// + /// Enum value. + /// Culture to use when resolving localized descriptions. + /// Description if found; otherwise . + public static string? GetDescription(Enum value, CultureInfo? culture) + { + var type = value.GetType(); + var valueName = value.ToString(); + + if (descriptionResolvers.TryGetValue(type, out var resolverDict) + && resolverDict.TryGetValue(valueName, out var resolver)) { - string? displayName = null; - try - { - displayName = displayAttr.GetName(); - } - catch - { - // If GetName throws, fallback to Name - displayName = null; - } - if (!string.IsNullOrWhiteSpace(displayName)) - { - return displayName; - } - if (!string.IsNullOrWhiteSpace(displayAttr.Name)) - { - return displayAttr.Name; - } + return resolver(culture); } - // Fallback to DescriptionAttribute - var descriptionAttr = fieldInfo?.GetCustomAttribute(); - if (descriptionAttr is not null && !string.IsNullOrWhiteSpace(descriptionAttr.Description)) + if (descriptionCache.TryGetValue(type, out var enumDict) && + enumDict.TryGetValue(valueName, out var description)) { - return descriptionAttr.Description; + return description; } - return value.ToString(); // Fallback to enum name if no attribute found + return null; } - - // ConvertBackTo is sealed in BaseConverterOneWay and cannot be overridden. } \ No newline at end of file From 94edbf793c639af3e927c25cf08201e60c84f3ef Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Wed, 11 Mar 2026 11:08:53 -0400 Subject: [PATCH 06/16] Add EnumDescriptionConverter This commit introduces an EnumDescriptionConverter that transforms Enum values into strings. It prioritizes the Name property of DisplayAttribute or the Description property of DescriptionAttribute, and falls back to the Enum's string representation. Unit tests verify attribute precedence, whitespace handling, and fallback behavior. --- .../AppShell.xaml.cs | 2 + .../MauiProgram.cs | 5 ++ .../EnumDescriptionConverterPage.xaml | 68 ++++++++++++++++--- .../EnumDescriptionConverterPage.xaml.cs | 9 ++- .../Converters/ConvertersGalleryViewModel.cs | 1 + .../EnumDescriptionConverterViewModel.cs | 32 +++++++++ 6 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs diff --git a/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs b/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs index 12721c1b46..ef27e34678 100644 --- a/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; + using CommunityToolkit.Maui.Sample.Pages; using CommunityToolkit.Maui.Sample.Pages.Alerts; using CommunityToolkit.Maui.Sample.Pages.Behaviors; @@ -58,6 +59,7 @@ public partial class AppShell : Shell CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), + CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 78f8c2bcf8..71e4772628 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; + using CommunityToolkit.Maui.ApplicationModel; +using CommunityToolkit.Maui.Converters; using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Markup; using CommunityToolkit.Maui.Media; @@ -27,9 +29,11 @@ using CommunityToolkit.Maui.Sample.Views.Popups; using CommunityToolkit.Maui.Storage; using CommunityToolkit.Maui.Views; + using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Microsoft.Maui.LifecycleEvents; + using Polly; #if WINDOWS10_0_17763_0_OR_GREATER @@ -193,6 +197,7 @@ static void RegisterViewsAndViewModels(in IServiceCollection services) services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); + services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml index e7147ed98b..e6ba1b455b 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml @@ -1,12 +1,58 @@ - - - - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs index e5bf60ec25..38e7adad40 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs @@ -1,9 +1,12 @@ +using CommunityToolkit.Maui.Sample.ViewModels.Converters; + namespace CommunityToolkit.Maui.Sample.Pages.Converters; -public partial class EnumDescriptionConverterPage : ContentPage +public partial class EnumDescriptionConverterPage : BasePage { - public EnumDescriptionConverterPage() + public EnumDescriptionConverterPage(EnumDescriptionConverterViewModel enumDescriptionConverterViewModel) + : base(enumDescriptionConverterViewModel) { InitializeComponent(); } -} \ No newline at end of file +} diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/ConvertersGalleryViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/ConvertersGalleryViewModel.cs index febf791102..7adcf7cba6 100644 --- a/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/ConvertersGalleryViewModel.cs +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/ConvertersGalleryViewModel.cs @@ -11,6 +11,7 @@ public partial class ConvertersGalleryViewModel() : BaseGalleryViewModel( SectionModel.Create(nameof(CompareConverter), "A converter that compares two IComparable objects and returns a boolean value or one of two specified objects."), SectionModel.Create(nameof(DateTimeOffsetConverter), "A converter that allows to convert from a DateTimeOffset type to a DateTime type"), SectionModel.Create(nameof(DoubleToIntConverter), "A converter that allows users to convert an incoming double value to an int."), + SectionModel.Create(nameof(EnumDescriptionConverter), "A converter that converts Enum values into readable text so they display nicely in the UI"), SectionModel.Create(nameof(EnumToBoolConverter), "A converter that allows you to convert an Enum to boolean value"), SectionModel.Create(nameof(EnumToIntConverter), "A converter that allows you to convert an Enum to its underlying int value"), SectionModel.Create(nameof(IndexToArrayItemConverter), "A converter that allows users to convert a int value binding to an item in an array."), diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs new file mode 100644 index 0000000000..711d958964 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CommunityToolkit.Maui.Sample.ViewModels.Converters; + +public partial class EnumDescriptionConverterViewModel : BaseViewModel +{ + [ObservableProperty] + public partial ModeName SelectedMode { get; set; } + + public EnumDescriptionConverterViewModel() + { + SelectedMode = ModeName.DarkMode; + } +} + +public enum ModeName +{ + // No Description needed for one word enum members that + // are spelled the way you want to display them + + [Description("Light Mode")] // Can Use Description attribute + LightMode, + [Display(Name = "Dark Mode")] // Or Display attribute with Name property + DarkMode, + System +} \ No newline at end of file From 1ff4d6afd629971f19f67724dbea1628f0b8149c Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Wed, 11 Mar 2026 13:46:54 -0400 Subject: [PATCH 07/16] Refactor EnumDescriptionConverter UI elements with styling and spacing adjustments This commit updates the EnumDescriptionConverterPage.xaml to enhance the visual presentation of labels for Enum modes. It modifies the border elements, increases padding, applies a light gray background, and introduces a RoundRectangle stroke shape. Additionally, label text colors for 'Without Converter' and 'With Converter' sections are set to dark red for better contrast and readability. --- .../EnumDescriptionConverterPage.xaml | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml index e6ba1b455b..3d8bcb54b2 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml @@ -34,23 +34,35 @@ + Padding="15,10" + Background="LightGray" + StrokeShape="RoundRectangle 8"> - - + - From 121742e0143941fed49e1632311f7562684ad524 Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:23:28 -0700 Subject: [PATCH 08/16] Update EnumDescriptionGenerator.cs Co-authored-by: Pedro Jesus --- .../Generators/EnumDescriptionGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs index a8f71d52d3..bdff69add5 100644 --- a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs +++ b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs @@ -124,7 +124,7 @@ static string GenerateCode(INamedTypeSymbol enumSymbol) AttributeData? descAttr = member.GetAttributes() .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == descriptionAttributeName); - if (descAttr != null && descAttr.ConstructorArguments.Length > 0) + if (descAttr is not null && descAttr.ConstructorArguments.Length > 0) { if (descAttr.ConstructorArguments[0].Value is string description && !string.IsNullOrWhiteSpace(description)) { From f18eff0eb5d6eceaea82d394d7909059304b32ac Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Wed, 11 Mar 2026 18:43:08 -0400 Subject: [PATCH 09/16] Refactor EnumDescriptionConverter UI Elements for Improved Readability This commit enhances the user interface of EnumDescriptionConverter by adjusting styles and spacing for better presentation of enum modes. Modifications include increased border width, added padding, application of a light gray background, and introduction of a RoundRectangle stroke for border elements. The contrast of 'Without Converter' and 'With Converter' section labels is improved through coloring the text in dark red. --- .../Generators/EnumDescriptionGenerator.cs | 224 +++--------------- .../Helpers/EnumDescriptionGeneratorHelper.cs | 175 ++++++++++++++ 2 files changed, 203 insertions(+), 196 deletions(-) create mode 100644 src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs index a8f71d52d3..04080cb530 100644 --- a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs +++ b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Text; +using CommunityToolkit.Maui.SourceGenerators.Helpers; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -24,8 +26,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .CreateSyntaxProvider( predicate: static (node, _) => node is EnumDeclarationSyntax, transform: static (ctx, _) => ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol) - .Where(static enumSymbol => enumSymbol?.TypeKind == TypeKind.Enum) - .Where(static enumSymbol => enumSymbol is not null && IsAccessibleFromNamespace(enumSymbol)); + .Where(static enumSymbol => enumSymbol?.TypeKind == TypeKind.Enum) + .Where(static enumSymbol => enumSymbol is not null && EnumDescriptionGeneratorHelper.IsAccessibleFromNamespace(enumSymbol)); context.RegisterSourceOutput(enumDeclarations, static (spc, enumSymbol) => { @@ -45,12 +47,18 @@ static string GenerateCode(INamedTypeSymbol enumSymbol) string ns = enumSymbol.ContainingNamespace.ToDisplayString(); string enumName = enumSymbol.Name; string enumQualifiedName = enumSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - string initializerClassName = $"{enumName}DescriptionInitializer_{GetStableHash(enumQualifiedName)}"; + // Remove 'global::' prefix if present + string cleanedQualifiedName = enumQualifiedName.StartsWith("global::", StringComparison.Ordinal) + ? enumQualifiedName.Substring(8) + : enumQualifiedName; + // Replace invalid characters for identifiers + string baseClassName = cleanedQualifiedName.Replace(".", "_").Replace("+", "_"); + string initializerClassName = $"{baseClassName}_DescriptionInitializer"; var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("#nullable enable"); - sb.AppendLine("using CommunityToolkit.Maui.Converters;"); + sb.AppendLine("using CommunityToolkit.Maui.Converters;"); sb.AppendLine(); if (!string.IsNullOrEmpty(ns)) @@ -59,9 +67,9 @@ static string GenerateCode(INamedTypeSymbol enumSymbol) sb.AppendLine(); } - sb.AppendLine($"internal static class {initializerClassName}"); + sb.AppendLine($"internal static class {initializerClassName}"); sb.AppendLine("{"); - sb.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + sb.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); sb.AppendLine(" internal static void Initialize()"); sb.AppendLine(" {"); sb.AppendLine(" var dict = new global::System.Collections.Generic.Dictionary();"); @@ -74,40 +82,44 @@ static string GenerateCode(INamedTypeSymbol enumSymbol) continue; } - string? descriptionFromDescriptionAttribute = GetDescriptionFromDescriptionAttribute(member); + + + + + string? descriptionFromDescriptionAttribute = EnumDescriptionGeneratorHelper.GetDescriptionFromDescriptionAttribute(member, descriptionAttributeName); string fallbackDescription = descriptionFromDescriptionAttribute ?? member.Name; - if (TryGetDisplayInfo(member, out var displayName, out var resourceType) + if (EnumDescriptionGeneratorHelper.TryGetDisplayInfo(member, displayAttributeName, out var displayName, out var resourceType) && displayName is not null && !string.IsNullOrWhiteSpace(displayName)) { - if (resourceType is not null) + if (resourceType is not null) { - // displayName is checked for null above - if (TryGetLocalizedDisplayResolverExpression(resourceType, displayName!, enumSymbol.ContainingAssembly, out var localizedExpression)) + // displayName is checked for null above + if (EnumDescriptionGeneratorHelper.TryGetLocalizedDisplayResolverExpression(resourceType, displayName!, enumSymbol.ContainingAssembly, out var localizedExpression)) { sb.AppendLine($" resolvers[\"{member.Name}\"] = static culture =>"); sb.AppendLine(" {"); sb.AppendLine($" var text = {localizedExpression};"); - sb.AppendLine($" return global::System.String.IsNullOrWhiteSpace(text) ? \"{EscapeString(fallbackDescription)}\" : text;"); + sb.AppendLine($" return global::System.String.IsNullOrWhiteSpace(text) ? \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\" : text;"); sb.AppendLine(" };"); continue; } // Cannot safely resolve localized resource access at compile-time - sb.AppendLine($" dict[\"{member.Name}\"] = \"{EscapeString(fallbackDescription)}\";"); + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\";"); continue; } - sb.AppendLine($" dict[\"{member.Name}\"] = \"{EscapeString(displayName!)}\";"); + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(displayName!)}\";"); continue; } - sb.AppendLine($" dict[\"{member.Name}\"] = \"{EscapeString(fallbackDescription)}\";"); + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\";"); } sb.AppendLine(); - sb.AppendLine($" EnumDescriptionRegistry.Register(typeof({enumQualifiedName}), dict);"); + sb.AppendLine($" EnumDescriptionRegistry.Register(typeof({enumQualifiedName}), dict);"); sb.AppendLine(" if (resolvers.Count > 0)"); sb.AppendLine(" {"); sb.AppendLine($" EnumDescriptionRegistry.Register(typeof({enumQualifiedName}), resolvers);"); @@ -118,184 +130,4 @@ static string GenerateCode(INamedTypeSymbol enumSymbol) return sb.ToString(); } - static string? GetDescriptionFromDescriptionAttribute(IFieldSymbol member) - { - // Check for DescriptionAttribute - AttributeData? descAttr = member.GetAttributes() - .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == descriptionAttributeName); - - if (descAttr != null && descAttr.ConstructorArguments.Length > 0) - { - if (descAttr.ConstructorArguments[0].Value is string description && !string.IsNullOrWhiteSpace(description)) - { - return description; - } - } - - return null; - } - - static bool TryGetDisplayInfo(IFieldSymbol member, out string? displayName, out INamedTypeSymbol? resourceType) - { - displayName = null; - resourceType = null; - - AttributeData? displayAttr = member.GetAttributes() - .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == displayAttributeName); - - if (displayAttr is null) - { - return false; - } - - foreach (KeyValuePair namedArg in displayAttr.NamedArguments) - { - if (namedArg.Key == "Name" && !namedArg.Value.IsNull && namedArg.Value.Value is string name) - { - displayName = name; - } - else if (namedArg.Key == "ResourceType" && !namedArg.Value.IsNull && namedArg.Value.Value is INamedTypeSymbol namedType) - { - resourceType = namedType; - } - else if (namedArg.Key == "ResourceType" && !namedArg.Value.IsNull && namedArg.Value.Value is ITypeSymbol typeSymbol && typeSymbol is INamedTypeSymbol resourceNamedType) - { - resourceType = resourceNamedType; - } - } - - return true; - } - - static bool TryGetLocalizedDisplayResolverExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) - { - expression = string.Empty; - - if (TryGetResourceManagerExpression(resourceType, resourceKey, targetAssembly, out var resourceManagerExpression)) - { - expression = resourceManagerExpression; - return true; - } - - if (SyntaxFacts.IsValidIdentifier(resourceKey) - && TryGetResourceMemberExpression(resourceType, resourceKey, targetAssembly, out var resourceMemberExpression)) - { - expression = resourceMemberExpression; - return true; - } - - return false; - } - - static bool TryGetResourceManagerExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) - { - expression = string.Empty; - - IPropertySymbol? resourceManagerProperty = resourceType - .GetMembers("ResourceManager") - .OfType() - .FirstOrDefault(static p => p.IsStatic && p.GetMethod is not null); - - if (resourceManagerProperty is null || !IsAccessibleToAssembly(resourceManagerProperty, targetAssembly)) - { - return false; - } - - if (resourceManagerProperty.Type.ToDisplayString() != "System.Resources.ResourceManager") - { - return false; - } - - var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - expression = $"{resourceTypeName}.ResourceManager.GetString(\"{EscapeString(resourceKey)}\", culture ?? global::System.Globalization.CultureInfo.CurrentUICulture)"; - return true; - } - - static bool TryGetResourceMemberExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) - { - expression = string.Empty; - - IPropertySymbol? property = resourceType.GetMembers(resourceKey).OfType() - .FirstOrDefault(static p => p.IsStatic && p.GetMethod is not null); - - if (property is not null) - { - if (property.Type.SpecialType == SpecialType.System_String && IsAccessibleToAssembly(property, targetAssembly)) - { - var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - expression = $"{resourceTypeName}.{resourceKey}"; - return true; - } - - return false; - } - - IFieldSymbol? field = resourceType.GetMembers(resourceKey).OfType() - .FirstOrDefault(static f => f.IsStatic); - - if (field is not null && field.Type.SpecialType == SpecialType.System_String && IsAccessibleToAssembly(field, targetAssembly)) - { - var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - expression = $"{resourceTypeName}.{resourceKey}"; - return true; - } - - return false; - } - - static bool IsAccessibleToAssembly(ISymbol symbol, IAssemblySymbol targetAssembly) - => symbol.DeclaredAccessibility switch - { - Accessibility.Public => true, - Accessibility.Internal or Accessibility.ProtectedOrInternal => SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, targetAssembly), - _ => false - }; - - static string EscapeString(string input) - { - return input.Replace("\\", "\\\\") - .Replace("\"", "\\\"") - .Replace("\n", "\\n") - .Replace("\r", "\\r") - .Replace("\t", "\\t"); - } - - static string GetStableHash(string value) - { - unchecked - { - uint hash = 2166136261; - foreach (char c in value) - { - hash ^= c; - hash *= 16777619; - } - - return hash.ToString("x8", System.Globalization.CultureInfo.InvariantCulture); - } - } - - static bool IsAccessibleFromNamespace(INamedTypeSymbol enumSymbol) - { - if (!IsTypeAccessible(enumSymbol)) - { - return false; - } - - INamedTypeSymbol? containingType = enumSymbol.ContainingType; - while (containingType is not null) - { - if (!IsTypeAccessible(containingType)) - { - return false; - } - - containingType = containingType.ContainingType; - } - - return true; - } - - static bool IsTypeAccessible(INamedTypeSymbol typeSymbol) - => typeSymbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs b/src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs new file mode 100644 index 0000000000..3b6bfbe94e --- /dev/null +++ b/src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs @@ -0,0 +1,175 @@ +using Microsoft.CodeAnalysis; + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace CommunityToolkit.Maui.SourceGenerators.Helpers; + +static class EnumDescriptionGeneratorHelper +{ + public static string? GetDescriptionFromDescriptionAttribute(IFieldSymbol member, string descriptionAttributeName) + { + AttributeData? descAttr = member.GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == descriptionAttributeName); + + if (descAttr != null && descAttr.ConstructorArguments.Length > 0) + { + if (descAttr.ConstructorArguments[0].Value is string description && !string.IsNullOrWhiteSpace(description)) + { + return description; + } + } + + return null; + } + + public static bool TryGetDisplayInfo(IFieldSymbol member, string displayAttributeName, out string? displayName, out INamedTypeSymbol? resourceType) + { + displayName = null; + resourceType = null; + + AttributeData? displayAttr = member.GetAttributes() + .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == displayAttributeName); + + if (displayAttr is null) + { + return false; + } + + foreach (KeyValuePair namedArg in displayAttr.NamedArguments) + { + if (namedArg.Key == "Name" && !namedArg.Value.IsNull && namedArg.Value.Value is string name) + { + displayName = name; + } + else if (namedArg.Key == "ResourceType" && !namedArg.Value.IsNull && namedArg.Value.Value is INamedTypeSymbol namedType) + { + resourceType = namedType; + } + else if (namedArg.Key == "ResourceType" && !namedArg.Value.IsNull && namedArg.Value.Value is ITypeSymbol typeSymbol && typeSymbol is INamedTypeSymbol resourceNamedType) + { + resourceType = resourceNamedType; + } + } + + return true; + } + + public static bool TryGetLocalizedDisplayResolverExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) + { + expression = string.Empty; + + if (TryGetResourceManagerExpression(resourceType, resourceKey, targetAssembly, out var resourceManagerExpression)) + { + expression = resourceManagerExpression; + return true; + } + + if (Microsoft.CodeAnalysis.CSharp.SyntaxFacts.IsValidIdentifier(resourceKey) + && TryGetResourceMemberExpression(resourceType, resourceKey, targetAssembly, out var resourceMemberExpression)) + { + expression = resourceMemberExpression; + return true; + } + + return false; + } + + static bool TryGetResourceManagerExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) + { + expression = string.Empty; + + IPropertySymbol? resourceManagerProperty = resourceType + .GetMembers("ResourceManager") + .OfType() + .FirstOrDefault(static p => p.IsStatic && p.GetMethod is not null); + + if (resourceManagerProperty is null || !IsAccessibleToAssembly(resourceManagerProperty, targetAssembly)) + { + return false; + } + + if (resourceManagerProperty.Type.ToDisplayString() != "System.Resources.ResourceManager") + { + return false; + } + + var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + expression = $"{resourceTypeName}.ResourceManager.GetString(\"{EscapeString(resourceKey)}\", culture ?? global::System.Globalization.CultureInfo.CurrentUICulture)"; + return true; + } + + public static bool TryGetResourceMemberExpression(INamedTypeSymbol resourceType, string resourceKey, IAssemblySymbol targetAssembly, out string expression) + { + expression = string.Empty; + + IPropertySymbol? property = resourceType.GetMembers(resourceKey).OfType() + .FirstOrDefault(static p => p.IsStatic && p.GetMethod is not null); + + if (property is not null) + { + if (property.Type.SpecialType == SpecialType.System_String && IsAccessibleToAssembly(property, targetAssembly)) + { + var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + expression = $"{resourceTypeName}.{resourceKey}"; + return true; + } + + return false; + } + + IFieldSymbol? field = resourceType.GetMembers(resourceKey).OfType() + .FirstOrDefault(static f => f.IsStatic); + + if (field is not null && field.Type.SpecialType == SpecialType.System_String && IsAccessibleToAssembly(field, targetAssembly)) + { + var resourceTypeName = resourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + expression = $"{resourceTypeName}.{resourceKey}"; + return true; + } + + return false; + } + + public static bool IsAccessibleToAssembly(ISymbol symbol, IAssemblySymbol targetAssembly) + => symbol.DeclaredAccessibility switch + { + Accessibility.Public => true, + Accessibility.Internal or Accessibility.ProtectedOrInternal => SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, targetAssembly), + _ => false + }; + + public static string EscapeString(string input) + { + return input.Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + } + + public static bool IsAccessibleFromNamespace(INamedTypeSymbol enumSymbol) + { + if (!IsTypeAccessible(enumSymbol)) + { + return false; + } + + INamedTypeSymbol? containingType = enumSymbol.ContainingType; + while (containingType is not null) + { + if (!IsTypeAccessible(containingType)) + { + return false; + } + + containingType = containingType.ContainingType; + } + + return true; + } + + public static bool IsTypeAccessible(INamedTypeSymbol typeSymbol) + => typeSymbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; +} From 295496b446371b87301f7d8b396d37f01c862be6 Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Wed, 11 Mar 2026 19:00:43 -0400 Subject: [PATCH 10/16] Refactor EnumDescriptionConverter UI elements for improved readability This commit enhances EnumDescriptionConverter`s user interface by adjusting styles and spacing for better presentation of enum modes. Modifications include increased border width, added padding, application of a light gray background, and introduction of a RoundRectangle stroke for border elements. The contrast of 'Without Converter' and 'With Converter' section labels is improved through coloring the text in dark red. --- .../Generators/EnumDescriptionGenerator.cs | 155 ++++++++++-------- .../Models/EnumDescriptionRecords.cs | 17 ++ 2 files changed, 101 insertions(+), 71 deletions(-) create mode 100644 src/CommunityToolkit.Maui.SourceGenerators/Models/EnumDescriptionRecords.cs diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs index 04080cb530..5ee71b12f0 100644 --- a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs +++ b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs @@ -5,6 +5,7 @@ using System.Text; using CommunityToolkit.Maui.SourceGenerators.Helpers; +using CommunityToolkit.Maui.SourceGenerators.Models; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -20,40 +21,78 @@ public class EnumDescriptionGenerator : IIncrementalGenerator static readonly string displayAttributeName = "System.ComponentModel.DataAnnotations.DisplayAttribute"; static readonly string descriptionAttributeName = "System.ComponentModel.DescriptionAttribute"; + static EnumDescriptionModel? CreateEnumDescriptionModel(INamedTypeSymbol? enumSymbol) + { + if (enumSymbol is null || enumSymbol.TypeKind != TypeKind.Enum || !EnumDescriptionGeneratorHelper.IsAccessibleFromNamespace(enumSymbol)) + { + return null; + } + + string ns = enumSymbol.ContainingNamespace.ToDisplayString(); + string enumName = enumSymbol.Name; + string enumQualifiedName = enumSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var members = new List(); + foreach (IFieldSymbol member in enumSymbol.GetMembers().OfType()) + { + if (member.ConstantValue is null) + { + continue; + } + + string? description = EnumDescriptionGeneratorHelper.GetDescriptionFromDescriptionAttribute(member, descriptionAttributeName); + string? displayName = null; + string? resourceType = null; + if (EnumDescriptionGeneratorHelper.TryGetDisplayInfo(member, displayAttributeName, out var dn, out var rt)) + { + displayName = dn; + resourceType = rt?.ToDisplayString(); + } + + members.Add(new EnumMemberModel( + member.Name, + description, + displayName, + resourceType + )); + } + + return new EnumDescriptionModel(enumName, ns, enumQualifiedName, members); + } + public void Initialize(IncrementalGeneratorInitializationContext context) { - IncrementalValuesProvider enumDeclarations = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => node is EnumDeclarationSyntax, - transform: static (ctx, _) => ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol) - .Where(static enumSymbol => enumSymbol?.TypeKind == TypeKind.Enum) - .Where(static enumSymbol => enumSymbol is not null && EnumDescriptionGeneratorHelper.IsAccessibleFromNamespace(enumSymbol)); - - context.RegisterSourceOutput(enumDeclarations, static (spc, enumSymbol) => - { - if (enumSymbol is null) - { - return; - } - - string code = GenerateCode(enumSymbol); - string hintName = $"{enumSymbol.ToDisplayString().Replace(".", "_")}.g.cs"; - spc.AddSource(hintName, SourceText.From(code, Encoding.UTF8)); - }); + IncrementalValuesProvider enumModels = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is EnumDeclarationSyntax, + transform: static (ctx, _) => EnumDescriptionGenerator.CreateEnumDescriptionModel(ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol)) + .Where(static model => model is not null); + + context.RegisterSourceOutput(enumModels, (spc, model) => + { + if (model is null) + { + return; + } + + string code = GenerateCode(model); + string hintName = $"{model.QualifiedName.Replace(".", "_")}.g.cs"; + spc.AddSource(hintName, SourceText.From(code, Encoding.UTF8)); + }); } - static string GenerateCode(INamedTypeSymbol enumSymbol) + static string GenerateCode(EnumDescriptionModel model) { - string ns = enumSymbol.ContainingNamespace.ToDisplayString(); - string enumName = enumSymbol.Name; - string enumQualifiedName = enumSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - // Remove 'global::' prefix if present - string cleanedQualifiedName = enumQualifiedName.StartsWith("global::", StringComparison.Ordinal) - ? enumQualifiedName.Substring(8) - : enumQualifiedName; - // Replace invalid characters for identifiers - string baseClassName = cleanedQualifiedName.Replace(".", "_").Replace("+", "_"); - string initializerClassName = $"{baseClassName}_DescriptionInitializer"; + string ns = model.Namespace; + string enumName = model.EnumName; + string enumQualifiedName = model.QualifiedName; + // Remove 'global::' prefix if present + string cleanedQualifiedName = enumQualifiedName.StartsWith("global::", StringComparison.Ordinal) + ? enumQualifiedName.Substring(8) + : enumQualifiedName; + // Replace invalid characters for identifiers + string baseClassName = cleanedQualifiedName.Replace(".", "_").Replace("+", "_"); + string initializerClassName = $"{baseClassName}_DescriptionInitializer"; var sb = new StringBuilder(); sb.AppendLine("// "); @@ -75,48 +114,22 @@ static string GenerateCode(INamedTypeSymbol enumSymbol) sb.AppendLine(" var dict = new global::System.Collections.Generic.Dictionary();"); sb.AppendLine(" var resolvers = new global::System.Collections.Generic.Dictionary>();"); - foreach (IFieldSymbol member in enumSymbol.GetMembers().OfType()) - { - if (member.ConstantValue is null) - { - continue; - } - - - - - - string? descriptionFromDescriptionAttribute = EnumDescriptionGeneratorHelper.GetDescriptionFromDescriptionAttribute(member, descriptionAttributeName); - string fallbackDescription = descriptionFromDescriptionAttribute ?? member.Name; - - if (EnumDescriptionGeneratorHelper.TryGetDisplayInfo(member, displayAttributeName, out var displayName, out var resourceType) - && displayName is not null - && !string.IsNullOrWhiteSpace(displayName)) - { - if (resourceType is not null) - { - // displayName is checked for null above - if (EnumDescriptionGeneratorHelper.TryGetLocalizedDisplayResolverExpression(resourceType, displayName!, enumSymbol.ContainingAssembly, out var localizedExpression)) - { - sb.AppendLine($" resolvers[\"{member.Name}\"] = static culture =>"); - sb.AppendLine(" {"); - sb.AppendLine($" var text = {localizedExpression};"); - sb.AppendLine($" return global::System.String.IsNullOrWhiteSpace(text) ? \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\" : text;"); - sb.AppendLine(" };"); - continue; - } - - // Cannot safely resolve localized resource access at compile-time - sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\";"); - continue; - } - - sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(displayName!)}\";"); - continue; - } - - sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\";"); - } + foreach (var member in model.Members) + { + string fallbackDescription = member.Description ?? member.Name; + if (member.DisplayName is not null && !string.IsNullOrWhiteSpace(member.DisplayName)) + { + if (member.ResourceType is not null) + { + // Cannot safely resolve localized resource access at compile-time + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\";"); + continue; + } + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(member.DisplayName)}\";"); + continue; + } + sb.AppendLine($" dict[\"{member.Name}\"] = \"{EnumDescriptionGeneratorHelper.EscapeString(fallbackDescription)}\";"); + } sb.AppendLine(); sb.AppendLine($" EnumDescriptionRegistry.Register(typeof({enumQualifiedName}), dict);"); diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Models/EnumDescriptionRecords.cs b/src/CommunityToolkit.Maui.SourceGenerators/Models/EnumDescriptionRecords.cs new file mode 100644 index 0000000000..4a2f85729d --- /dev/null +++ b/src/CommunityToolkit.Maui.SourceGenerators/Models/EnumDescriptionRecords.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace CommunityToolkit.Maui.SourceGenerators.Models; + +public record EnumDescriptionModel( + string EnumName, + string Namespace, + string QualifiedName, + IReadOnlyList Members +); + +public record EnumMemberModel( + string Name, + string? Description, + string? DisplayName, + string? ResourceType +); From 28304c1ae3a5cdfa2d5cf94a2343db064e90fd23 Mon Sep 17 00:00:00 2001 From: Billy Martin Date: Wed, 11 Mar 2026 19:24:10 -0400 Subject: [PATCH 11/16] Refactor EnumDescriptionGeneratorHelper to remove reflection The code has been altered to avoid reflection when checking for the presence of an Enum description attribute. Previously, the check used reflection with `getAttribute` to ensure the attribute's class matched `descriptionAttributeName`, which has been replaced with a direct null and length check on `descAttr`. --- .../Helpers/EnumDescriptionGeneratorHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs b/src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs index 3b6bfbe94e..18de628043 100644 --- a/src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs +++ b/src/CommunityToolkit.Maui.SourceGenerators/Helpers/EnumDescriptionGeneratorHelper.cs @@ -13,7 +13,7 @@ static class EnumDescriptionGeneratorHelper AttributeData? descAttr = member.GetAttributes() .FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == descriptionAttributeName); - if (descAttr != null && descAttr.ConstructorArguments.Length > 0) + if (descAttr is not null && descAttr.ConstructorArguments.Length > 0) { if (descAttr.ConstructorArguments[0].Value is string description && !string.IsNullOrWhiteSpace(description)) { From 012182e678043094104a22023ae56a313d98afa1 Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:19:22 -0700 Subject: [PATCH 12/16] Update samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Converters/EnumDescriptionConverterViewModel.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs index 711d958964..16703c31b4 100644 --- a/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using System.Text; using CommunityToolkit.Mvvm.ComponentModel; From 0c6f83f1d93f54301bf8037ef7c8af44d14156a6 Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:19:37 -0700 Subject: [PATCH 13/16] Update samples/CommunityToolkit.Maui.Sample/MauiProgram.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- samples/CommunityToolkit.Maui.Sample/MauiProgram.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 71e4772628..d36fb6c190 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -1,7 +1,6 @@ using System.Diagnostics.CodeAnalysis; using CommunityToolkit.Maui.ApplicationModel; -using CommunityToolkit.Maui.Converters; using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Markup; using CommunityToolkit.Maui.Media; From 5413fef7be19c177142628b9485049114a05e022 Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:19:48 -0700 Subject: [PATCH 14/16] Update samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Pages/Converters/EnumDescriptionConverterPage.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml index 3d8bcb54b2..959da71dce 100644 --- a/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml @@ -27,7 +27,7 @@ - - + false true From 3a012cd0b3c310c8042c4325e470506a989fbfac Mon Sep 17 00:00:00 2001 From: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:20:33 -0700 Subject: [PATCH 16/16] Update src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Converters/EnumDescriptionConverter.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs index f5d426011e..4d2cb0f330 100644 --- a/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs +++ b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs @@ -1,8 +1,5 @@ using System.Collections.Concurrent; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; using System.Globalization; -using System.Reflection; namespace CommunityToolkit.Maui.Converters;