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/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj index 5302f4a884..03a9f92b62 100644 --- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj +++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj @@ -32,8 +32,7 @@ 10.0.19041.56 - + false true @@ -87,6 +86,18 @@ + + + EnumDescriptionConverterPage.xaml + + + + + + MSBuild:Compile + + + win-x64 diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 78f8c2bcf8..d36fb6c190 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; + using CommunityToolkit.Maui.ApplicationModel; using CommunityToolkit.Maui.Core; using CommunityToolkit.Maui.Markup; @@ -27,9 +28,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 +196,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 new file mode 100644 index 0000000000..959da71dce --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + 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..38e7adad40 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Converters/EnumDescriptionConverterPage.xaml.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Maui.Sample.ViewModels.Converters; + +namespace CommunityToolkit.Maui.Sample.Pages.Converters; + +public partial class EnumDescriptionConverterPage : BasePage +{ + public EnumDescriptionConverterPage(EnumDescriptionConverterViewModel enumDescriptionConverterViewModel) + : base(enumDescriptionConverterViewModel) + { + InitializeComponent(); + } +} 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..16703c31b4 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Converters/EnumDescriptionConverterViewModel.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +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 diff --git a/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs new file mode 100644 index 0000000000..f7dda3bacc --- /dev/null +++ b/src/CommunityToolkit.Maui.SourceGenerators/Generators/EnumDescriptionGenerator.cs @@ -0,0 +1,146 @@ +// CommunityToolkit.Maui.Analyzers/EnumDescriptionGenerator.cs +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using CommunityToolkit.Maui.SourceGenerators.Helpers; +using CommunityToolkit.Maui.SourceGenerators.Models; + +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"; + + 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 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(EnumDescriptionModel model) + { + 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("// "); + 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 (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);"); + sb.AppendLine(" if (resolvers.Count > 0)"); + sb.AppendLine(" {"); + sb.AppendLine($" EnumDescriptionRegistry.Register(typeof({enumQualifiedName}), resolvers);"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + +} \ 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..18de628043 --- /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 is not 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; +} 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 +); diff --git a/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs new file mode 100644 index 0000000000..960ca3a3e1 --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Converters/EnumDescriptionConverterTests.cs @@ -0,0 +1,191 @@ +using Xunit; + +using CommunityToolkit.Maui.Converters; + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace CommunityToolkit.Maui.UnitTests.Converters; + +public class EnumDescriptionConverterTests : BaseOneWayConverterTest +{ + public enum TestEnum + { + [Display(Name = "Display Name")] + WithDisplay, + [Description("Description Text")] + WithDescription, + NoAttribute + } + + public 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 = (ICommunityToolkitValueConverter)new EnumDescriptionConverter(); + Assert.Throws(() => converter.Convert(null, typeof(string), null, 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); + } + + #region DisplayAttribute Localization & Edge Cases + + public class TestResources + { + public static string LocalizedDisplay => "Localized Display"; + public static int NonStringResource => 42; + } + + public 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 + + public 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 new file mode 100644 index 0000000000..4d2cb0f330 --- /dev/null +++ b/src/CommunityToolkit.Maui/Converters/EnumDescriptionConverter.cs @@ -0,0 +1,100 @@ +using System.Collections.Concurrent; +using System.Globalization; + +namespace CommunityToolkit.Maui.Converters; + +/// +/// 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; + + /// + public override string ConvertFrom(Enum value, CultureInfo? culture = null) + { + ArgumentNullException.ThrowIfNull(value); + + // 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)) + { + return resolver(culture); + } + + if (descriptionCache.TryGetValue(type, out var enumDict) && + enumDict.TryGetValue(valueName, out var description)) + { + return description; + } + + return null; + } +} \ No newline at end of file