diff --git a/src/Uno.Extensions.Core.Generators/AnalyzerReleases.Unshipped.md b/src/Uno.Extensions.Core.Generators/AnalyzerReleases.Unshipped.md index 1a98c51e2e..dbf32d75d1 100644 --- a/src/Uno.Extensions.Core.Generators/AnalyzerReleases.Unshipped.md +++ b/src/Uno.Extensions.Core.Generators/AnalyzerReleases.Unshipped.md @@ -10,6 +10,7 @@ KE0002 | Usage | Error | A record that implements GetKeyHashCode should also i KE0003 | Usage | Error | A record that implements KeyEquals should also implement GetKeyHashCode. KE0004 | Usage | Warning | A record flagged with [ImplicitKeyEquality] attribute must have an eligible key property. KE0005 | Usage | Warning | A record should have only one matching key property for implicit IKeyEquatable generation. +KE0006 | Usage | Warning | A record that implements IKeyEquatable should also implement IKeyed. PS0001 | Usage | Error | A property selector can only use property members. PS0002 | Usage | Error | A property selector cannot have any closure. PS0003 | Usage | Error | A property selector must be a lambda. diff --git a/src/Uno.Extensions.Core.Generators/Common/Extensions/RoslynExtensions.cs b/src/Uno.Extensions.Core.Generators/Common/Extensions/RoslynExtensions.cs index 0e11c67607..4d1d1d45f1 100644 --- a/src/Uno.Extensions.Core.Generators/Common/Extensions/RoslynExtensions.cs +++ b/src/Uno.Extensions.Core.Generators/Common/Extensions/RoslynExtensions.cs @@ -15,6 +15,9 @@ namespace Uno.Extensions.Generators; internal static class RoslynExtensions { + public static string ToFullString(this ISymbol symbol) + => symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + public static IEnumerable GetNamespaceTypes(this INamespaceSymbol sym) { foreach (var child in sym.GetTypeMembers()) @@ -152,6 +155,18 @@ bool IsExpectedType(INamedTypeSymbol named) || SymbolEqualityComparer.Default.Equals(named.ConstructedFrom, typeOrInterface); } + public static IPropertySymbol? FindImplementationOf(this INamedTypeSymbol type, IPropertySymbol boundedInterfaceProperty, SymbolEqualityComparer? comparer = null) + => type.GetAllProperties().FirstOrDefault(p => p.IsImplementationOf(boundedInterfaceProperty, comparer)); + + public static IPropertySymbol? FindLocalImplementationOf(this INamedTypeSymbol type, IPropertySymbol boundedInterfaceProperty, SymbolEqualityComparer? comparer = null) + => type.GetProperties().FirstOrDefault(p => p.IsImplementationOf(boundedInterfaceProperty, comparer)); + + public static bool IsImplementationOf(this IPropertySymbol property, IPropertySymbol boundedInterfaceProperty, SymbolEqualityComparer? comparer = null) + => property is { OverriddenProperty: not null } + ? SymbolEqualityComparer.IncludeNullability.Equals(property.OverriddenProperty, boundedInterfaceProperty) + : property.Name.Equals(boundedInterfaceProperty.Name, StringComparison.Ordinal) + && (comparer ?? SymbolEqualityComparer.IncludeNullability).Equals(property.Type, boundedInterfaceProperty.Type); + public static IMethodSymbol? FindLocalImplementationOf(this INamedTypeSymbol type, IMethodSymbol boundedInterfaceMethod, SymbolEqualityComparer? comparer = null) => type.GetMethods().FirstOrDefault(m => m.IsImplementationOf(boundedInterfaceMethod, comparer)); @@ -396,6 +411,9 @@ private static object ToTypedArray(Type type, IEnumerable values) => type.GetMembers().OfType().FirstOrDefault(method => method.Name.Equals(methodName, comparison)) ?? (allowBaseTypes && type.BaseType is { } @base? @base.FindMethod(methodName, allowBaseTypes, comparison) : null); + public static IPropertySymbol GetProperty(this INamedTypeSymbol type, string methodName, bool allowBaseTypes = true, StringComparison comparison = StringComparison.Ordinal) + => type.FindProperty(methodName, allowBaseTypes, comparison) ?? throw new InvalidOperationException($"Property {methodName} not found on {type.Name}."); + public static IMethodSymbol GetMethod(this INamedTypeSymbol type, string methodName, bool allowBaseTypes = true, StringComparison comparison = StringComparison.Ordinal) => type.FindMethod(methodName, allowBaseTypes, comparison) ?? throw new InvalidOperationException($"Method {methodName} not found on {type.Name}."); } diff --git a/src/Uno.Extensions.Core.Generators/Common/GenContext/GenerationContext.cs b/src/Uno.Extensions.Core.Generators/Common/GenContext/GenerationContext.cs index fe27d54efe..4ad25756ef 100644 --- a/src/Uno.Extensions.Core.Generators/Common/GenContext/GenerationContext.cs +++ b/src/Uno.Extensions.Core.Generators/Common/GenContext/GenerationContext.cs @@ -33,10 +33,12 @@ public static bool IsDisabled(this GeneratorExecutionContext context, string dis isOptional: x.attribute.IsOptional || x.parameter.GetCustomAttributesData().Any(attr => attr.AttributeType.FullName.Equals("System.Runtime.CompilerServices.NullableAttribute")), symbol: compilation .GetTypesByMetadataName(x.attribute.Type) - .OrderBy(t => - SymbolEqualityComparer.Default.Equals(t.ContainingAssembly, compilation.Assembly) ? 0 - : t.IsAccessibleTo(compilation.Assembly) ? 1 - : 2) + .OrderBy(t => t switch + { + _ when SymbolEqualityComparer.Default.Equals(t.ContainingAssembly, compilation.Assembly) => 0, + _ when t.IsAccessibleTo(compilation.Assembly) => 1, + _ => 2 + }) .FirstOrDefault() )) .ToList(); diff --git a/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationContext.cs b/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationContext.cs index ea9c855a07..44c7a715e4 100644 --- a/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationContext.cs +++ b/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationContext.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using System.Threading; using Microsoft.CodeAnalysis; using Uno.Extensions.Equality; @@ -10,9 +13,10 @@ internal record KeyEqualityGenerationContext( // Types [ContextType($"{NS.Equality}.IKeyEquatable`1")] INamedTypeSymbol IKeyEquatable, + [ContextType($"{NS.Equality}.IKeyed`1")] INamedTypeSymbol IKeyed, // Config - [ContextType(typeof(ImplicitKeyEqualityAttribute))] INamedTypeSymbol ImplicitKeyAttribute, + [ContextType(typeof(ImplicitKeysAttribute))] INamedTypeSymbol ImplicitKeyAttribute, [ContextType(typeof(KeyAttribute))] INamedTypeSymbol KeyAttribute, [ContextType("System.ComponentModel.DataAnnotations.KeyAttribute?")] INamedTypeSymbol? DataAnnotationsKeyAttribute ) @@ -22,4 +26,29 @@ internal record KeyEqualityGenerationContext( private IMethodSymbol? _keyEquals; public IMethodSymbol KeyEquals => _keyEquals ??= IKeyEquatable.GetMethod(nameof(KeyEquals)); + + public ITypeSymbol ConstructTupleOrSingle(params ITypeSymbol[] typeArguments) + => typeArguments switch + { + null or { Length: 0 } => throw new InvalidOperationException("No types to create tuple."), + { Length: 1 } => typeArguments[0], + _ when Context.Compilation.GetTypeByMetadataName($"System.ValueTuple`{typeArguments.Length}") is { } tuple => tuple.Construct(typeArguments), + _ => throw new InvalidOperationException("Failed to construct tuple."), + }; + + public ImmutableArray DeconstructTupleOrSingle(ITypeSymbol? maybeTuple) + => maybeTuple switch + { + null => ImmutableArray.Empty, + {IsTupleType: true} => ((INamedTypeSymbol)maybeTuple).TypeArguments, + _ => ImmutableArray.Create(maybeTuple) + }; + + public INamedTypeSymbol? GetLocalIKeyed(INamedTypeSymbol? baseIKeyed, ICollection additionalLocalKeysTypes) + => (baseIKeyed, additionalLocalKeysTypes) switch + { + (_, null or { Count: 0 }) => null, // If no local keys, then no local implementation of IKeyed + (null, _) => IKeyed.Construct(ConstructTupleOrSingle(additionalLocalKeysTypes.Select(k => k.Type).ToArray())), + _ => IKeyed.Construct(ConstructTupleOrSingle(DeconstructTupleOrSingle(baseIKeyed.TypeArguments[0]).Concat(additionalLocalKeysTypes.Select(k => k.Type)).ToArray())) + }; } diff --git a/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationTool.cs b/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationTool.cs index 0b92e6d7ff..d77548508f 100644 --- a/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationTool.cs +++ b/src/Uno.Extensions.Core.Generators/KeyEquality/KeyEqualityGenerationTool.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.CodeAnalysis; using Uno.Extensions.Equality; @@ -51,7 +52,7 @@ public KeyEqualityGenerationTool(KeyEqualityGenerationContext context) private void LoadConfigs() { - var assemblyImplicit = _assembly.FindAttribute() ?? new ImplicitKeyEqualityAttribute(); + var assemblyImplicit = _assembly.FindAttribute() ?? new ImplicitKeysAttribute(); var assemblyTypes = from module in _assembly.Modules from type in module.GetNamespaceTypes() select type; foreach (var type in assemblyTypes) { @@ -59,7 +60,7 @@ private void LoadConfigs() } } - private Config? GetOrCreateConfig(INamedTypeSymbol? type, ImplicitKeyEqualityAttribute assemblyImplicit) + private Config? GetOrCreateConfig(INamedTypeSymbol? type, ImplicitKeysAttribute assemblyImplicit) { if (type is null) { @@ -74,20 +75,32 @@ private void LoadConfigs() if (!type.IsRecord) { // If the type is a class that implement IKeyEquatable, we still add it to the configs (with code gen disabled) for the registry generation. - return _configs[type] = type.IsOrImplements(_ctx.IKeyEquatable, allowBaseTypes: true, out var implementation) - ? new Config(type, implementation, null, new (0), NeedsCodeGen: false, HasGetKeyHashCode: true, HasKeyEquals: true) - : null; + var nonRecordIsIKeyEquatable = type.IsOrImplements(_ctx.IKeyEquatable, allowBaseTypes: true, out var nonRecordIKeyEquatable); + var nonRecordIsIKeyed = type.IsOrImplements(_ctx.IKeyed, allowBaseTypes: true, out var nonRecordIKeyed); + + if (nonRecordIsIKeyEquatable || nonRecordIsIKeyed) + { + return _configs[type] = new Config(type, nonRecordIKeyEquatable, null, nonRecordIKeyed, null, new(0), NeedsCodeGen: false, IsCustomImplementation: true); + } + else + { + return null; + } } var iKeyEquatable = _ctx.IKeyEquatable.Construct(type); var iKeyEquatable_GetKeyHashCode = iKeyEquatable.GetMethod(GetKeyHashCode); var iKeyEquatable_KeyEquals = iKeyEquatable.GetMethod(KeyEquals); - var baseIKeyEquatable = type.BaseType is { } baseType ? GetBaseIKeyEquatable(baseType) : null; + var (baseIKeyEquatable, baseIKeyed) = type.BaseType is { } baseType ? GetBaseImplementations(baseType) : default; var hasIKeyEquatableDeclared = type.IsOrImplements(iKeyEquatable, allowBaseTypes: false, out _); + var hasIKeyedDeclared = type.IsOrImplements(_ctx.IKeyed, allowBaseTypes: false, out var declaredIKeyed); var getKeyHashCode = type.FindLocalImplementationOf(iKeyEquatable_GetKeyHashCode, SymbolEqualityComparer.Default); var keyEquals = type.FindLocalImplementationOf(iKeyEquatable_KeyEquals, SymbolEqualityComparer.Default); - var keys = SearchKeys(type, assemblyImplicit); + var isCustomImplementation = getKeyHashCode is not null || keyEquals is not null; + + var keys = isCustomImplementation ? new (0) : SearchKeys(type, assemblyImplicit); + var iKeyed = isCustomImplementation ? declaredIKeyed : _ctx.GetLocalIKeyed(baseIKeyed, keys); var isIKeyEquatable = keys is { Count: > 0 } || hasIKeyEquatableDeclared || getKeyHashCode is not null || keyEquals is not null; var needsCodeGen = isIKeyEquatable && (!hasIKeyEquatableDeclared || getKeyHashCode is null || keyEquals is null); @@ -97,7 +110,7 @@ private void LoadConfigs() { // If the type is not IKeyEquatable but inherits from type that is, we still add it to the configs (with code gen disabled) for the registry generation. return _configs[type] = baseIKeyEquatable is not null - ? new Config(type, null, baseIKeyEquatable, new(0), NeedsCodeGen: false, HasGetKeyHashCode: false, HasKeyEquals: false) + ? new Config(type, null, baseIKeyEquatable, null, baseIKeyed, new(0), NeedsCodeGen: false, IsCustomImplementation: false) : null; } @@ -109,6 +122,7 @@ private void LoadConfigs() return _configs[type] = null; // Reduce number of errors by considering type as not IKeyEquatable } + // Make sure that if user partially implemented IKeyEquatable, he fully implements it, and he also provide IKeyed implementation if (getKeyHashCode is not null && keyEquals is null) { _ctx.Context.ReportDiagnostic(Rules.KE0002.GetDiagnostic(type, getKeyHashCode)); @@ -117,16 +131,32 @@ private void LoadConfigs() { _ctx.Context.ReportDiagnostic(Rules.KE0003.GetDiagnostic(type, keyEquals)); } + if (isCustomImplementation && !hasIKeyedDeclared) + { + _ctx.Context.ReportDiagnostic(Rules.KE0006.GetDiagnostic(type, getKeyHashCode ?? keyEquals!)); + } + + return _configs[type] = new Config(type, iKeyEquatable, baseIKeyEquatable, iKeyed, baseIKeyed, keys, needsCodeGen, isCustomImplementation); - return _configs[type] = new Config(type, iKeyEquatable, baseIKeyEquatable, keys, needsCodeGen, getKeyHashCode is not null, keyEquals is not null); + (INamedTypeSymbol? iKeyEquatable, INamedTypeSymbol? iKeyed) GetBaseImplementations(INamedTypeSymbol baseType) + { + if (SymbolEqualityComparer.Default.Equals(baseType.ContainingAssembly, _assembly)) + { + return GetOrCreateConfig(baseType, assemblyImplicit) is { } config + ? (config.IKeyEquatable, config.IKeyed ?? config.BaseIKeyed) + : default; + } + else + { + baseType.IsOrImplements(_ctx.IKeyEquatable, out var baseKeyEquatable); + baseType.IsOrImplements(_ctx.IKeyEquatable, out var baseKeyed); - INamedTypeSymbol? GetBaseIKeyEquatable(INamedTypeSymbol baseType) - => SymbolEqualityComparer.Default.Equals(baseType.ContainingAssembly, _assembly) - ? GetOrCreateConfig(baseType, assemblyImplicit)?.IKeyEquatable - : (baseType.IsOrImplements(_ctx.IKeyEquatable, out var baseKeyEquatable) ? baseKeyEquatable : null); + return (baseKeyEquatable, baseKeyed); + } + } } - private List SearchKeys(INamedTypeSymbol type, ImplicitKeyEqualityAttribute assemblyImplicit) + private List SearchKeys(INamedTypeSymbol type, ImplicitKeysAttribute assemblyImplicit) { // Search for properties flagged with [Key] attribute (Using Uno's attribute or System.ComponentModel.DataAnnotations.KeyAttribute) var keys = type @@ -138,7 +168,7 @@ private List SearchKeys(INamedTypeSymbol type, ImplicitKeyEqual // If none found, search for implicit key properties if (keys is { Count: 0 }) { - var typeImplicit = type.FindAttribute(); + var typeImplicit = type.FindAttribute(); var @implicit = typeImplicit ?? assemblyImplicit; if (@implicit.IsEnabled) { @@ -179,10 +209,34 @@ private string GenerateKeyEquatable(INamedTypeSymbol type, Config config) return this.AsPartialOf( type, - $"{NS.Equality}.IKeyEquatable<{type}>", // i.e. config.IKeyEquatable + $"{NS.Equality}.IKeyEquatable<{type}>{(config.IKeyed is null ? "" : ", " + config.IKeyed.ToFullString())}", // i.e. config.IKeyEquatable $@" - {(config.HasGetKeyHashCode - ? $"// Skipping {GetKeyHashCode} as it has already been implemented in user's code" + {(config.IsCustomImplementation + ? "// Skipping IKeyed.Key as user is providing a custom implementation of IKeyEquatable" + : $@"/// + {this.GetCodeGenAttribute()} + {config.IKeyed!.TypeArguments[0].ToFullString()} {config.IKeyed.ToFullString()}.Key + {{ + get + {{ + {config switch + { + { BaseIKeyed: null, Keys.Count: 1 } => $"return {config.Keys[0].Name};", + { BaseIKeyed: null } => $"return ({config.GetKeyNames()});", + _ => $@" + var baseKey = (({config.BaseIKeyed.ToFullString()})this).Key; + return ({_ctx.DeconstructTupleOrSingle(config.BaseIKeyed!.TypeArguments[0]).Length switch + { + 1 => "baseKey", + var baseKeys => Enumerable.Range(1, baseKeys).Select(i => $"baseKey.Item{i}").Align(9, ",") + }}, + {config.GetKeyNames()});".Align(8) + }} + }} + }}".Align(4))} + + {(config.IsCustomImplementation + ? $"// Skipping {GetKeyHashCode} as user is providing a custom implementation of IKeyEquatable" : $@"/// {this.GetCodeGenAttribute()} public {modifiers} int {GetKeyHashCode}() @@ -205,8 +259,8 @@ private string GenerateKeyEquatable(INamedTypeSymbol type, Config config) }} }}".Align(4))} - {(config.HasKeyEquals - ? $"// Skipping {KeyEquals} as it has already been implemented in user's code" + {(config.IsCustomImplementation + ? $"// Skipping {KeyEquals} as user is providing a custom implementation of IKeyEquatable" : $@"/// {this.GetCodeGenAttribute()} public bool {KeyEquals}({type}{(type.IsValueType ? "" : "?")} other) @@ -309,18 +363,24 @@ private __KeyEqualityProvider() /// /// /// - /// The bounded implementation of IKeyEquatable. + /// The bounded local implementation of IKeyEquatable. /// The bounded base implementation of IKeyEquatable if any. + /// The bounded local implementation of IKeyed if any. + /// The bounded base implementation of IKeyed if any. /// The keys to use to generation. /// Indicates is a partial class is needed. - /// Indicates if the GetKeyHashCode has already been implemented. - /// Indicates if the KeyEquals has already been implemented. + /// Indicates the user is providing it own implementation of IKeyEquatable. private record Config( INamedTypeSymbol Type, INamedTypeSymbol? IKeyEquatable, INamedTypeSymbol? BaseIKeyEquatable, + INamedTypeSymbol? IKeyed, + INamedTypeSymbol? BaseIKeyed, List Keys, bool NeedsCodeGen, - bool HasGetKeyHashCode, - bool HasKeyEquals); + [property: MemberNotNullWhen(true, nameof(Config.IKeyed))] bool IsCustomImplementation) + { + public string GetKeyNames(string separator = ", ") + => Keys.Select(k => k.Name).JoinBy(separator); + } } diff --git a/src/Uno.Extensions.Core.Generators/KeyEquality/Rules.KeyEquality.cs b/src/Uno.Extensions.Core.Generators/KeyEquality/Rules.KeyEquality.cs index d43b942c6c..810b3ffd33 100644 --- a/src/Uno.Extensions.Core.Generators/KeyEquality/Rules.KeyEquality.cs +++ b/src/Uno.Extensions.Core.Generators/KeyEquality/Rules.KeyEquality.cs @@ -11,7 +11,7 @@ internal static partial class Rules public static class KE0001 { private const string message = "The record '{0}' is eligible to IKeyEquatable generation (due to {1}) but is not partial." - + " Either make it partial (recommended), either disable implicit IKeyEquality generation using [ImplicitKeyEquality(IsEnabled = false)]" + + " Either make it partial (recommended), either disable implicit IKeyEquality generation using [ImplicitKeys(IsEnabled = false)]" + " on the record itself or on the whole assembly (not recommended)."; public static readonly DiagnosticDescriptor Descriptor = new DiagnosticDescriptor( @@ -96,13 +96,13 @@ public static Diagnostic GetDiagnostic(INamedTypeSymbol @class, IMethodSymbol ke public static class KE0004 { - private const string message = "The record '{0}' is flagged with [ImplicitKeyEquality] attribute, but no property match any of the defined implicit keys." + private const string message = "The record '{0}' is flagged with [ImplicitKeys] attribute, but no property match any of the defined implicit keys." + " The IKeyEquatable implementation cannot be generated." - + " You should either remove the [ImplicitKeyEquality] attribute, either add property named {1}."; + + " You should either remove the [ImplicitKeys] attribute, either add property named {1}."; public static readonly DiagnosticDescriptor Descriptor = new DiagnosticDescriptor( nameof(KE0004), - "Records flags with [ImplicitKeyEquality] attribute should have a matching key", + "Records flags with [ImplicitKeys] attribute should have a matching key", message, Category.Usage, DiagnosticSeverity.Warning, @@ -166,6 +166,31 @@ public static Diagnostic GetDiagnostic(INamedTypeSymbol @class, ICollection string.Format(CultureInfo.InvariantCulture, message, @class.Name); + + public static Diagnostic GetDiagnostic(INamedTypeSymbol @class, IMethodSymbol equatableImpl) + => Diagnostic.Create( + Descriptor, + equatableImpl.DeclaringSyntaxReferences.FirstOrDefault() is { } syntax + ? Location.Create(syntax.SyntaxTree, syntax.Span) + : Location.None, + @class.Name); + } + private static string FormatKeys(ICollection keys, string separator) => keys?.Count switch { diff --git a/src/Uno.Extensions.Core.Tests/KeyEquality/Given_KeyEquatableRecord_Then_Generate.cs b/src/Uno.Extensions.Core.Tests/KeyEquality/Given_KeyEquatableRecord_Then_Generate.cs index 3376b7e30e..9150936fdb 100644 --- a/src/Uno.Extensions.Core.Tests/KeyEquality/Given_KeyEquatableRecord_Then_Generate.cs +++ b/src/Uno.Extensions.Core.Tests/KeyEquality/Given_KeyEquatableRecord_Then_Generate.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.Linq; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -9,6 +10,12 @@ namespace Uno.Extensions.Core.Tests.KeyEquality; [TestClass] public class Given_KeyEquatableRecord_Then_Generate { + [TestMethod] + public void When_GetKey() + { + ((IKeyed)new MyKeyEquatableRecord(42)).Key.Should().Be(42); + } + [TestMethod] public void When_HasSameId_Then_KeyEquals() { @@ -46,15 +53,17 @@ public void When_HasDifferentId_Then_DifferentKeyHashCode() } [TestMethod] - public void When_IsKeyEquatable_Then_ImplementsInterface() + public void When_IsKeyEquatable_Then_ImplementsInterfaces() { (new MyKeyEquatableRecord(42) as IKeyEquatable).Should().NotBeNull(); + (new MyKeyEquatableRecord(42) as IKeyed).Should().NotBeNull(); } [TestMethod] public void When_IsNotKeyEquatable_Then_DoesNotImplementInterface() { (new MyNotKeyEquatableRecord(42) as IKeyEquatable).Should().BeNull(); + (new MyNotKeyEquatableRecord(42) as IKeyed).Should().BeNull(); } [TestMethod] @@ -83,6 +92,8 @@ public void When_CustomImplicitKeyEquatable_Then_CustomKeyUsed() inst1.GetKeyHashCode().Should().NotBe(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeFalse(); + + ((IKeyed)inst1).Key.Should().Be(1); } [TestMethod] @@ -93,6 +104,8 @@ public void When_CustomImplicitKeyEquatable_Then_OnlyCustomKeyIsUsed() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] @@ -103,6 +116,8 @@ public void When_CustomKeyEquatable_Then_CustomKeyUsed() inst1.GetKeyHashCode().Should().NotBe(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeFalse(); + + ((IKeyed)inst1).Key.Should().Be(1); } [TestMethod] @@ -113,6 +128,8 @@ public void When_CustomKeyEquatable_Then_OnlyCustomKeyIsUsed() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] public void When_CustomDataAnnotationsKeyEquatable_Then_CustomKeyUsed() @@ -122,6 +139,8 @@ public void When_CustomDataAnnotationsKeyEquatable_Then_CustomKeyUsed() inst1.GetKeyHashCode().Should().NotBe(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeFalse(); + + ((IKeyed)inst1).Key.Should().Be(1); } [TestMethod] @@ -132,6 +151,8 @@ public void When_CustomDataAnnotationsKeyEquatable_Then_OnlyCustomKeyIsUsed() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] @@ -142,6 +163,8 @@ public void When_CustomKeyWithImplicitEquatable_Then_CustomKeyUsed() inst1.GetKeyHashCode().Should().NotBe(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeFalse(); + + ((IKeyed)inst1).Key.Should().Be(1); } [TestMethod] @@ -152,6 +175,8 @@ public void When_CustomKeyWithImplicitEquatable_Then_OnlyCustomKeyIsUsed() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] @@ -162,6 +187,8 @@ public void When_CustomKeyWithoutImplicitEquatable_Then_Generated() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] @@ -172,6 +199,8 @@ public void When_RecordStruct_Then_Generated() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] @@ -182,6 +211,8 @@ public void When_CustomImplementedKeyEquatable_Then_OnlyCustomImplIsUsed() inst1.GetKeyHashCode().Should().Be(3); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(3); } [TestMethod] @@ -198,6 +229,8 @@ public void When_CustomExplicitlyImplementedKeyEquatable_Then_OnlyCustomImplIsUs ((IKeyEquatable)inst1).GetKeyHashCode().Should().Be(3); ((IKeyEquatable)inst1).KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(3); } [TestMethod] @@ -209,7 +242,7 @@ public void When_CustomExplicitlyImplementedKeyEquatable_Then_HasKeyEqualityComp [TestMethod] public void When_CustomExplicitlyImplementedEquatableClass_Then_HasKeyEqualityComparer() { - KeyEqualityComparer.Find().Should().NotBe(null); + KeyEqualityComparer.Find().Should().NotBe(null); } [TestMethod] @@ -220,6 +253,8 @@ public void When_SubWithSameBaseIdButOfDifferentType_Then_NotKeyEquals() inst1.GetKeyHashCode().Should().NotBe(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeFalse(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] @@ -231,6 +266,8 @@ public void When_SubKeyEquatableRecord_Then_Generated() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + ((IKeyed)inst1).Key.Should().Be(42); + var comparer = KeyEqualityComparer.Find(); comparer.Should().NotBeNull(because: "we should have fallback on base type comparer"); @@ -251,6 +288,8 @@ public void When_SubNotKeyEquatableRecord_Then_NotGenerated() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + ((IKeyed)inst1).Key.Should().Be(42); + var comparer = KeyEqualityComparer.Find(); comparer.Should().NotBeNull(because: "we should have fallback on base type comparer"); @@ -269,6 +308,9 @@ public void When_SubCustomImplicitKeyEquatableRecord_Then_GeneratedAndUseBaseKey inst1.KeyEquals(inst3).Should().BeFalse(); inst1.KeyEquals(inst4).Should().BeFalse(); + ((IKeyed)inst1).Key.Should().Be(1); + ((IKeyed<(int, int)>)inst1).Key.Should().Be((1, 42)); + var comparer = KeyEqualityComparer.Find(); comparer.Should().NotBe(null); @@ -287,6 +329,9 @@ public void When_SubCustomKeyEquatableRecord_Then_GeneratedAndUseBaseKeyAndCusto inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + ((IKeyed)inst1).Key.Should().Be(1); + ((IKeyed<(int, int)>)inst1).Key.Should().Be((1, 42)); + var comparer = KeyEqualityComparer.Find(); comparer.Should().NotBe(null); @@ -306,6 +351,8 @@ public void When_SubNot_KeyEquatableRecord_Then_Generated() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + ((IKeyed)inst1).Key.Should().Be("1"); + var comparer = KeyEqualityComparer.Find(); comparer.Should().NotBe(null); @@ -330,6 +377,8 @@ public void When_SubNot_CustomImplicitKeyEquatableRecord_Then_GeneratedAndUseCus inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + ((IKeyed)inst1).Key.Should().Be(42); + var comparer = KeyEqualityComparer.Find(); comparer.Should().NotBe(null); @@ -346,6 +395,8 @@ public void When_SubNot_CustomKeyEquatableRecord_Then_GeneratedAndUseCustomKey() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + ((IKeyed)inst1).Key.Should().Be(42); + var comparer = KeyEqualityComparer.Find(); comparer.Should().NotBe(null); @@ -361,6 +412,8 @@ public void When_NestedRecord_Then_Generated() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); } [TestMethod] @@ -371,31 +424,62 @@ public void When_NestedRecordStruct_Then_Generated() inst1.GetKeyHashCode().Should().Be(inst2.GetKeyHashCode()); inst1.KeyEquals(inst2).Should().BeTrue(); + + ((IKeyed)inst1).Key.Should().Be(42); + } + + [TestMethod] + public void When_SubKeyEquatable_Then_InvokedBase() + { + var inst1 = new MySubCustom_KeyEquatbleRecord(42); + var inst2 = new MySubCustom_KeyEquatbleRecord(42); + + using var tracker = new KeyEquatableTestHelper(); + + var k = ((IKeyed<(int, int)>)inst1).Key; + var h = inst1.GetKeyHashCode(); + var e = inst2.KeyEquals(inst2); + + tracker.GetKey.Count.Should().Be(1); + tracker.GetKey[0].type.Should().Be(typeof(MyBaseCustomImplementationKeyEquatableRecord)); + tracker.GetKey[0].instance.Should().Be(inst1); + + tracker.GetKeyHashCode.Count.Should().Be(1); + tracker.GetKeyHashCode[0].type.Should().Be(typeof(MyBaseCustomImplementationKeyEquatableRecord)); + tracker.GetKeyHashCode[0].instance.Should().Be(inst1); + + tracker.KeyEquals.Count.Should().Be(1); + tracker.KeyEquals[0].type.Should().Be(typeof(MyBaseCustomImplementationKeyEquatableRecord)); + tracker.KeyEquals[0].instance.Should().Be(inst1); + tracker.KeyEquals[0].other.Should().Be(inst2); } } public partial record MyKeyEquatableRecord(int Id); -[ImplicitKeyEquality(IsEnabled = false)] +[ImplicitKeys(IsEnabled = false)] public partial record MyNotKeyEquatableRecord(int Id); -[ImplicitKeyEquality("MyKey")] +[ImplicitKeys("MyKey")] public partial record MyCustomImplicitKeyEquatableRecord(int Id, int MyKey); public partial record MyCustomKeyEquatableRecord(int Id, [property:Key] int MyKey); public partial record MyCustomDataAnnotationsKeyEquatableRecord(int Id, [property: System.ComponentModel.DataAnnotations.KeyAttribute] int MyKey); -[ImplicitKeyEquality("MyOtherKey")] +[ImplicitKeys("MyOtherKey")] public partial record MyCustomKeyWithImplicitEquatableRecord(int Id, [property: Key] int MyKey, int MyOtherKey); -[ImplicitKeyEquality(IsEnabled = false)] +[ImplicitKeys(IsEnabled = false)] public partial record MyCustomKeyWithoutImplicitEquatableRecord(int Id, [property: Key] int MyKey, int MyOtherKey); public partial record struct MyKeyEquatableRecordStruct(int Id); -public partial record MyCustomImplementationEquatableRecord(int Id, int MySecondKey) : IKeyEquatable +public partial record MyCustomImplementationEquatableRecord(int Id, int MySecondKey) : IKeyEquatable, IKeyed { + /// + public int Key => Id + MySecondKey; + public int GetKeyHashCode() => Id + MySecondKey; @@ -403,8 +487,11 @@ public bool KeyEquals(MyCustomImplementationEquatableRecord other) => GetKeyHashCode() == other.GetKeyHashCode(); } -public partial record MyCustomExplicitImplementationEquatableRecord(int Id, int MySecondKey) : IKeyEquatable +public partial record MyCustomExplicitImplementationEquatableRecord(int Id, int MySecondKey) : IKeyEquatable, IKeyed { + /// + int IKeyed.Key => Id + MySecondKey; + int IKeyEquatable.GetKeyHashCode() => Id + MySecondKey; @@ -412,14 +499,14 @@ bool IKeyEquatable.KeyEquals(MyCu => ((IKeyEquatable)this).GetKeyHashCode() == ((IKeyEquatable)other).GetKeyHashCode(); } -public class MyCustomExplicitImplementationEquatableClass : IKeyEquatable +public class MyCustomImplementationEquatableClass : IKeyEquatable { /// public int GetKeyHashCode() => 0; /// - public bool KeyEquals(MyCustomExplicitImplementationEquatableClass other) + public bool KeyEquals(MyCustomImplementationEquatableClass other) => true; } @@ -429,10 +516,10 @@ public partial record MyBaseKeyEquatableRecord(int Id); public partial record MySubKeyEquatableRecord(int Id, string SomethingElse) : MyBaseKeyEquatableRecord(Id); -[ImplicitKeyEquality(IsEnabled = false)] +[ImplicitKeys(IsEnabled = false)] public partial record MySubNotKeyEquatableRecord(int Id, string SomethingElse) : MyBaseKeyEquatableRecord(Id); -[ImplicitKeyEquality("MyKey")] +[ImplicitKeys("MyKey")] public partial record MySubCustomImplicitKeyEquatableRecord(int Id, int MyKey) : MyBaseKeyEquatableRecord(Id); public partial record MySubCustomKeyEquatableRecord(int Id, [property: Key] int MyKey) : MyBaseKeyEquatableRecord(Id); @@ -441,21 +528,45 @@ public partial record MySubCustomKeyEquatableRecord(int Id, [property: Key] int -[ImplicitKeyEquality(IsEnabled = false)] +[ImplicitKeys(IsEnabled = false)] public partial record MyBaseNotKeyEquatableRecord(int Id); public partial record MySubNot_KeyEquatableRecord(int Id, string Key) : MyBaseNotKeyEquatableRecord(Id); -[ImplicitKeyEquality(IsEnabled = false)] +[ImplicitKeys(IsEnabled = false)] public partial record MySubNot_NotKeyEquatableRecord(int Id, string SomethingElse) : MyBaseNotKeyEquatableRecord(Id); -[ImplicitKeyEquality("MyKey")] +[ImplicitKeys("MyKey")] public partial record MySubNot_CustomImplicitKeyEquatableRecord(int Id, int MyKey) : MyBaseNotKeyEquatableRecord(Id); public partial record MySubNot_CustomKeyEquatableRecord(int Id, [property: Key] int MyKey) : MyBaseNotKeyEquatableRecord(Id); +public partial record MyBaseCustomImplementationKeyEquatableRecord(int MyKey) : IKeyed +{ + public int Key + { + get + { + KeyEquatableTestHelper.NotifyGetKey(typeof(MyBaseCustomImplementationKeyEquatableRecord), this); + return MyKey; + } + } + public virtual int GetKeyHashCode() + { + KeyEquatableTestHelper.NotifyGetKeyHashCodeInvoked(typeof(MyBaseCustomImplementationKeyEquatableRecord), this); + return MyKey; + } + + public virtual bool KeyEquals(MyBaseCustomImplementationKeyEquatableRecord other) + { + KeyEquatableTestHelper.NotifyKeyEqualsInvoked(typeof(MyBaseCustomImplementationKeyEquatableRecord), this, other); + return other.MyKey == MyKey; + } +} + +public partial record MySubCustom_KeyEquatbleRecord(int Id) : MyBaseCustomImplementationKeyEquatableRecord(42); public partial class MyKeyEqualityTypesContainer @@ -464,3 +575,45 @@ public partial record MyNestedKeyEquatableRecord(int Id); public partial record struct MyNestedKeyEquatableRecordStruct(int Id); } + +internal class KeyEquatableTestHelper : IDisposable +{ + private static KeyEquatableTestHelper? _current; + + public ImmutableList<(Type type, object instance)> GetKey { get; private set; } = ImmutableList<(Type, object)>.Empty; + public ImmutableList<(Type type, object instance)> GetKeyHashCode { get; private set; } = ImmutableList<(Type, object)>.Empty; + public ImmutableList<(Type type, object instance, object other)> KeyEquals { get; private set; } = ImmutableList<(Type, object, object)>.Empty; + + public KeyEquatableTestHelper() + { + _current = this; + } + + public static void NotifyGetKey(Type type, object instance) + { + if (_current is { } current) + { + current.GetKey = current.GetKey.Add((type, instance)); + } + } + + public static void NotifyGetKeyHashCodeInvoked(Type type, object instance) + { + if (_current is { } current) + { + current.GetKeyHashCode = current.GetKeyHashCode.Add((type, instance)); + } + } + + public static void NotifyKeyEqualsInvoked(Type type, object instance, object other) + { + if (_current is { } current) + { + current.KeyEquals = current.KeyEquals.Add((type, instance, other)); + } + } + + /// + public void Dispose() + => _current = null; +} diff --git a/src/Uno.Extensions.Core/Equality/IKeyed.cs b/src/Uno.Extensions.Core/Equality/IKeyed.cs new file mode 100644 index 0000000000..8c7cf5a3aa --- /dev/null +++ b/src/Uno.Extensions.Core/Equality/IKeyed.cs @@ -0,0 +1,17 @@ +using System; +using System.Linq; + +namespace Uno.Extensions.Equality; + +/// +/// Defines an entity that has a constant logic identifier. +/// +/// The type of the key +public interface IKeyed + where TKey : notnull +{ + /// + /// Gets the key identifier of this entity. + /// + TKey Key { get; } +} diff --git a/src/Uno.Extensions.Core/Equality/ImplicitKeyEqualityAttribute.cs b/src/Uno.Extensions.Core/Equality/ImplicitKeysAttribute.cs similarity index 72% rename from src/Uno.Extensions.Core/Equality/ImplicitKeyEqualityAttribute.cs rename to src/Uno.Extensions.Core/Equality/ImplicitKeysAttribute.cs index 114aeaad7c..b2205e9d21 100644 --- a/src/Uno.Extensions.Core/Equality/ImplicitKeyEqualityAttribute.cs +++ b/src/Uno.Extensions.Core/Equality/ImplicitKeysAttribute.cs @@ -1,22 +1,23 @@ using System; +using System.ComponentModel; using System.Linq; namespace Uno.Extensions.Equality; /// -/// Configures the generation of implicit key equality comparer for entry tracking. +/// Configures the implicit generation of IKeyed and IKeyEquatable that can be used for entry tracking. /// See remarks for details about key resolution. /// /// -/// When key equality generator runs, it will apply those rules in that order: +/// When key generator runs, it will apply those rules in that order: /// 1. If one or more properties are flagged with the , those properties will be used. -/// 2. Otherwise, if a property is named like one of the configured , this property is going to be used. +/// 2. Otherwise, if a property is named like one of the configured , this property is going to be used. /// Matching is made case-insensitive. -/// If there are multiple properties that are matching, the order of the configured will be used, +/// If there are multiple properties that are matching, the order of the configured will be used, /// and only the first one witch match will be used. /// [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] -partial class ImplicitKeyEqualityAttribute : Attribute +sealed partial class ImplicitKeysAttribute : Attribute { /// /// Gets or sets a bool which indicates if the generation of key equality based on property names is enabled of not. @@ -31,7 +32,7 @@ partial class ImplicitKeyEqualityAttribute : Attribute /// /// Create a new instance using default values. /// - public ImplicitKeyEqualityAttribute() + public ImplicitKeysAttribute() { } @@ -39,7 +40,7 @@ public ImplicitKeyEqualityAttribute() /// Creates a new instance specifying the . /// /// The name of properties that should be implicitly used as key. - public ImplicitKeyEqualityAttribute(params string[] propertyNames) + public ImplicitKeysAttribute(params string[] propertyNames) { PropertyNames = propertyNames; } diff --git a/src/Uno.Extensions.Core/Equality/KeyEqualityComparer.cs b/src/Uno.Extensions.Core/Equality/KeyEqualityComparer.cs index 24cb695222..ca47cd0d98 100644 --- a/src/Uno.Extensions.Core/Equality/KeyEqualityComparer.cs +++ b/src/Uno.Extensions.Core/Equality/KeyEqualityComparer.cs @@ -11,7 +11,7 @@ namespace Uno.Extensions.Equality; /// /// /// This registry is expected to be automatically full-filled by the code generated by the KeyEqualityGenerationTool for all types that explicitly -/// implements or types that are eligible for its implementation generation (cf. ImplicitKeyEqualityAttribute). +/// implements or types that are eligible for its implementation generation (cf. ImplicitKeysAttribute). /// public static class KeyEqualityComparer { diff --git a/src/Uno.Extensions.Core/Equality/_AttributesVisibility.cs b/src/Uno.Extensions.Core/Equality/_AttributesVisibility.cs index e6956bd986..6ecfc3b7bd 100644 --- a/src/Uno.Extensions.Core/Equality/_AttributesVisibility.cs +++ b/src/Uno.Extensions.Core/Equality/_AttributesVisibility.cs @@ -11,7 +11,7 @@ namespace Uno.Extensions.Equality; // To resolve this, we don't set any access modifier in **Attributes.cs files (so they are internal by default), // and then make them public only in the Core project. -public partial class ImplicitKeyEqualityAttribute +public partial class ImplicitKeysAttribute { } diff --git a/src/Uno.Extensions.Navigation/AssemblyInfo.cs b/src/Uno.Extensions.Navigation/AssemblyInfo.cs index 80fb079620..911fca0762 100644 --- a/src/Uno.Extensions.Navigation/AssemblyInfo.cs +++ b/src/Uno.Extensions.Navigation/AssemblyInfo.cs @@ -6,4 +6,4 @@ [assembly: InternalsVisibleTo("Uno.Extensions.Navigation.Toolkit.UI")] [assembly: InternalsVisibleTo("Uno.Extensions.Navigation.Toolkit.WinUI")] -[assembly: ImplicitKeyEquality(IsEnabled = false)] +[assembly: ImplicitKeys(IsEnabled = false)] diff --git a/src/Uno.Extensions.Reactive.Testing/ConstraintParts/Changed.cs b/src/Uno.Extensions.Reactive.Testing/ConstraintParts/Changed.cs index 07eb43608c..8cb2a70be8 100644 --- a/src/Uno.Extensions.Reactive.Testing/ConstraintParts/Changed.cs +++ b/src/Uno.Extensions.Reactive.Testing/ConstraintParts/Changed.cs @@ -8,7 +8,7 @@ namespace Uno.Extensions.Reactive.Testing; public class Changed : ChangesConstraint { - public static Changed None { get; } = new(); + public static Changed None { get; } = new(Array.Empty()); public static Changed Data { get; } = new(MessageAxis.Data); @@ -20,21 +20,32 @@ public class Changed : ChangesConstraint public static Changed Pagination { get; } = new(MessageAxis.Pagination); + public static Changed Selection { get; } = new(MessageAxis.Selection); + + public static Changed Axes(params string[] axisIdentifiers) + => new(axisIdentifiers); + public static Changed Axes(params MessageAxis[] axes) => new(axes); - public readonly MessageAxis[] Expected; + public readonly string[] Expected; public Changed(params MessageAxis[] expected) - => Expected = expected; + => Expected = expected.Select(a => a.Identifier).ToArray(); + + public Changed(params string[] expectedIdentifiers) + => Expected = expectedIdentifiers; /// public override void Assert(ChangeCollection changes) - => changes.Should().BeEquivalentTo(Expected); + => changes.Select(a => a.Identifier).Should().BeEquivalentTo(Expected); public static Changed operator &(Changed left, Changed right) => new(left.Expected.Concat(right.Expected).ToArray()); public static Changed operator &(Changed left, MessageAxis axis) - => new(left.Expected.Concat(new [] {axis}).ToArray()); + => new(left.Expected.Concat(new[] { axis.Identifier }).ToArray()); + + public static Changed operator &(Changed left, string axisIdentifier) + => new(left.Expected.Concat(new[] { axisIdentifier }).ToArray()); } diff --git a/src/Uno.Extensions.Reactive.Testing/ConstraintParts/ChangedConstraint.cs b/src/Uno.Extensions.Reactive.Testing/ConstraintParts/ChangedConstraint.cs index 9e04e2f4f8..acd218ab4e 100644 --- a/src/Uno.Extensions.Reactive.Testing/ConstraintParts/ChangedConstraint.cs +++ b/src/Uno.Extensions.Reactive.Testing/ConstraintParts/ChangedConstraint.cs @@ -16,6 +16,9 @@ public ChangedConstraint(ChangesConstraint value) Value = value; } + public static implicit operator ChangedConstraint(MessageAxis axis) + => new Changed(axis); + public static implicit operator ChangedConstraint(Changed axes) => new(axes); diff --git a/src/Uno.Extensions.Reactive.Testing/ConstraintParts/Selection.cs b/src/Uno.Extensions.Reactive.Testing/ConstraintParts/Selection.cs new file mode 100644 index 0000000000..4b1fb9df8e --- /dev/null +++ b/src/Uno.Extensions.Reactive.Testing/ConstraintParts/Selection.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using FluentAssertions; +using FluentAssertions.Execution; + +namespace Uno.Extensions.Reactive.Testing; + +public class Selection : AxisConstraint +{ + public static Selection Undefined { get; } = new(isDefined: false, isEmpty: null); + + public static Selection Empty { get; } = new(isDefined: true, isEmpty: true); + + public static Selection Items(params T[] selectedItems) + => new(selectedItems); + + private readonly bool _isDefined; + private readonly bool? _isEmpty; + + private protected Selection(bool isDefined, bool? isEmpty) + { + _isDefined = isDefined; + _isEmpty = isEmpty; + } + + /// + public override MessageAxis ConstrainedAxis => MessageAxis.Selection; + + /// + public override void Assert(IMessageEntry actual) + { + var actualSelection = actual.Get(MessageAxis.Selection); + + using (AssertionScope.Current.ForContext("is defined")) + { + (actualSelection is not null).Should().Be(_isDefined); + } + + if (actualSelection is not null && _isEmpty is not null) + { + using (AssertionScope.Current.ForContext("is empty")) + { + actualSelection.IsEmpty.Should().Be(_isEmpty.Value); + } + } + } +} + +public class Selection : Selection +{ + private readonly T[] _selectedItems; + + public Selection(T[] selectedItems) + : base(isDefined: true, isEmpty: false) + { + _selectedItems = selectedItems; + } + + /// + public override MessageAxis ConstrainedAxis => MessageAxis.Selection; + + /// + public override void Assert(IMessageEntry actual) + { + base.Assert(actual); + + var actualItems = (IImmutableList)actual.Data.SomeOrDefault(ImmutableList.Empty); + var actualSelection = actual.Get(MessageAxis.Selection) ?? SelectionInfo.Empty; + + using (AssertionScope.Current.ForContext("items")) + { + var selectedItems = actualSelection.GetSelectedItems(actualItems, failIfOutOfRange: true); + + selectedItems.Should().BeEquivalentTo(_selectedItems); + } + } +} diff --git a/src/Uno.Extensions.Reactive.Testing/FeedCoreRules.cs b/src/Uno.Extensions.Reactive.Testing/FeedCoreRules.cs index 619e9f22e6..4d383c0beb 100644 --- a/src/Uno.Extensions.Reactive.Testing/FeedCoreRules.cs +++ b/src/Uno.Extensions.Reactive.Testing/FeedCoreRules.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Execution; +using Uno.Extensions.Reactive.Core; namespace Uno.Extensions.Reactive.Testing; @@ -53,7 +54,10 @@ public static async ValueTask When_Feed_Then_CompliesToCoreRules(FeedUnd using var scope = new AssertionScope("initial message content"); await When_Subscribe_Then_FirstMessageHasDataOrIsTransient(sut.Value, ct); - await When_SubscribeMultipleTimeWithSameContext_Then_GetSameResult(sut.Dependencies, sut.Value, ct); + if (sut.Value is not IStateImpl) + { + await When_SubscribeMultipleTimeWithSameContext_Then_GetSameResult(sut.Dependencies, sut.Value, ct); + } } /// @@ -68,7 +72,10 @@ public static async ValueTask When_ListFeed_Then_CompliesToCoreRules(Lis using var scope = new AssertionScope("initial message content"); await When_Subscribe_Then_FirstMessageHasDataOrIsTransient(sut.Value, ct); - await When_SubscribeMultipleTimeWithSameContext_Then_GetSameResult(sut.Dependencies, sut.Value, ct); + if (sut.Value is not IStateImpl) + { + await When_SubscribeMultipleTimeWithSameContext_Then_GetSameResult(sut.Dependencies, sut.Value, ct); + } } @@ -105,7 +112,7 @@ internal static async Task When_Subscribe_Then_FirstMessageHasDataOrIsTransient< } } - private static async Task When_SubscribeMultipleTimeWithSameContext_Then_GetSameResult( + internal static async Task When_SubscribeMultipleTimeWithSameContext_Then_GetSameResult( ImmutableArray> dependencies, TFeed feed, CancellationToken ct) diff --git a/src/Uno.Extensions.Reactive.Testing/FeedRecorder.T.cs b/src/Uno.Extensions.Reactive.Testing/FeedRecorder.T.cs index 42ca3a0dcd..2f9a9faf99 100644 --- a/src/Uno.Extensions.Reactive.Testing/FeedRecorder.T.cs +++ b/src/Uno.Extensions.Reactive.Testing/FeedRecorder.T.cs @@ -140,7 +140,7 @@ public async ValueTask WaitForMessages(int count, int timeout, CancellationToken } catch (OperationCanceledException) when (!ct.IsCancellationRequested && cts.IsCancellationRequested) { - throw new TimeoutException($"[{Name}] The source feed did not produced the expected {count} messages (got only {_messages.Count}) within the given delay of {TimeSpan.FromMilliseconds(timeout):g}."); + throw new TimeoutException($"[{Name}] The source feed did not produced the expected {count} messages (got only {_messages.Count}) within the given delay of {TimeSpan.FromMilliseconds(timeout):g}.\r\n{this}"); } finally { diff --git a/src/Uno.Extensions.Reactive.Tests/Commands/Given_AsyncCommand.cs b/src/Uno.Extensions.Reactive.Tests/Commands/Given_AsyncCommand.cs index 9973a6665b..f3181a8851 100644 --- a/src/Uno.Extensions.Reactive.Tests/Commands/Given_AsyncCommand.cs +++ b/src/Uno.Extensions.Reactive.Tests/Commands/Given_AsyncCommand.cs @@ -150,7 +150,7 @@ private async Task WaitFor(Func predicate) return (sut, executions); } - [ImplicitKeyEquality(IsEnabled = false)] + [ImplicitKeys(IsEnabled = false)] private record Execution(ExecutionStartedEventArgs Start) { public const int DefaultTimeoutMs = 1000; diff --git a/src/Uno.Extensions.Reactive.Tests/Core/TestRequest.cs b/src/Uno.Extensions.Reactive.Tests/Core/TestRequest.cs index 47a33d8249..5df7f4efdf 100644 --- a/src/Uno.Extensions.Reactive.Tests/Core/TestRequest.cs +++ b/src/Uno.Extensions.Reactive.Tests/Core/TestRequest.cs @@ -6,7 +6,7 @@ namespace Uno.Extensions.Reactive.Tests.Core; -[ImplicitKeyEquality(IsEnabled = false)] +[ImplicitKeys(IsEnabled = false)] internal record TestRequest(int? _id = null) : IContextRequest { private static int _nextId; diff --git a/src/Uno.Extensions.Reactive.Tests/Messaging/Given_Messaging.cs b/src/Uno.Extensions.Reactive.Tests/Messaging/Given_Messaging.cs index eb487d31bb..a283637fbe 100644 --- a/src/Uno.Extensions.Reactive.Tests/Messaging/Given_Messaging.cs +++ b/src/Uno.Extensions.Reactive.Tests/Messaging/Given_Messaging.cs @@ -261,6 +261,6 @@ private static IImmutableList Items(params int[] items) private static IImmutableList Items(params (int key, int version)[] items) => items.Select(i => new MyEntity(i.key, i.version)).ToImmutableList(); - [ImplicitKeyEquality(IsEnabled = false)] + [ImplicitKeys(IsEnabled = false)] private record MyEntity(int Key, int Version = 0); } diff --git a/src/Uno.Extensions.Reactive.Tests/Operators/Given_FeedToListFeedAdapter.cs b/src/Uno.Extensions.Reactive.Tests/Operators/Given_FeedToListFeedAdapter.cs index 0492c27318..6c0e602e6e 100644 --- a/src/Uno.Extensions.Reactive.Tests/Operators/Given_FeedToListFeedAdapter.cs +++ b/src/Uno.Extensions.Reactive.Tests/Operators/Given_FeedToListFeedAdapter.cs @@ -126,7 +126,7 @@ await result.Should().BeAsync(r => r public partial record MyKeyedRecord(int Id, int Version); - [ImplicitKeyEquality(IsEnabled = false)] + [ImplicitKeys(IsEnabled = false)] public partial record MyNotKeyedRecord(int Id, int Version); public partial class MyNotKeyedClass diff --git a/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.CoreRules.cs b/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.CoreRules.cs new file mode 100644 index 0000000000..9747f0c852 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.CoreRules.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.Extensions.Reactive.Operators; +using Uno.Extensions.Reactive.Testing; + +namespace Uno.Extensions.Reactive.Tests.Operators; + +partial class Given_ListFeedSelection : FeedTests +{ + [TestMethod] + public async Task When_ListFeedSelection_Then_CompliesToCoreRules() + { + var state = State.Value(this, () => new MyRecord(-42)); + var sut = FeedCoreRules + .Using(ListFeed.Async(async _ => ImmutableList.Create(new MyRecord(42)))) + .WhenListFeed(src => ListFeedSelection.Create(src, state, "sut")) + .Then_CompilesToCoreRules(CT); + } + + private record MyRecord(int Value); +} diff --git a/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.cs b/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.cs new file mode 100644 index 0000000000..9236d01ca8 --- /dev/null +++ b/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.cs @@ -0,0 +1,808 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.Extensions.Equality; +using Uno.Extensions.Reactive.Operators; +using Uno.Extensions.Reactive.Testing; + +namespace Uno.Extensions.Reactive.Tests.Operators; + +[TestClass] +public partial class Given_ListFeedSelection : FeedTests +{ + [TestMethod] + public async Task When_Single_With_InvalidInitial_And_UpdateSelectionState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 15); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Set(5, CT); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + } + + [TestMethod] + public async Task When_Single_With_InvalidInitial_And_UpdateSelectionStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 15); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Set(20, CT); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + ); + } + + [TestMethod] + public async Task When_Single_With_ValidInitial_And_UpdateSelectionState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 1); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Set(5, CT); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + } + + [TestMethod] + public async Task When_Single_With_ValidInitial_And_UpdateSelectionStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 1); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Set(15, CT); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Empty)) + ); + } + + [TestMethod] + public async Task When_Single_With_InvalidInitial_And_UpdateListState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 15).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(15, Error.No, Progress.Final) + .Message(5, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Single_With_InvalidInitial_And_UpdateListStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 15).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(20, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + ); + + await selection.Should().BeAsync(r => r + .Message(15, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Single_With_ValidInitial_And_UpdateListState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 1).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(1, Error.No, Progress.Final) + .Message(5, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Single_With_ValidInitial_And_UpdateListStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 1).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(15, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + ); + + await selection.Should().BeAsync(r => r + .Message(1, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Single_With_InvalidInitial_And_UpdateSutState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 15).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(15, Error.No, Progress.Final) + .Message(5, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Single_With_InvalidInitial_And_UpdateSutStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 15).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(20, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + ); + + await selection.Should().BeAsync(r => r + .Message(15, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Single_With_ValidInitial_And_UpdateSutState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 1).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(1, Error.No, Progress.Final) + .Message(5, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Single_With_ValidInitial_And_UpdateSutStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => 1).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(15, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + ); + + await selection.Should().BeAsync(r => r + .Message(1, Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_InvalidInitial_And_UpdateSelectionState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(15) as IImmutableList); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Update(_ => ImmutableList.Create(5), CT); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + } + + [TestMethod] + public async Task When_Multiple_With_InvalidInitial_And_UpdateSelectionStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(15) as IImmutableList); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Update(_ => ImmutableList.Create(20), CT); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + ); + } + + [TestMethod] + public async Task When_Multiple_With_ValidInitial_And_UpdateSelectionState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(1) as IImmutableList).Record(); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Feed.Update(_ => ImmutableList.Create(5), CT); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + } + + [TestMethod] + public async Task When_Multiple_With_ValidInitial_And_UpdateSelectionStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(1) as IImmutableList).Record(); + var list = ListFeed.Async(async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + await selection.Feed.Update(_ => ImmutableList.Create(15), CT); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Empty)) + ); + } + + [TestMethod] + public async Task When_Multiple_With_InvalidInitial_And_UpdateListState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(15) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(15), Error.No, Progress.Final) + .Message(Items.Some(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_InvalidInitial_And_UpdateListStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(15) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(20, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(15), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_ValidInitial_And_UpdateListState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(1) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(1), Error.No, Progress.Final) + .Message(Items.Some(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_ValidInitial_And_UpdateListStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(1) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(15, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(1), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_InvalidInitial_And_UpdateSutState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(15) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(15), Error.No, Progress.Final) + .Message(Items.Some(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_InvalidInitial_And_UpdateSutStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(15) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(20, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(10), Error.No, Progress.Final, Selection.Empty) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(15), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_ValidInitial_And_UpdateSutState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(1) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(5, CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(5))) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(1), Error.No, Progress.Final) + .Message(Items.Some(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_Multiple_With_ValidInitial_And_UpdateSutStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => ImmutableList.Create(1) as IImmutableList).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 10).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(15, CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(Items.Range(10), Error.No, Progress.Final, Selection.Items(1))) + ); + + await selection.Should().BeAsync(r => r + .Message(Items.Some(1), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_InvalidInitial_And_UpdateSelectionState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(15)); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListFeed.Async(async _ => items); + var sut = list.Selection(selection, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + await selection.Update(_ => new MyAggregateRoot(5), CT); + + await sut.Should().BeAsync(r => r + .Message(items, Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection) + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(5)))) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_InvalidInitial_And_UpdateSelectionStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(15)); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListFeed.Async(async _ => items); + var sut = list.Selection(selection, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + await selection.Update(_ => new MyAggregateRoot(20), CT); + + await sut.Should().BeAsync(r => r + .Message(items, Error.No, Progress.Final, Selection.Empty) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_ValidInitial_And_UpdateSelectionState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(1)); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListFeed.Async(async _ => items); + var sut = list.Selection(selection, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + await selection.Update(_ => new MyAggregateRoot(5), CT); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(1)))) + .Message(m => m + .Changed(Changed.Selection) + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(5)))) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_ValidInitial_And_UpdateSelectionStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(1)); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListFeed.Async(async _ => items); + var sut = list.Selection(selection, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + await selection.Update(_ => new MyAggregateRoot(15), CT); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(1)))) + .Message(m => m + .Changed(Changed.Selection) + .Current(items, Error.No, Progress.Final, Selection.Empty)) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_InvalidInitial_And_UpdateListState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(15)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(new MyEntity(5), CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(items, Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(5)))) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(15), Error.No, Progress.Final) + .Message(new MyAggregateRoot(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_InvalidInitial_And_UpdateListStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(15)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(new MyEntity(20), CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(items, Error.No, Progress.Final, Selection.Empty) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(15), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_ValidInitial_And_UpdateListState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(1)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(new MyEntity(5), CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(1)))) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(5)))) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(1), Error.No, Progress.Final) + .Message(new MyAggregateRoot(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_ValidInitial_And_UpdateListStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(1)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await list.TrySelectAsync(new MyEntity(15), CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(1)))) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(1), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_InvalidInitial_And_UpdateSutState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(15)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(new MyEntity(5), CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(items, Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(5)))) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(15), Error.No, Progress.Final) + .Message(new MyAggregateRoot(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_InvalidInitial_And_UpdateSutStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(15)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(new MyEntity(20), CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(items, Error.No, Progress.Final, Selection.Empty) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(15), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_ValidInitial_And_UpdateSutState_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(1)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(new MyEntity(5), CT)).Should().Be(true); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(1)))) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(5)))) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(1), Error.No, Progress.Final) + .Message(new MyAggregateRoot(5), Error.No, Progress.Final) + ); + } + + [TestMethod] + public async Task When_ProjectedSingle_With_ValidInitial_And_UpdateSutStateInvalid_Then_ListStateUpdated() + { + var selection = State.Value(this, () => new MyAggregateRoot(1)).Record(); + var items = Enumerable.Range(0, 10).Select(i => new MyEntity(i)).ToImmutableList(); + var list = ListState.Async(this, async _ => items); + var sut = list.Selection(selection.Feed, e => e.MyEntityKey).Record(); + + await sut.WaitForMessages(1); + + (await sut.Feed.TrySelectAsync(new MyEntity(15), CT)).Should().Be(false); + + await sut.Should().BeAsync(r => r + .Message(m => m + .Current(items, Error.No, Progress.Final, Selection.Items(new MyEntity(1)))) + ); + + await selection.Should().BeAsync(r => r + .Message(new MyAggregateRoot(1), Error.No, Progress.Final) + ); + } + + public partial record MyEntity(int Key) : IKeyed; + + public partial record MyAggregateRoot + { + public MyAggregateRoot() + { + } + + public MyAggregateRoot(int key) + { + MyEntityKey = key; + } + + public int? MyEntityKey { get; init; } + } +} diff --git a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj index dceeb9dbdf..1f04763e21 100644 --- a/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj +++ b/src/Uno.Extensions.Reactive.Tests/Uno.Extensions.Reactive.Tests.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/src/Uno.Extensions.Reactive.UI/AssemblyInfo.cs b/src/Uno.Extensions.Reactive.UI/AssemblyInfo.cs index bd1267f56b..656d329df8 100644 --- a/src/Uno.Extensions.Reactive.UI/AssemblyInfo.cs +++ b/src/Uno.Extensions.Reactive.UI/AssemblyInfo.cs @@ -3,4 +3,4 @@ [assembly: InternalsVisibleTo("Uno.Extensions.Reactive.Tests")] -[assembly: ImplicitKeyEquality(IsEnabled = false)] +[assembly: ImplicitKeys(IsEnabled = false)] diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/BindableListFeed.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/BindableListFeed.cs index a74e859a41..46b4be45ef 100644 --- a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/BindableListFeed.cs +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/BindableListFeed.cs @@ -35,6 +35,9 @@ public BindableListFeed(string propertyName, IListFeed source, SourceContext _items = CreateBindableCollection(_state, ctx); } + /// + SourceContext IState.Context => _state.Context; + /// public string PropertyName { get; } @@ -85,7 +88,8 @@ private static BindableCollection CreateBindableCollection(IListState state, var requests = new RequestSource(); var pagination = new PaginationService(LoadMore); - var services = new SingletonServiceProvider(pagination); + var selection = new SelectionService(SetSelected); + var services = new SingletonServiceProvider(pagination, selection); var collection = BindableCollection.Create( services: services, itemComparer: ListFeed.DefaultComparer); @@ -108,9 +112,10 @@ private static BindableCollection CreateBindableCollection(IListState state, return; } + var items = default(IImmutableList); if (msg.Changes.Contains(MessageAxis.Data, out var changes)) { - var items = msg.Current.Data.SomeOrDefault(ImmutableList.Empty); + items = msg.Current.Data.SomeOrDefault(ImmutableList.Empty); currentCount = items.Count; collection.Switch(new ImmutableObservableCollection(items), changes as CollectionChangeSet); @@ -130,6 +135,15 @@ private static BindableCollection CreateBindableCollection(IListState state, { pageTokens.Received(page.Tokens); } + + if (msg.Changes.Contains(MessageAxis.Selection) && msg.Current.GetSelectionInfo() is {} selectionInfo) + { + var selectedIndex = selectionInfo.IsEmpty + ? null + : selectionInfo.GetSelectedIndex(items ??= msg.Current.Data.SomeOrDefault(ImmutableList.Empty), failIfOutOfRange: false, failIfMultiple: false); + + selection.SelectFromModel(selectedIndex); + } }, ctx.Token); @@ -144,6 +158,14 @@ async ValueTask LoadMore(uint desiredCount, CancellationToken ct) return (uint)Math.Max(0, resultCount - originalCount); } + async ValueTask SetSelected(uint? selectedIndex, CancellationToken ct) + { + var info = selectedIndex is null + ? SelectionInfo.Empty + : SelectionInfo.Single(selectedIndex.Value); + await state.UpdateMessage(msg => msg.Selected(info), ct); + } + return collection; } diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/BranchStrategy.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/BranchStrategy.cs index 612ae420f6..de5ed5ed2c 100644 --- a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/BranchStrategy.cs +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/BranchStrategy.cs @@ -70,7 +70,7 @@ public BranchStrategy(DataStructure dataStructure, uint dataLevel, CollectionAna * The issue is that the first event makes **absoluteley no sense** (the index is the index of the group while on root we usually deals with item indexes). * * As currently we will do either some add / remove in groups, either a **FULL** reset on the whole grouped collection, - * we admit this as limitiation and we won't support this. + * we admit this as limitation and we won't support this. * * Behavior of full reset in grouped collection is validated by the test 'Given_BindableCollection.When_Grouped_SwitchWithReset' * @@ -84,7 +84,7 @@ public BranchStrategy(DataStructure dataStructure, uint dataLevel, CollectionAna // Init the flat tracking view (ie. the flatten view of the groups that can be consumed directly by the ICollectionView properties) var flatCollectionChanged = new FlatCollectionChangedFacet(() => view ?? throw new InvalidOperationException("The owner provider must be resolved lazily!")); - var flatSelectionFacet = new SelectionFacet(() => view ?? throw new InvalidOperationException("The owner provider must be resolved lazily!")); + var flatSelectionFacet = new SelectionFacet(source, () => view ?? throw new InvalidOperationException("The owner provider must be resolved lazily!")); var flatPaginationFacet = new PaginationFacet(source, extendedPropertiesFacet); // Init the groups tracking diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/DataLayer.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/DataLayer.cs index 789f84f0b8..3c574b1617 100644 --- a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/DataLayer.cs +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/DataLayer.cs @@ -34,6 +34,8 @@ internal sealed class DataLayer : ILayerHolder, IBindableCollectionViewSource, I public IBindableCollectionViewSource? Parent => _parent; + public IDispatcherInternal? Dispatcher => _context; + /// /// Creates a holder for the root layer of data /// diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/LeafStrategy.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/LeafStrategy.cs index 00fba386cb..957fd406f9 100644 --- a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/LeafStrategy.cs +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Data/LeafStrategy.cs @@ -35,7 +35,7 @@ public LeafStrategy(DataStructure dataStructure, CollectionAnalyzer diffAnalyzer if (_isRoot) // I.e. Collection is not grouped { var paginationFacet = new PaginationFacet(source, extendedPropertiesFacet); - var selectionFacet = new SelectionFacet(() => view ?? throw new InvalidOperationException("The owner provider must be resolved lazily!")); + var selectionFacet = new SelectionFacet(source, () => view ?? throw new InvalidOperationException("The owner provider must be resolved lazily!")); view = new BasicView(collectionFacet, collectionChangedFacet, extendedPropertiesFacet, selectionFacet, paginationFacet); diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Facets/SelectionFacet.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Facets/SelectionFacet.cs index 1fe0e738ec..0a23cc469d 100644 --- a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Facets/SelectionFacet.cs +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Facets/SelectionFacet.cs @@ -1,15 +1,17 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using Windows.Foundation.Collections; - +using Uno.Extensions.Reactive.Bindings.Collections.Services; +using Uno.Extensions.Reactive.Dispatching; namespace Uno.Extensions.Reactive.Bindings.Collections._BindableCollection.Facets { /// /// The selection facet of the ICollectionView /// - internal class SelectionFacet + internal class SelectionFacet : IDisposable { /* * Note: The selection is sync beetween the ListView and the CollectionView only when SelectionMode is 'Single' @@ -23,37 +25,90 @@ internal class SelectionFacet * */ + private readonly EventRegistrationTokenTable _currentChanged = new(); + private readonly EventRegistrationTokenTable _currentChanging = new(); + private readonly ISelectionService? _service; private readonly Lazy> _target; + private readonly IDispatcherInternal? _dispatcher; - public SelectionFacet(Func> target) + private bool _isInit; + + public SelectionFacet(IBindableCollectionViewSource source, Func> target) { + _service = source.GetService(typeof(ISelectionService)) as ISelectionService; _target = new Lazy>(target, LazyThreadSafetyMode.None); + _dispatcher = source.Dispatcher; } - private readonly EventRegistrationTokenTable _currentChanged = new(); - private readonly EventRegistrationTokenTable _currentChanging = new(); + private void Init() + { + // Note: As the OnServiceStateChanged might cause a SetCurrent, which will try to resolve the _target, + // we must keep this Init lazy. + + if (_isInit) + { + return; + } + _isInit = true; + + if (_service is not null) + { + _service.StateChanged += OnServiceStateChanged; + OnServiceStateChanged(_service, EventArgs.Empty); + } + } + + private void OnServiceStateChanged(object? snd, EventArgs args) + { + if (_dispatcher is null or { HasThreadAccess: true }) + { + MoveCurrentToPosition((int?)_service!.SelectedIndex ?? -1); + } + else + { + _dispatcher.TryEnqueue(() => MoveCurrentToPosition((int?)_service!.SelectedIndex ?? -1)); + } + } public EventRegistrationToken AddCurrentChangedHandler(CurrentChangedEventHandler value) - => _currentChanged.AddEventHandler(value); + { + Init(); + return _currentChanged.AddEventHandler(value); + } #if USE_EVENT_TOKEN public void RemoveCurrentChangedHandler(EventRegistrationToken value) - => _currentChanged.RemoveEventHandler(value); + { + Init(); + _currentChanged.RemoveEventHandler(value); + } #endif public void RemoveCurrentChangedHandler(CurrentChangedEventHandler value) - => _currentChanged.RemoveEventHandler(value); + { + Init(); + _currentChanged.RemoveEventHandler(value); + } public EventRegistrationToken AddCurrentChangingHandler(CurrentChangingEventHandler value) - => _currentChanging.AddEventHandler(value); + { + Init(); + return _currentChanging.AddEventHandler(value); + } #if USE_EVENT_TOKEN public void RemoveCurrentChangingHandler(EventRegistrationToken value) - => _currentChanging.RemoveEventHandler(value); + { + Init(); + _currentChanging.RemoveEventHandler(value); + } #endif public void RemoveCurrentChangingHandler(CurrentChangingEventHandler value) - => _currentChanging.RemoveEventHandler(value); + { + Init(); + _currentChanging.RemoveEventHandler(value); + } public object? CurrentItem { get; private set; } @@ -61,37 +116,19 @@ public void RemoveCurrentChangingHandler(CurrentChangingEventHandler value) public bool IsCurrentAfterLast => false; - public bool IsCurrentBeforeFirst => CurrentPosition < 0; - - private bool SetCurrent(int index, object? value, bool isCancelable = true) + public bool IsCurrentBeforeFirst { - if (CurrentPosition == index && CurrentItem == value) + get { - // Current is already update to date, do not raise events for nothing! - return true; - } - - var changing = _currentChanging.InvocationList; - if (changing != null) - { - var args = new CurrentChangingEventArgs(isCancelable); - changing.Invoke(this, args); - if (isCancelable && args.Cancel) - { - return false; - } + Init(); + return CurrentPosition < 0; } - - CurrentPosition = index; - CurrentItem = value; - - _currentChanged.InvocationList?.Invoke(this, CurrentItem); - - return true; } public bool MoveCurrentTo(object item) { + Init(); + if (item == null) { return SetCurrent(-1, null); @@ -106,6 +143,8 @@ public bool MoveCurrentTo(object item) public bool MoveCurrentToPosition(int index) { + Init(); + if (index < 0) { return SetCurrent(-1, null); @@ -116,12 +155,57 @@ public bool MoveCurrentToPosition(int index) } } - public bool MoveCurrentToFirst() => MoveCurrentToPosition(0); + public bool MoveCurrentToFirst() => MoveCurrentToPosition(0); // No needs to Init: are not using any Current*** + + public bool MoveCurrentToLast() => MoveCurrentToPosition(_target.Value.Count - 1); // No needs to Init: are not using any Current*** + + public bool MoveCurrentToNext() + { + Init(); + return CurrentPosition + 1 < _target.Value.Count && MoveCurrentToPosition(CurrentPosition + 1); + } + + public bool MoveCurrentToPrevious() + { + Init(); + return CurrentPosition > 0 && MoveCurrentToPosition(CurrentPosition - 1); + } + + private bool SetCurrent(int index, object? value, bool isCancelable = true) + { + if (CurrentPosition == index && EqualityComparer.Default.Equals(CurrentItem, value)) + { + // Current is already up to date, do not raise events for nothing! + return true; + } + + var changing = _currentChanging.InvocationList; + if (changing != null) + { + var args = new CurrentChangingEventArgs(isCancelable); + changing.Invoke(this, args); + if (isCancelable && args.Cancel) + { + return false; + } + } - public bool MoveCurrentToLast() => MoveCurrentToPosition(_target.Value.Count - 1); + CurrentPosition = index; + CurrentItem = value; + + _service?.SelectFromView(index); - public bool MoveCurrentToNext() => CurrentPosition + 1 < _target.Value.Count && MoveCurrentToPosition(CurrentPosition + 1); + _currentChanged.InvocationList?.Invoke(this, CurrentItem); - public bool MoveCurrentToPrevious() => CurrentPosition > 0 && MoveCurrentToPosition(CurrentPosition - 1); + return true; + } + + public void Dispose() + { + if (_service is not null) + { + _service.StateChanged -= OnServiceStateChanged; + } + } } } diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/IBindableCollectionViewSource.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/IBindableCollectionViewSource.cs index 746bb30660..4f4fd4ab6a 100644 --- a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/IBindableCollectionViewSource.cs +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/IBindableCollectionViewSource.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Uno.Extensions.Reactive.Dispatching; namespace Uno.Extensions.Reactive.Bindings.Collections._BindableCollection; @@ -14,5 +15,17 @@ internal interface IBindableCollectionViewSource : IServiceProvider event EventHandler CurrentSourceChanged; + /// + /// Gets the dispatcher to which this collection view source belongs. + /// + /// This can be null if this collection belongs to background threads (uncommon). + IDispatcherInternal? Dispatcher { get; } + + /// + /// Get a specific facet of this collection. + /// + /// Type of the facet + /// The requested facet. + /// If the requested facet is not available on this collection. TFacet GetFacet(); } diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Services/ISelectionService.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Services/ISelectionService.cs new file mode 100644 index 0000000000..2fe1469d1f --- /dev/null +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Services/ISelectionService.cs @@ -0,0 +1,29 @@ +using System; +using System.Linq; + +namespace Uno.Extensions.Reactive.Bindings.Collections.Services; + +internal interface ISelectionService +{ + /// + /// Event raise when any properties of the service has changed + /// + event EventHandler StateChanged; + + /// + /// Get the index of the primary selected item. + /// + uint? SelectedIndex { get; } + + /// + /// Sets the **single** selected item. + /// + /// The index of the selected item or null to clear selection. + void SelectFromModel(uint? index); + + /// + /// Sets the **single** selected item. + /// + /// The index of the selected item or -1 to clear selection. + void SelectFromView(int index); +} diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Services/SelectionService.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Services/SelectionService.cs new file mode 100644 index 0000000000..06806f4e79 --- /dev/null +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Collections/Services/SelectionService.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Uno.Extensions.Reactive.Bindings.Collections.Services; + +/// +/// A simple selection service which acts as a push-pull adapter between source and the . +/// +internal sealed class SelectionService : ISelectionService, IDisposable +{ + private readonly AsyncAction _setSelectionFromView; + + private CancellationTokenSource? _setSelectionFromViewToken; + private uint? _selectedIndex; + private bool _isDisposed; + + /// + public event EventHandler? StateChanged; + + + public SelectionService(AsyncAction setSelectionFromView) + { + _setSelectionFromView = setSelectionFromView; + } + + /// + public uint? SelectedIndex + { + get => _selectedIndex; + private set + { + if (_selectedIndex != value) + { + _selectedIndex = value; + StateChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + /// + public void SelectFromModel(uint? index) + => SelectedIndex = index; + + /// + public void SelectFromView(int index) + { + var selectedIndex = SelectedIndex = index > 0 ? (uint?)index : null; + + if (_isDisposed) + { + return; + } + + var ct = new CancellationTokenSource(); + Interlocked.Exchange(ref _setSelectionFromViewToken, ct)?.Cancel(); + + if (_isDisposed) + { + ct.Cancel(); + return; + } + + Task.Run(() => _setSelectionFromView(selectedIndex, ct.Token), ct.Token); + } + + /// + public void Dispose() + { + _isDisposed = true; + _setSelectionFromViewToken?.Cancel(); + } +} diff --git a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Input.cs b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Input.cs index 5d86287e63..61f988dc82 100644 --- a/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Input.cs +++ b/src/Uno.Extensions.Reactive.UI/Presentation/Bindings/Input.cs @@ -11,6 +11,9 @@ internal sealed class Input : IInput { private readonly IState _state; + /// + SourceContext IState.Context => _state.Context; + public string PropertyName { get; } public Input(string propertyName, IState state) diff --git a/src/Uno.Extensions.Reactive/Core/Axes/MessageAxes.cs b/src/Uno.Extensions.Reactive/Core/Axes/MessageAxes.cs index 35219246cf..169253fe58 100644 --- a/src/Uno.Extensions.Reactive/Core/Axes/MessageAxes.cs +++ b/src/Uno.Extensions.Reactive/Core/Axes/MessageAxes.cs @@ -35,6 +35,16 @@ public static class MessageAxes /// internal const string Pagination = nameof(Pagination); + /// + /// Name of the selection axis. + /// + internal const string Selection = nameof(Selection); + + /// + /// Name of the axe used to de-bounce data bound values + /// + internal const string SelectionSource = nameof(SelectionSource); + /// /// Name of the axe used to de-bounce data bound values /// diff --git a/src/Uno.Extensions.Reactive/Core/Axes/MessageAxis.cs b/src/Uno.Extensions.Reactive/Core/Axes/MessageAxis.cs index 4f814f7d88..0cfbb52a3e 100644 --- a/src/Uno.Extensions.Reactive/Core/Axes/MessageAxis.cs +++ b/src/Uno.Extensions.Reactive/Core/Axes/MessageAxis.cs @@ -50,6 +50,15 @@ public abstract class MessageAxis : IEquatable /// internal static MessageAxis Pagination => new(MessageAxes.Pagination, PaginationInfo.Aggregate); + /// + /// For a refreshable source, this axis contains information about the version of this source. + /// + /// + /// This is expected to be full-filled only by "source" feed that are refreshable, + /// not the sources feed built from a stream of data nor operators. + /// + internal static MessageAxis Selection => new(MessageAxes.Selection, SelectionInfo.Aggregate); + internal MessageAxis(string identifier) { Identifier = identifier; diff --git a/src/Uno.Extensions.Reactive/Core/Axes/MessageAxisExtensions.cs b/src/Uno.Extensions.Reactive/Core/Axes/MessageAxisExtensions.cs index 03a524d225..cc6c83adc7 100644 --- a/src/Uno.Extensions.Reactive/Core/Axes/MessageAxisExtensions.cs +++ b/src/Uno.Extensions.Reactive/Core/Axes/MessageAxisExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics.Contracts; using System.Linq; @@ -51,8 +52,7 @@ public static Option GetData(this MessageEntry entry) /// The builder. /// The value to set. /// The for fluent building. - public static TBuilder Data(this TBuilder builder, T value) - where TBuilder : IMessageBuilder + public static MessageBuilder Data(this MessageBuilder builder, T value) => builder.Data((Option)value); /// @@ -60,11 +60,27 @@ public static TBuilder Data(this TBuilder builder, T value) /// /// The builder. /// The value to set. - /// The changes made from the previous value /// The for fluent building. - internal static TBuilder Data(this TBuilder builder, T value, IChangeSet? changeSet) - where TBuilder : IMessageBuilder - => builder.Data((Option)value, changeSet); + public static MessageBuilder Data(this MessageBuilder builder, TResult value) + => builder.Data((Option)value); + + /// + /// Sets the data of an + /// + /// The builder. + /// The value to set. + /// The for fluent building. + public static MessageBuilder> Data(this MessageBuilder> builder, ImmutableList value) + => builder.Data((Option>)value); + + /// + /// Sets the data of an + /// + /// The builder. + /// The value to set. + /// The for fluent building. + public static MessageBuilder> Data(this MessageBuilder> builder, ImmutableList value) + => builder.Data((Option>)value); /// /// Sets the data of an @@ -72,10 +88,36 @@ internal static TBuilder Data(this TBuilder builder, T value, IChan /// The builder. /// The data to set. /// The for fluent building. - public static TBuilder Data(this TBuilder builder, Option data) - where TBuilder : IMessageBuilder + public static MessageBuilder Data(this MessageBuilder builder, Option data) { - builder.Set(MessageAxis.Data, MessageAxis.Data.ToMessageValue(data)); + builder.Set(MessageAxis.Data, MessageAxis.Data.ToMessageValue(data), null); + + return builder; + } + + /// + /// Sets the data of an + /// + /// The builder. + /// The data to set. + /// The for fluent building. + public static MessageBuilder Data(this MessageBuilder builder, Option data) + { + builder.Set(MessageAxis.Data, MessageAxis.Data.ToMessageValue(data), null); + + return builder; + } + + /// + /// Sets the data of an + /// + /// The builder. + /// The data to set. + /// The changes made from the previous value + /// The for fluent building. + internal static MessageBuilder Data(this MessageBuilder builder, Option data, IChangeSet? changeSet) + { + builder.Set(MessageAxis.Data, MessageAxis.Data.ToMessageValue(data), changeSet); return builder; } @@ -87,8 +129,7 @@ public static TBuilder Data(this TBuilder builder, Option data) /// The data to set. /// The changes made from the previous value /// The for fluent building. - internal static TBuilder Data(this TBuilder builder, Option data, IChangeSet? changeSet) - where TBuilder : IMessageBuilder + internal static MessageBuilder Data(this MessageBuilder builder, Option data, IChangeSet? changeSet) { builder.Set(MessageAxis.Data, MessageAxis.Data.ToMessageValue(data), changeSet); @@ -157,6 +198,31 @@ public static TBuilder Error(this TBuilder builder, Exception? error) where TBuilder : IMessageBuilder => builder.Set(MessageAxis.Error, error); + /// + /// Gets the progress of an + /// + /// The entry. + /// The progress. + /// Use instead. + [Pure] + [EditorBrowsable(EditorBrowsableState.Never)] // Use IMessageEntry.Progress instead + public static bool GetProgress(this IMessageEntry entry) + => MessageAxis.Progress.FromMessageValue(entry[MessageAxis.Progress]); + + /// + /// Sets the progress of an + /// + /// The builder. + /// The progress to set. + /// The for fluent building. + public static TBuilder IsTransient(this TBuilder builder, bool isTransient) + where TBuilder : IMessageBuilder + { + builder.Set(MessageAxis.Progress, MessageAxis.Progress.ToMessageValue(isTransient)); + + return builder; + } + /// /// Sets the refresh info of an /// @@ -171,8 +237,7 @@ internal static TBuilder Refreshed(this TBuilder builder, TokenSet /// /// The entry. - /// The progress. - /// Use instead. + /// The pagination info. [Pure] internal static PaginationInfo? GetPaginationInfo(this IMessageEntry entry) => entry.Get(MessageAxis.Pagination); @@ -188,30 +253,55 @@ internal static TBuilder Paginated(this TBuilder builder, PaginationIn => builder.Set(MessageAxis.Pagination, page); /// - /// Gets the progress of an + /// Gets the selection info of an /// /// The entry. - /// The progress. - /// Use instead. + /// The selection info. [Pure] - [EditorBrowsable(EditorBrowsableState.Never)] // Use IMessageEntry.Progress instead - public static bool GetProgress(this IMessageEntry entry) - => MessageAxis.Progress.FromMessageValue(entry[MessageAxis.Progress]); + public static SelectionInfo? GetSelectionInfo(this IMessageEntry entry) + => entry.Get(MessageAxis.Selection); + /// - /// Sets the progress of an + /// Gets the selected items of a list /// - /// The builder. - /// The progress to set. - /// The for fluent building. - public static TBuilder IsTransient(this TBuilder builder, bool isTransient) - where TBuilder : IMessageBuilder + /// The entry. + /// The selected items or an empty collection if none. + [Pure] + public static IImmutableList GetSelectedItems(this MessageEntry> entry) { - builder.Set(MessageAxis.Progress, MessageAxis.Progress.ToMessageValue(isTransient)); + var items = entry.Data.SomeOrDefault(ImmutableList.Empty); + var info = entry.Get(MessageAxis.Selection) ?? SelectionInfo.Empty; - return builder; + return info.GetSelectedItems(items); + } + + /// + /// Gets the **first** selected item of a list if any. + /// + /// If more than one items are selected, this will return only the first selected item. + /// The entry. + /// The selected item if any. + [Pure] + public static T? GetSelectedItem(this MessageEntry> entry) + where T : notnull + { + var items = entry.Data.SomeOrDefault(ImmutableList.Empty); + var info = entry.Get(MessageAxis.Selection) ?? SelectionInfo.Empty; + + return info.GetSelectedItem(items); } + /// + /// Sets the selection info of an + /// + /// The builder. + /// The selection info. + /// The for fluent building. + public static TBuilder Selected(this TBuilder builder, SelectionInfo selection) + where TBuilder : IMessageBuilder + => builder.Set(MessageAxis.Selection, selection); + /// /// Fluently applies an additional configuration action on a message builder. /// diff --git a/src/Uno.Extensions.Reactive/Core/Axes/SelectionIndexRange.cs b/src/Uno.Extensions.Reactive/Core/Axes/SelectionIndexRange.cs new file mode 100644 index 0000000000..31e9ed3c8e --- /dev/null +++ b/src/Uno.Extensions.Reactive/Core/Axes/SelectionIndexRange.cs @@ -0,0 +1,21 @@ +using System; +using System.Linq; + +namespace Uno.Extensions.Reactive; + +/// +/// An index based range of selected items. +/// +/// The index of the first selected item. +/// The count of selected items. +public sealed record SelectionIndexRange(uint FirstIndex, uint Length) +{ + /// + /// Gets the index of the last selected item. + /// + public uint LastIndex { get; } = FirstIndex + Length - 1; + + /// + public override string ToString() + => $"[{FirstIndex}, {LastIndex}["; +}; diff --git a/src/Uno.Extensions.Reactive/Core/Axes/SelectionInfo.cs b/src/Uno.Extensions.Reactive/Core/Axes/SelectionInfo.cs new file mode 100644 index 0000000000..63df7c0243 --- /dev/null +++ b/src/Uno.Extensions.Reactive/Core/Axes/SelectionInfo.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Uno.Extensions.Reactive; + +/// +/// Contains information about selected items of a collection. +/// +public sealed record SelectionInfo +{ + /// + /// The collection of the selected ranges. + /// + public IReadOnlyList Ranges { get; } + + // The ctor is private so we can assume that we don't have any empty range + // (So Ranges.Count == 0 means empty and Ranges.Count > 1 means multiple!) + // This also ensure that we don't have any overlap. + private SelectionInfo(IReadOnlyList ranges) + { + Ranges = ranges; + } + + /// + /// Gets an instance containing no selected item. + /// + public static SelectionInfo Empty { get; } = new(ImmutableList.Empty); + + /// + /// Creates a new instance containing only a single selected item. + /// + /// + /// + public static SelectionInfo Single(uint index) => new(new[] { new SelectionIndexRange(index, 1) }); + + internal static bool TryCreateSingle( + IImmutableList items, + T selectedItem, + [NotNullWhen(true)] out SelectionInfo? selectionInfo, + IEqualityComparer? comparer = null) + { + if (items is null or { Count: 0 }) + { + selectionInfo = default; + return false; + } + + var selectedIndex = comparer is null ? items.IndexOf(selectedItem) : items.IndexOf(selectedItem, comparer); + if (selectedIndex < 0) + { + selectionInfo = default; + return false; + } + + selectionInfo = Single((uint)selectedIndex); + return true; + } + + internal static bool TryCreateMultiple( + IImmutableList items, + IImmutableList selectedItems, + [NotNullWhen(true)] out SelectionInfo? selectionInfo, + IEqualityComparer? comparer = null) + { + if (selectedItems is null or { Count: 0 }) + { + selectionInfo = Empty; + return true; + } + + if (items is null or { Count: 0 }) + { + selectionInfo = default; + return false; + } + + var indexOf = comparer is null ? (Func)items.IndexOf : item => items.IndexOf(item, comparer); + var selectedIndexes = selectedItems.Select(indexOf).ToList(); + selectedIndexes.Sort(); + if (selectedIndexes[0] < 0) + { + selectionInfo = default; + return false; + } + + var ranges = new List(); + (int start, uint count) range = (-1, 0); + foreach (var index in selectedIndexes) + { + if (range is {start: -1}) + { + range = (index, 1); + } + else if (range.start + range.count == index) + { + range.count++; + } + else + { + ranges.Add(new SelectionIndexRange((uint)range.start, range.count)); + range = (index, 1); + } + } + + // The current 'range' cannot be empty here since the selectedItems has at least one element! + ranges.Add(new SelectionIndexRange((uint)range.start, range.count)); + + selectionInfo = new SelectionInfo(ranges); + return true; + } + + /// + /// Indicates if there is any selected item or not. + /// + public bool IsEmpty => Ranges is null or { Count: 0 }; + + /// + /// Gets total number of selected items. + /// + public uint Count => (uint?)Ranges?.Sum(range => range.Length) ?? 0; + + // Note: There no way to properly aggregate selection on multiple sources. + internal static SelectionInfo Aggregate(IReadOnlyCollection values) + => Empty; + + internal bool TryGetSelectedItem(IImmutableList items, [NotNullWhen(true)] out T? selectedItem, bool failIfOutOfRange = true, bool failIfMultiple = false) + { + if (TryGetSelectedIndex(items, out var selectedIndex, failIfOutOfRange, failIfMultiple)) + { + selectedItem = items[(int)selectedIndex]!; + return true; + } + + selectedItem = default!; + return true; + } + + internal bool TryGetSelectedIndex(IImmutableList items, [NotNullWhen(true)] out uint? selectedIndex, bool failIfOutOfRange = true, bool failIfMultiple = false) + { + if (IsEmpty) + { + selectedIndex = default; + return false; + } + + if (failIfMultiple && (Ranges.Count > 0 || Ranges[0].Length > 1)) + { + throw new InvalidOperationException("Multiple selected items."); + } + + var index = Ranges[0].FirstIndex; + if (index >= items.Count) + { + if (failIfOutOfRange) + { + throw new IndexOutOfRangeException($"This selection info starts with index {index}, but the provided collection has only {items.Count} items."); + } + + selectedIndex = default!; + return false; + } + + selectedIndex = index; + return true; + } + + internal T? GetSelectedItem(IImmutableList items, bool failIfOutOfRange = true, bool failIfMultiple = false) + => TryGetSelectedItem(items, out var selectedItem, failIfOutOfRange, failIfMultiple) ? selectedItem : default; + + internal uint? GetSelectedIndex(IImmutableList items, bool failIfOutOfRange = true, bool failIfMultiple = false) + => TryGetSelectedIndex(items, out var selectedIndex, failIfOutOfRange, failIfMultiple) ? selectedIndex : default; + + internal IImmutableList GetSelectedItems(IImmutableList items, bool failIfOutOfRange = true) + { + if (IsEmpty) + { + return ImmutableList.Empty; + } + + // TODO: Create a sparse collection for that + var selectedItems = ImmutableList.CreateBuilder(); + foreach (var range in Ranges) + { + if (failIfOutOfRange && range.LastIndex >= items.Count) + { + throw new IndexOutOfRangeException($"This selection includes items from {range.FirstIndex} to {range.LastIndex} (included), but the provided collection has only {items.Count} items."); + } + selectedItems.AddRange(items.Skip((int)range.FirstIndex).Take((int)range.Length)); + } + + return selectedItems.ToImmutable(); + } + + /// + public override string ToString() + => IsEmpty + ? "--Empty--" + : string.Join(" & ", Ranges); +} diff --git a/src/Uno.Extensions.Reactive/Core/IListState.cs b/src/Uno.Extensions.Reactive/Core/IListState.cs index 48104739a4..fdbf9590da 100644 --- a/src/Uno.Extensions.Reactive/Core/IListState.cs +++ b/src/Uno.Extensions.Reactive/Core/IListState.cs @@ -12,7 +12,7 @@ namespace Uno.Extensions.Reactive; /// 2. can be updated /// /// The type of the items in the list. -public interface IListState : IListFeed, IAsyncDisposable +public interface IListState : IListFeed, IState { /// /// Updates the current internal message. diff --git a/src/Uno.Extensions.Reactive/Core/IState.T.cs b/src/Uno.Extensions.Reactive/Core/IState.T.cs new file mode 100644 index 0000000000..36abfeb455 --- /dev/null +++ b/src/Uno.Extensions.Reactive/Core/IState.T.cs @@ -0,0 +1,24 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Uno.Extensions.Reactive; + +/// +/// An that is **state full** which:
+/// 1. replays the internal current data if any at the beginning of the enumeration;
+/// 2. can be updated +///
+/// The type of the value. +public interface IState : IFeed, IState +{ + /// + /// Updates the current internal message. + /// + /// The update method to apply to the current message. + /// A cancellation to cancel the async operation. + /// A ValueTask to track the async update. + /// This is the raw way to update a state, you should consider using the method instead. + ValueTask UpdateMessage(Action> updater, CancellationToken ct); +} diff --git a/src/Uno.Extensions.Reactive/Core/IState.cs b/src/Uno.Extensions.Reactive/Core/IState.cs index 6c453dc582..92b8766e2c 100644 --- a/src/Uno.Extensions.Reactive/Core/IState.cs +++ b/src/Uno.Extensions.Reactive/Core/IState.cs @@ -1,25 +1,20 @@ using System; using System.ComponentModel; using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using Uno.Extensions.Reactive.Core; namespace Uno.Extensions.Reactive; /// -/// An that is **state full** which:
-/// 1. replays the internal current data if any at the beginning of the enumeration;
-/// 2. can be updated +/// The base interface for and . +/// This should not be used unless for type constraints which matches one of the generic types. ///
-/// The type of the value. -public interface IState : IFeed, IAsyncDisposable +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IState : IAsyncDisposable { /// - /// Updates the current internal message. + /// The context to which this state belong /// - /// The update method to apply to the current message. - /// A cancellation to cancel the async operation. - /// A ValueTask to track the async update. - /// This is the raw way to update a state, you should consider using the method instead. - ValueTask UpdateMessage(Action> updater, CancellationToken ct); + [EditorBrowsable(EditorBrowsableState.Never)] + SourceContext Context { get; } } diff --git a/src/Uno.Extensions.Reactive/Core/Internal/IStateStore.cs b/src/Uno.Extensions.Reactive/Core/Internal/IStateStore.cs index ab603d0d54..db115cbc05 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/IStateStore.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/IStateStore.cs @@ -39,7 +39,7 @@ FeedSubscription GetOrCreateSubscription(TSource source /// This store has been disposed. TState GetOrCreateState(TSource source, Func factory) where TSource : class - where TState : IStateImpl, IAsyncDisposable; + where TState : IState; /// /// Create a for a given value. @@ -51,5 +51,5 @@ TState GetOrCreateState(TSource source, FuncA new state initialized with given initial value /// This store has been disposed. TState CreateState(Option initialValue, Func, TState> factory) - where TState : IStateImpl, IAsyncDisposable; + where TState : IState; } diff --git a/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs b/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs index 65ff41a0df..cce2d7b7e4 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/SourceContext.cs @@ -263,7 +263,7 @@ public IAsyncEnumerable>> GetOrCreateSource(IListFe /// The state wrapping the given feed [EditorBrowsable(EditorBrowsableState.Advanced)] public IState GetOrCreateState(IFeed feed) - => GetOrCreateStateCore(feed); + => States.GetOrCreateState, IState>(feed, static (ctx, f) => new StateImpl(ctx, f)); /// /// Get or create a for a given list feed. @@ -273,7 +273,7 @@ public IState GetOrCreateState(IFeed feed) /// The list state wrapping the given list feed [EditorBrowsable(EditorBrowsableState.Advanced)] public IListState GetOrCreateListState(IListFeed feed) - => new ListStateImpl(GetOrCreateStateCore(feed.AsFeed())); + => States.GetOrCreateState, IListState>(feed, static (ctx, f) => new ListStateImpl((StateImpl>)ctx.GetOrCreateState(f.AsFeed()))); /// /// Get or create a for a given list feed. @@ -283,15 +283,12 @@ public IListState GetOrCreateListState(IListFeed feed) /// The list state wrapping the given list feed [EditorBrowsable(EditorBrowsableState.Advanced)] public IListState GetOrCreateListState(IFeed> feed) - => new ListStateImpl(GetOrCreateStateCore(feed)); - - private StateImpl GetOrCreateStateCore(IFeed feed) - => States.GetOrCreateState(feed, (ctx, f) => new StateImpl(ctx, f)); + => States.GetOrCreateState>, IListState>(feed, static (ctx, f) => new ListStateImpl((StateImpl>)ctx.GetOrCreateState(f))); // WARNING: DO NOT USE, this breaks the cache by providing a custom config! // We need to make those config "upgradable" in order to properly share the instances of State internal ListStateImpl DoNotUse_GetOrCreateListState(IListFeed feed, StateUpdateKind updatesKind) - => new ListStateImpl(States.GetOrCreateState(feed, (ctx, f) => new StateImpl>(ctx, f.AsFeed(), updatesKind: updatesKind))); + => States.GetOrCreateState(feed, /*static*/ (ctx, f) => new ListStateImpl(new StateImpl>(ctx, f.AsFeed(), updatesKind: updatesKind))); /// /// Create a for a given value. @@ -302,7 +299,7 @@ internal ListStateImpl DoNotUse_GetOrCreateListState(IListFeed feed, St /// This context has been disposed. [EditorBrowsable(EditorBrowsableState.Advanced)] public IState CreateState(Option initialValue) - => CreateStateCore(initialValue); + => States.CreateState>(initialValue, static (ctx, iv) => new StateImpl(ctx, iv)); /// /// Create a for a given value. @@ -313,10 +310,7 @@ public IState CreateState(Option initialValue) /// This context has been disposed. [EditorBrowsable(EditorBrowsableState.Advanced)] public IListState CreateListState(Option> initialValue) - => new ListStateImpl(CreateStateCore(initialValue)); - - private StateImpl CreateStateCore(Option initialValue) - => States.CreateState(initialValue, (ctx, iv) => new StateImpl(ctx, iv)); + => States.CreateState, IListState>(initialValue, static (ctx, iv) => new ListStateImpl((StateImpl>)ctx.CreateState(iv))); #endregion #region Requests diff --git a/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs b/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs index 2321aee872..c35ab1383d 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/StateImpl.cs @@ -25,7 +25,7 @@ internal sealed class StateImpl : IState, IFeed, IAsyncDisposable, ISta /// /// Gets the context to which this state belongs. /// - internal SourceContext Context { get; } + public SourceContext Context { get; } SourceContext IStateImpl.Context => Context; internal Message Current => _current; @@ -163,9 +163,12 @@ public ValueTask UpdateMessage(Action> updater, CancellationTo { if (_updates is not null) { + // WARNING: There is a MAJOR issue here: if updater fails, the error will be propagated into the output feed instead of the caller! + // This is acceptable for now as so far there is no way to reach this code from public API + // First we make sure that the UpdateFeed is active, so the update will be applied ^^ _innerEnumeration?.Enable(); - return _updates.Update(_ => true, updater, ct); + return _updates.Update((_, _) => true, (_, msg) => updater(new(msg.Get, ((IMessageBuilder)msg).Set)), ct); } else { diff --git a/src/Uno.Extensions.Reactive/Core/Internal/StateStore.None.cs b/src/Uno.Extensions.Reactive/Core/Internal/StateStore.None.cs index 62b6c7a169..6fe1d07840 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/StateStore.None.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/StateStore.None.cs @@ -19,12 +19,12 @@ public FeedSubscription GetOrCreateSubscription(TSource source) /// public TState GetOrCreateState(TSource source, Func factory) where TSource : class - where TState : IStateImpl, IAsyncDisposable + where TState : IState => throw new InvalidOperationException("Cannot create a state on SourceContext.None. " + SourceContext.NoneContextErrorDesc); /// public TState CreateState(Option initialValue, Func, TState> factory) - where TState : IStateImpl, IAsyncDisposable + where TState : IState => throw new InvalidOperationException("Cannot create a state on SourceContext.None. " + SourceContext.NoneContextErrorDesc); /// diff --git a/src/Uno.Extensions.Reactive/Core/Internal/StateStore.cs b/src/Uno.Extensions.Reactive/Core/Internal/StateStore.cs index 02c70026cf..c570cfd32f 100644 --- a/src/Uno.Extensions.Reactive/Core/Internal/StateStore.cs +++ b/src/Uno.Extensions.Reactive/Core/Internal/StateStore.cs @@ -63,7 +63,7 @@ public FeedSubscription GetOrCreateSubscription(TSource source) public TState GetOrCreateState(TSource source, Func factory) where TSource : class - where TState : IStateImpl, IAsyncDisposable + where TState : IState { var states = _states; if (states is null) @@ -93,7 +93,7 @@ public TState GetOrCreateState(TSource source, Func(Option initialValue, Func, TState> factory) - where TState : IStateImpl, IAsyncDisposable + where TState : IState { var states = _states; if (states is null) diff --git a/src/Uno.Extensions.Reactive/Core/ListFeed.Extensions.cs b/src/Uno.Extensions.Reactive/Core/ListFeed.Extensions.cs index 2fea3e0c81..035b048e30 100644 --- a/src/Uno.Extensions.Reactive/Core/ListFeed.Extensions.cs +++ b/src/Uno.Extensions.Reactive/Core/ListFeed.Extensions.cs @@ -201,4 +201,27 @@ public static IFeed> AsFeed( // Note: DO NOT unwrap FeedToListFeedAdapter, as it adds some behavior => AttachedProperty.GetOrCreate(source, typeof(TItem), (s, _) => new ListFeedToFeedAdapter(s)); #endregion + + /// + /// Gets the selected items of a list feed, or an empty collection if none. + /// + /// Type of the items of the list feed. + /// The source list feed to get selected items for. + /// A cancellation to cancel the async operation. + /// The selected items, or an empty collection if none. + [EditorBrowsable(EditorBrowsableState.Never)] + public static async ValueTask> GetSelectedItems(this IListFeed source, CancellationToken ct) + => (await source.Message(ct)).Current.GetSelectedItems(); + + /// + /// Gets the selected item of a list feed, or null if none. + /// + /// Type of the items of the list feed. + /// The source list feed to get selected items for. + /// A cancellation to cancel the async operation. + /// The selected item, or null if none. + [EditorBrowsable(EditorBrowsableState.Never)] + public static async ValueTask GetSelectedItem(this IListFeed source, CancellationToken ct) + where T : notnull + => (await source.Message(ct)).Current.GetSelectedItem(); } diff --git a/src/Uno.Extensions.Reactive/Core/ListFeed.cs b/src/Uno.Extensions.Reactive/Core/ListFeed.cs index 849c77820d..c5f0b0bfa9 100644 --- a/src/Uno.Extensions.Reactive/Core/ListFeed.cs +++ b/src/Uno.Extensions.Reactive/Core/ListFeed.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; +using Uno.Extensions.Edition; +using Uno.Extensions.Equality; using Uno.Extensions.Reactive.Operators; using Uno.Extensions.Reactive.Utils; @@ -32,6 +36,16 @@ public static IListFeed Create(Func Async(AsyncFunc> valueProvider, Signal? refresh = null) => Feed.Async(valueProvider, refresh).AsListFeed(); + /// + /// Creates a custom feed from an async method. + /// + /// The type of the value of the resulting feed. + /// The async method to use to load the value of the resulting feed. + /// A refresh trigger to reload the . + /// A feed that encapsulate the source. + public static IListFeed Async(AsyncFunc> valueProvider, Signal? refresh = null) + => Feed.Async(valueProvider, refresh).AsListFeed(); + /// /// Creates a custom feed from an async enumerable sequence of value. /// @@ -80,5 +94,129 @@ public static IFeed SelectAsync( AsyncFunc selector) => default!); */ + + /// + /// Creates a ListState from a ListFeed onto which the selected items is being synced with the provided external state. + /// + /// Type of the items of the list feed. + /// The source list feed. + /// The external state from and onto which the selection of the resulting list state is going to be synced. + /// For debug purposes, the name of this subscription. DO NOT provide anything here, let the compiler provide this. + /// For debug purposes, the name of this subscription. DO NOT provide anything here, let the compiler provide this. + /// A ListState from and onto which the selection is going to be synced. + public static IListState Selection( + this IListFeed source, + IState> selectionState, + [CallerMemberName] string caller = "", + [CallerLineNumber] int line = -1) + => AttachedProperty.GetOrCreate( + source, + (selectionState, caller, line), + static (src, args) => ListFeedSelection.Create(src, args.selectionState, $"Selection defined in {args.caller} at line {args.line}.")); + + /// + /// Creates a ListState from a ListFeed onto which the selected items is being synced with the provided external state. + /// + /// Type of the items of the list feed. + /// The source list feed. + /// The external state from and onto which the selection of the resulting list state is going to be synced. + /// For debug purposes, the name of this subscription. DO NOT provide anything here, let the compiler provide this. + /// For debug purposes, the name of this subscription. DO NOT provide anything here, let the compiler provide this. + /// A ListState from and onto which the selection is going to be synced. + public static IListState Selection( + this IListFeed source, + IState selectionState, + [CallerMemberName] string caller = "", + [CallerLineNumber] int line = -1) + => AttachedProperty.GetOrCreate( + source, + (selectionState, caller, line), + static (src, args) => ListFeedSelection.Create(src, args.selectionState, $"Selection defined in {args.caller} at line {args.line}.")); + + /// + /// Creates a ListState from a ListFeed onto which the selected item is being synced with the provided external state using projection. + /// + /// Type of the items of the list feed. + /// Type of the key of items of the list feed. + /// Type of the entity which hold the selection. + /// The source list feed. + /// The external state from and onto which the selection of the resulting list state is going to be synced. + /// The selector to get and set the key of the selected item on a . + /// + /// The path of the file where this operator is being used. + /// This is used to resolve the (cf. for more info). + /// DO NOT provide anything here, let the compiler provide this. + /// + /// For debug purposes, the name of this subscription. DO NOT provide anything here, let the compiler provide this. + /// + /// The line number of the file where this operator is being used. + /// This is used to resolve the (cf. for more info). + /// DO NOT provide anything here, let the compiler provide this. + /// + /// A ListState from and onto which the selection is going to be synced. + public static IListState Selection( + this IListFeed source, + IState selectionState, + PropertySelector keySelector, + [CallerFilePath] string path = "", + [CallerMemberName] string caller = "", + [CallerLineNumber] int line = -1) + where TSource : IKeyed + where TSourceKey : notnull + where TOther : new() + => AttachedProperty.GetOrCreate( + source, + (selectionState, keySelector, path, caller, line), + static (src, args) => ListFeedSelection.Create( + src, + args.selectionState, + i => i.Key, + PropertySelectors.Get(args.keySelector, nameof(keySelector), args.path, args.line), + () => new(), + default, + $"Selection defined in {args.caller} at line {args.line}.")); + + /// + /// Creates a ListState from a ListFeed onto which the selected item is being synced with the provided external state using projection. + /// + /// Type of the items of the list feed. + /// Type of the key of items of the list feed. + /// Type of the entity which hold the selection. + /// The source list feed. + /// The external state from and onto which the selection of the resulting list state is going to be synced. + /// The selector to get and set the key of the selected item on a . + /// + /// The path of the file where this operator is being used. + /// This is used to resolve the (cf. for more info). + /// DO NOT provide anything here, let the compiler provide this. + /// + /// For debug purposes, the name of this subscription. DO NOT provide anything here, let the compiler provide this. + /// + /// The line number of the file where this operator is being used. + /// This is used to resolve the (cf. for more info). + /// DO NOT provide anything here, let the compiler provide this. + /// + /// A ListState from and onto which the selection is going to be synced. + public static IListState Selection( + this IListFeed source, + IState selectionState, + PropertySelector keySelector, + [CallerFilePath] string path = "", + [CallerMemberName] string caller = "", + [CallerLineNumber] int line = -1) + where TSource : IKeyed + where TSourceKey : struct + where TOther : new() + => AttachedProperty.GetOrCreate( + source, + (selectionState, keySelector, path, caller, line), + static (src, args) => ListFeedSelection.CreateValueType( + src, + args.selectionState, + i => i.Key, + PropertySelectors.Get(args.keySelector, nameof(keySelector), args.path, args.line), + () => new(), + default, + $"Selection defined in {args.caller} at line {args.line}.")); #endregion } diff --git a/src/Uno.Extensions.Reactive/Core/ListState.Extensions.cs b/src/Uno.Extensions.Reactive/Core/ListState.Extensions.cs index 31b8826d10..45d3cc6036 100644 --- a/src/Uno.Extensions.Reactive/Core/ListState.Extensions.cs +++ b/src/Uno.Extensions.Reactive/Core/ListState.Extensions.cs @@ -153,4 +153,68 @@ public static IDisposable ForEachAsync(this IListState state, AsyncAction< where T : notnull => new StateForEach>(state, (list, ct) => action(list ?? ImmutableList.Empty, ct), $"ForEachAsync defined in {caller} at line {line}."); #endregion -} + + /// + /// Tries to select some items in a list state. + /// + /// The type of the state + /// The state to update. + /// The items to flag as selected. + /// A token to abort the async operation. + /// + public static async ValueTask TrySelectAsync(this IListState state, IImmutableList selectedItems, CancellationToken ct) + { + var comparer = ListFeed.DefaultComparer.Entity; + var success = false; + + await state.UpdateMessage(msg => + { + var items = msg.CurrentData.SomeOrDefault(ImmutableList.Empty); + if (SelectionInfo.TryCreateMultiple(items, selectedItems, out var selection, comparer)) + { + success = true; + msg.Selected(selection); + } + }, ct); + + return success; + } + + /// + /// Tries to select a single item in a list state. + /// + /// The type of the state + /// The state to update. + /// The item to flag as selected. + /// A token to abort the async operation. + /// + public static async ValueTask TrySelectAsync(this IListState state, T selectedItem, CancellationToken ct) + where T : notnull + { + var comparer = ListFeed.DefaultComparer.Entity; + var success = false; + + await state.UpdateMessage(msg => + { + var items = msg.CurrentData.SomeOrDefault(ImmutableList.Empty); + if (SelectionInfo.TryCreateSingle(items, selectedItem, out var selection, comparer)) + { + success = true; + msg.Selected(selection); + } + }, ct); + + return success; + } + + /// + /// Clear the selection info of a list state. + /// + /// The type of the state + /// The state to update. + /// A token to abort the async operation. + /// + public static async ValueTask ClearSelection(this IListState state, CancellationToken ct) + where T : notnull + => await state.UpdateMessage(msg => msg.Selected(SelectionInfo.Empty), ct); +} \ No newline at end of file diff --git a/src/Uno.Extensions.Reactive/Core/ListState.T.cs b/src/Uno.Extensions.Reactive/Core/ListState.T.cs index d99bbe5c30..2c8e53893d 100644 --- a/src/Uno.Extensions.Reactive/Core/ListState.T.cs +++ b/src/Uno.Extensions.Reactive/Core/ListState.T.cs @@ -27,16 +27,7 @@ public static class ListState /// A feed that encapsulate the source. public static IListState Create(TOwner owner, Func>>> sourceProvider) where TOwner : class - => AttachedProperty.GetOrCreate(owner, sourceProvider, (o, sp) => S(o, new CustomFeed>(sp))); - - /// - /// Creates a custom feed from a raw sequence of . - /// - /// The provider of the message enumerable sequence. - /// A feed that encapsulate the source. - [EditorBrowsable(EditorBrowsableState.Never)] - internal static IListState Create(Func>>> sourceProvider) - => AttachedProperty.GetOrCreate(Validate(sourceProvider), sp => S(sp, new CustomFeed>(sp))); + => AttachedProperty.GetOrCreate(owner, sourceProvider, static (o, sp) => S(o, new CustomFeed>(sp))); /// /// Creates a custom feed from a raw sequence of . @@ -47,16 +38,7 @@ internal static IListState Create(FuncA feed that encapsulate the source. public static IListState Create(TOwner owner, Func>>> sourceProvider) where TOwner : class - => AttachedProperty.GetOrCreate(owner, sourceProvider, (o, sp) => S(o, new CustomFeed>(_ => sp()))); - - /// - /// Creates a custom feed from a raw sequence of . - /// - /// The provider of the message enumerable sequence. - /// A feed that encapsulate the source. - [EditorBrowsable(EditorBrowsableState.Never)] - internal static IListState Create(Func>>> sourceProvider) - => AttachedProperty.GetOrCreate(Validate(sourceProvider), sp => S(sp, new CustomFeed>(_ => sp()))); + => AttachedProperty.GetOrCreate(owner, sourceProvider, static (o, sp) => S(o, new CustomFeed>(_ => sp()))); /// /// Gets or creates an empty list state. @@ -74,7 +56,7 @@ public static IListState Empty(TOwner owner, [CallerMemberName] strin name ?? throw new InvalidOperationException("The name of the list state must not be null"), line < 0 ? throw new InvalidOperationException("The provided line number is invalid.") : line ), - (o, _) => SourceContext.GetOrCreate(o).CreateListState(Option>.None())); + static (o, _) => SourceContext.GetOrCreate(o).CreateListState(Option>.None())); /// /// Gets or creates a list state from a static initial list of items. @@ -83,10 +65,10 @@ public static IListState Empty(TOwner owner, [CallerMemberName] strin /// The owner of the state. /// The provider of the initial value of the state. /// A feed that encapsulate the source. - public static IListState Value(TOwner owner, Func> valueProvider) + public static IListState Value(TOwner owner, Func> valueProvider) where TOwner : class - // Note: We force the usage of delegate so 2 properties which are doing State.Value(this, () => 42) will effectively have 2 distinct states. - => AttachedProperty.GetOrCreate(owner, valueProvider, (o, v) => SourceContext.GetOrCreate(o).CreateListState(Option>.Some(v()))); + // Note: We force the usage of delegate so 2 properties which are doing State.Value(this, () => 42) will effectively have 2 distinct states. + => AttachedProperty.GetOrCreate(owner, valueProvider, static (o, v) => SourceContext.GetOrCreate(o).CreateListState(Option>.Some(v()))); /// /// Gets or creates a list state from a static initial list of items. @@ -95,10 +77,10 @@ public static IListState Value(TOwner owner, Func> v /// The owner of the state. /// The provider of the initial value of the state. /// A feed that encapsulate the source. - public static IListState Value(TOwner owner, Func> valueProvider) + public static IListState Value(TOwner owner, Func> valueProvider) where TOwner : class // Note: We force the usage of delegate so 2 properties which are doing State.Value(this, () => 42) will effectively have 2 distinct states. - => AttachedProperty.GetOrCreate(owner, valueProvider, (o, v) => SourceContext.GetOrCreate(o).CreateListState(Option>.Some(v()))); + => AttachedProperty.GetOrCreate(owner, valueProvider, static (o, v) => SourceContext.GetOrCreate(o).CreateListState(Option>.Some(v()))); /// /// Gets or creates a list state from a static initial list of items. @@ -110,7 +92,19 @@ public static IListState Value(TOwner owner, Func> public static IListState Value(TOwner owner, Func>> valueProvider) where TOwner : class // Note: We force the usage of delegate so 2 properties which are doing State.Value(this, () => 42) will effectively have 2 distinct states. - => AttachedProperty.GetOrCreate(owner, valueProvider, (o, v) => SourceContext.GetOrCreate(owner).CreateListState(v())); + => AttachedProperty.GetOrCreate(owner, valueProvider, static (o, v) => SourceContext.GetOrCreate(o).CreateListState(v())); + + /// + /// Gets or creates a list state from a static initial list of items. + /// + /// Type of the owner of the state. + /// The owner of the state. + /// The provider of the initial value of the state. + /// A feed that encapsulate the source. + public static IListState Value(TOwner owner, Func>> valueProvider) + where TOwner : class + // Note: We force the usage of delegate so 2 properties which are doing State.Value(this, () => 42) will effectively have 2 distinct states. + => AttachedProperty.GetOrCreate(owner, valueProvider, static (o, v) => SourceContext.GetOrCreate(o).CreateListState(v().Map(l => l as IImmutableList))); /// /// Creates a custom feed from an async method. @@ -122,19 +116,19 @@ public static IListState Value(TOwner owner, FuncA feed that encapsulate the source. public static IListState Async(TOwner owner, AsyncFunc>> valueProvider, Signal? refresh = null) where TOwner : class - => AttachedProperty.GetOrCreate(owner, (valueProvider, refresh), (o, args) => S(o, new AsyncFeed>(args.valueProvider, args.refresh))); + => AttachedProperty.GetOrCreate(owner, (valueProvider, refresh), static (o, args) => S(o, new AsyncFeed>(args.valueProvider, args.refresh))); /// /// Creates a custom feed from an async method. /// + /// Type of the owner of the state. + /// The owner of the state. /// The async method to use to load the value of the resulting feed. /// A refresh trigger to reload the . /// A feed that encapsulate the source. - [EditorBrowsable(EditorBrowsableState.Never)] - internal static IListState Async(AsyncFunc>> valueProvider, Signal? refresh = null) - => refresh is null - ? AttachedProperty.GetOrCreate(Validate(valueProvider), vp => S(vp, new AsyncFeed>(vp))) - : AttachedProperty.GetOrCreate(refresh, Validate(valueProvider), (r, vp) => S(vp, new AsyncFeed>(vp, r))); + public static IListState Async(TOwner owner, AsyncFunc>> valueProvider, Signal? refresh = null) + where TOwner : class + => AttachedProperty.GetOrCreate(owner, (valueProvider, refresh), static (o, args) => S(o, new AsyncFeed>(async ct => (await args.valueProvider(ct)).Map(l => l as IImmutableList), args.refresh))); /// /// Creates a custom feed from an async method. @@ -146,19 +140,19 @@ internal static IListState Async(AsyncFunc>> valuePr /// A feed that encapsulate the source. public static IListState Async(TOwner owner, AsyncFunc> valueProvider, Signal? refresh = null) where TOwner : class - => AttachedProperty.GetOrCreate(owner, (valueProvider, refresh), (o, args) => S(o, new AsyncFeed>(args.valueProvider, args.refresh))); + => AttachedProperty.GetOrCreate(owner, (valueProvider, refresh), static (o, args) => S(o, new AsyncFeed>(args.valueProvider, args.refresh))); /// /// Creates a custom feed from an async method. /// + /// Type of the owner of the state. + /// The owner of the state. /// The async method to use to load the value of the resulting feed. /// A refresh trigger to reload the . /// A feed that encapsulate the source. - [EditorBrowsable(EditorBrowsableState.Never)] - internal static IListState Async(AsyncFunc> valueProvider, Signal? refresh = null) - => refresh is null - ? AttachedProperty.GetOrCreate(Validate(valueProvider), vp => S(vp, new AsyncFeed>(vp))) - : AttachedProperty.GetOrCreate(refresh, Validate(valueProvider), (r, vp) => S(vp, new AsyncFeed>(vp, r))); + public static IListState Async(TOwner owner, AsyncFunc> valueProvider, Signal? refresh = null) + where TOwner : class + => AttachedProperty.GetOrCreate(owner, (valueProvider, refresh), static (o, args) => S(o, new AsyncFeed>(async ct => await args.valueProvider(ct) as IImmutableList, args.refresh))); /// /// Creates a custom feed from an async enumerable sequence of value. @@ -169,7 +163,7 @@ internal static IListState Async(AsyncFunc> valueProvider, /// A feed that encapsulate the source. public static IListState AsyncEnumerable(TOwner owner, Func>>> enumerableProvider) where TOwner : class - => AttachedProperty.GetOrCreate(owner, enumerableProvider, (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); + => AttachedProperty.GetOrCreate(owner, enumerableProvider, static (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); /// /// Creates a custom feed from an async enumerable sequence of value. @@ -180,7 +174,7 @@ public static IListState AsyncEnumerable(TOwner owner, FuncA feed that encapsulate the source. public static IListState AsyncEnumerable(TOwner owner, Func>>> enumerableProvider) where TOwner : class - => AttachedProperty.GetOrCreate(owner, enumerableProvider, (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); + => AttachedProperty.GetOrCreate(owner, enumerableProvider, static (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); /// /// Creates a custom feed from an async enumerable sequence of value. @@ -189,7 +183,7 @@ public static IListState AsyncEnumerable(TOwner owner, FuncA feed that encapsulate the source. [EditorBrowsable(EditorBrowsableState.Never)] internal static IListState AsyncEnumerable(Func>>> enumerableProvider) - => AttachedProperty.GetOrCreate(Validate(enumerableProvider), ep => S(ep, new AsyncEnumerableFeed>(ep))); + => AttachedProperty.GetOrCreate(Validate(enumerableProvider), static ep => S(ep, new AsyncEnumerableFeed>(ep))); /// /// Creates a custom feed from an async enumerable sequence of value. @@ -200,7 +194,7 @@ internal static IListState AsyncEnumerable(FuncA feed that encapsulate the source. public static IListState AsyncEnumerable(TOwner owner, Func>> enumerableProvider) where TOwner : class - => AttachedProperty.GetOrCreate(owner, enumerableProvider, (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); + => AttachedProperty.GetOrCreate(owner, enumerableProvider, static (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); /// /// Creates a custom feed from an async enumerable sequence of value. @@ -211,7 +205,7 @@ public static IListState AsyncEnumerable(TOwner owner, FuncA feed that encapsulate the source. public static IListState AsyncEnumerable(TOwner owner, Func>> enumerableProvider) where TOwner : class - => AttachedProperty.GetOrCreate(owner, enumerableProvider, (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); + => AttachedProperty.GetOrCreate(owner, enumerableProvider, static (o, ep) => S(o, new AsyncEnumerableFeed>(ep))); /// /// Creates a custom feed from an async enumerable sequence of value. @@ -220,7 +214,7 @@ public static IListState AsyncEnumerable(TOwner owner, FuncA feed that encapsulate the source. [EditorBrowsable(EditorBrowsableState.Never)] internal static IListState AsyncEnumerable(Func>> enumerableProvider) - => AttachedProperty.GetOrCreate(Validate(enumerableProvider), ep => S(ep, new AsyncEnumerableFeed>(ep))); + => AttachedProperty.GetOrCreate(Validate(enumerableProvider), static ep => S(ep, new AsyncEnumerableFeed>(ep))); // WARNING: This not implemented for restrictions described in the remarks section of the AsyncPaginated // While restrictions are acceptable for a paginated by index ListState, it would be invalid for custom cursors. @@ -255,7 +249,7 @@ internal static IListState AsyncEnumerable(Func public static IListState AsyncPaginated(TOwner owner, AsyncFunc> getPage) where TOwner : class - => AttachedProperty.GetOrCreate(owner, getPage, (o, gp) => + => AttachedProperty.GetOrCreate(owner, getPage, static (o, gp) => { ListStateImpl? state = default; var paginatedFeed = new PaginatedListFeed, T>(ByIndexCursor.First, ByIndexCursor.GetPage(GetPage)); diff --git a/src/Uno.Extensions.Reactive/Core/ListState.cs b/src/Uno.Extensions.Reactive/Core/ListState.cs index 8d7cbbdf18..02c0f918e0 100644 --- a/src/Uno.Extensions.Reactive/Core/ListState.cs +++ b/src/Uno.Extensions.Reactive/Core/ListState.cs @@ -40,6 +40,19 @@ public static IListState Value(TOwner owner, Func 42) will effectively have 2 distinct states. => ListState.Value(owner, valueProvider); + /// + /// Gets or creates a list state from a static initial list of items. + /// + /// Type of the owner of the state. + /// The type of the value of the resulting feed. + /// The owner of the state. + /// The provider of the initial value of the state. + /// A feed that encapsulate the source. + public static IListState Value(TOwner owner, Func> valueProvider) + where TOwner : class + // Note: We force the usage of delegate so 2 properties which are doing State.Value(this, () => 42) will effectively have 2 distinct states. + => ListState.Value(owner, valueProvider); + /// /// Gets or creates a list state from an async method. /// @@ -53,6 +66,19 @@ public static IListState Async(TOwner owner, AsyncFunc ListState.Async(owner, valueProvider, refresh); + /// + /// Gets or creates a list state from an async method. + /// + /// Type of the owner of the state. + /// The type of the value of the resulting feed. + /// The owner of the state. + /// The async method to use to load the value of the resulting feed. + /// A refresh trigger to reload the . + /// A feed that encapsulate the source. + public static IListState Async(TOwner owner, AsyncFunc> valueProvider, Signal? refresh = null) + where TOwner : class + => ListState.Async(owner, valueProvider, refresh); + /// /// Gets or creates a list state from an async enumerable sequence of list of items. /// diff --git a/src/Uno.Extensions.Reactive/Core/MessageBuilder.TParent.cs b/src/Uno.Extensions.Reactive/Core/MessageBuilder.TParent.cs index 039556a0f4..8d4f8d56ed 100644 --- a/src/Uno.Extensions.Reactive/Core/MessageBuilder.TParent.cs +++ b/src/Uno.Extensions.Reactive/Core/MessageBuilder.TParent.cs @@ -66,9 +66,9 @@ internal MessageBuilder(Message? parent, Message local) bool IMessageEntry.IsTransient => CurrentIsTransient; MessageAxisValue IMessageEntry.this[MessageAxis axis] => Get(axis).value; - internal Option CurrentData => (Option)Get(MessageAxis.Data).value.Value!; - internal Exception? CurrentError => (Exception?)Get(MessageAxis.Data).value.Value; - internal bool CurrentIsTransient => Get(MessageAxis.Progress).value is { IsSet: true } progress && (bool)progress.Value!; + internal Option CurrentData => MessageAxis.Data.FromMessageValue(Get(MessageAxis.Data).value); + internal Exception? CurrentError => MessageAxis.Error.FromMessageValue(Get(MessageAxis.Error).value); + internal bool CurrentIsTransient => MessageAxis.Progress.FromMessageValue(Get(MessageAxis.Progress).value); /// (MessageAxisValue value, IChangeSet? changes) IMessageBuilder.Get(MessageAxis axis) diff --git a/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.Factories.cs b/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.Factories.cs new file mode 100644 index 0000000000..f3976c4130 --- /dev/null +++ b/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.Factories.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using Uno.Extensions.Edition; +using Uno.Extensions.Reactive.Logging; + +namespace Uno.Extensions.Reactive.Operators; + +internal static class ListFeedSelection +{ + public static IListState Create(IListFeed source, IState> selectionState, string logTag) + { + var comparer = ListFeed.DefaultComparer.Entity; + + return new ListFeedSelection>(source, selectionState, InfoToItems, ItemsToInfo, logTag); + + Option> InfoToItems(IImmutableList items, SelectionInfo info, Option> _) + => info.GetSelectedItems(items) is { Count: > 0 } selectedItems + ? Extensions.Option>.Some(selectedItems) + : Extensions.Option>.None(); + + SelectionInfo ItemsToInfo(IImmutableList items, Option> selectedItems) + { + if (selectedItems.IsSome(out var other)) + { + if (SelectionInfo.TryCreateMultiple(items, other, out var info, comparer)) + { + return info; + } + + selectionState.Log().Warn( + $"In the {logTag}, some items that have been set as selected in the state are not present in the list. " + + "Selection is being cleared on the list state."); + } + + return SelectionInfo.Empty; + } + } + + public static IListState Create(IListFeed source, IState selectionState, string logTag) + { + var comparer = ListFeed.DefaultComparer.Entity; + + return new ListFeedSelection(source, selectionState, InfoToItem, ItemToInfo, logTag); + + Option InfoToItem(IImmutableList items, SelectionInfo selection, Option _) + => selection.TryGetSelectedItem(items, out var item) + ? Extensions.Option.Some(item) + : Extensions.Option.None(); + + SelectionInfo ItemToInfo(IImmutableList items, Option selectedItem) + { + if (selectedItem.IsSome(out var other)) + { + if (SelectionInfo.TryCreateSingle(items, other, out var selection, comparer)) + { + return selection; + } + + selectionState.Log().Warn( + $"In the {logTag}, the item '{other}' has been set as the selected in the state but is not present in the list. " + + "Selection is being cleared on the list state."); + } + + return SelectionInfo.Empty; + } + } + + public static IListState Create( + IListFeed source, + IState selectionState, + Func keySelector, + IValueAccessor foreignKeySelector, + Func defaultFactory, + TKey? emptySelectionKey, + string logTag) + where TKey : notnull + { + return new ListFeedSelection(source, selectionState, InfoToOther, OtherToInfo, logTag); + + Option InfoToOther(IImmutableList items, SelectionInfo selection, Option other) + { + var result = (selection.TryGetSelectedItem(items, out var item), other) switch + { + (true, { Type: OptionType.Some }) => Extensions.Option.Some(foreignKeySelector.Set((TOther)other, keySelector(item!))), + (true, _) => Extensions.Option.Some(foreignKeySelector.Set(defaultFactory(), keySelector(item!))), + (false, { Type: OptionType.Some }) => Extensions.Option.Some(foreignKeySelector.Set((TOther)other, emptySelectionKey)), + (false, _) => other + }; + + return result; + } + + SelectionInfo OtherToInfo(IImmutableList items, Option otherOpt) + { + if (otherOpt.IsSome(out var other) + && foreignKeySelector.Get(other) is { } selectedKey) + { + var selectedIndex = IndexOfKey(items, keySelector, selectedKey); + if (selectedIndex >= 0) + { + return SelectionInfo.Single((uint)selectedIndex); + } + + selectionState.Log().Warn( + $"In the {logTag}, an item with key '{selectedKey}' has been set as the selected in the state but is not present in the list. " + + "Selection is being cleared on the list state."); + } + + return SelectionInfo.Empty; + } + } + + public static IListState CreateValueType( + IListFeed source, + IState selectionState, + Func keySelector, + IValueAccessor foreignKeySelector, + Func defaultFactory, + TKey? emptySelectionKey, + string logTag) + where TKey : struct + { + return new ListFeedSelection(source, selectionState, InfoToOther, OtherToInfo, logTag); + + Option InfoToOther(IImmutableList items, SelectionInfo selection, Option other) + { + var result = (selection.TryGetSelectedItem(items, out var item), other) switch + { + (true, { Type: OptionType.Some }) => Extensions.Option.Some(foreignKeySelector.Set((TOther)other, keySelector(item!))), + (true, _) => Extensions.Option.Some(foreignKeySelector.Set(defaultFactory(), keySelector(item!))), + (false, { Type: OptionType.Some }) => Extensions.Option.Some(foreignKeySelector.Set((TOther)other, emptySelectionKey)), + (false, _) => other + }; + + return result; + } + + SelectionInfo OtherToInfo(IImmutableList items, Option otherOpt) + { + if (otherOpt.IsSome(out var other) + && foreignKeySelector.Get(other) is { } selectedKey) + { + var selectedIndex = IndexOfKey(items, keySelector, selectedKey); + if (selectedIndex >= 0) + { + return SelectionInfo.Single((uint)selectedIndex); + } + + selectionState.Log().Warn( + $"In the {logTag}, an item with key '{selectedKey}' has been set as the selected in the state but is not present in the list. " + + "Selection is being cleared on the list state."); + } + + return SelectionInfo.Empty; + } + } + + private static int IndexOfKey(IImmutableList items, Func selector, TKey? key) + where TKey : notnull + { + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + if (selector(item!).Equals(key)) + { + return i; + } + } + + return -1; + } +} diff --git a/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs b/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs new file mode 100644 index 0000000000..3b5595abf8 --- /dev/null +++ b/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Uno.Extensions.Reactive.Core; +using Uno.Extensions.Reactive.Logging; +using Uno.Extensions.Reactive.Utils; + +namespace Uno.Extensions.Reactive.Operators; + +internal sealed class ListFeedSelection : IListState, IStateImpl +{ + internal static MessageAxis SelectionUpdateSource { get; } = new(MessageAxes.SelectionSource, _ => null) { IsTransient = true }; + + private readonly IListFeed _source; + private readonly IState _selectionState; + private readonly Func, SelectionInfo, Option, Option> _selectionToOther; + private readonly Func, Option, SelectionInfo> _otherToSelection; + private readonly CancellationTokenSource _ct; + private readonly string _name; + private readonly StateSubscriptionMode _mode; + + private int _state = State.New; + private IState>? _impl; + + private static class State + { + public const int New = 0; + public const int Enabled = 1; + public const int Disposed = int.MaxValue; + } + + public ListFeedSelection( + IListFeed source, + IState selectionState, + Func, SelectionInfo, Option, Option> selectionToOther, + Func, Option, SelectionInfo> otherToSelection, + string logTag, + StateSubscriptionMode mode = StateSubscriptionMode.Default) + { + _source = source; + _selectionState = selectionState; + _selectionToOther = selectionToOther; + _otherToSelection = otherToSelection; + _name = logTag; + _mode = mode; + + // We must share the lifetime of the selectionState, so we share its Context. + var ctx = ((IStateImpl)selectionState).Context; + _ct = CancellationTokenSource.CreateLinkedTokenSource(ctx.Token); // We however allow early dispose of this state + Context = ctx; + + if (mode is StateSubscriptionMode.Eager) + { + Enable(); + } + } + + /// + public SourceContext Context { get; } + + /// + public IAsyncEnumerable>> GetSource(SourceContext context, CancellationToken ct = default) + => Enable().GetSource(context, ct); + + /// + public async ValueTask UpdateMessage(Action>> updater, CancellationToken ct) + => await Enable() + .UpdateMessage( + u => + { + var currentData = u.CurrentData; + var currentSelection = u.Get(MessageAxis.Selection); + + updater(u); + + var updatedData = u.CurrentData; + var updatedSelection = u.Get(MessageAxis.Selection); + + var dataHasChanged = !OptionEqualityComparer>.RefEquals.Equals(updatedData, currentData); + var selectionHasChanged = currentSelection != updatedSelection; + + if (dataHasChanged && !selectionHasChanged) + { + // The data has been updated, but not the selection. + // While this could be valid, it might also cause some OutOfRange, so for now we just clear it. + // TODO: This is only to ensure reliability, we should detect changes on the collection and update the SelectionInfo accordingly! + u.Set(MessageAxis.Selection, MessageAxisValue.Unset, null); + + selectionHasChanged = true; + } + + if (selectionHasChanged) + { + u.Set(SelectionUpdateSource, this); + } + }, + ct) + .ConfigureAwait(false); + + private IState> Enable() + { + switch (Interlocked.CompareExchange(ref _state, State.Enabled, State.New)) + { + case State.Disposed: throw new ObjectDisposedException(_name); + case State.Enabled: return _impl!; + } + + var feed = new UpdateFeed>(_source.AsFeed()); + var impl = new StateImpl>(Context, feed, StateSubscriptionMode.Eager); + + SelectionFeedUpdate? selectionFromState = null; + + Context + .GetOrCreateSource(_selectionState) + .Where(msg => msg.Changes.Contains(MessageAxis.Data) && msg.Current.Get(SelectionUpdateSource) != this) + .ForEachAwaitWithCancellationAsync(SyncFromStateToList, ConcurrencyMode.AbortPrevious, _ct.Token); + + Context + .GetOrCreateSource(impl) + .Where(msg => msg.Changes.Contains(MessageAxis.Selection) && msg.Current.Get(SelectionUpdateSource) != _selectionState) + .ForEachAwaitWithCancellationAsync(SyncFromListToState, ConcurrencyMode.AbortPrevious, _ct.Token); + + return _impl = impl; + + async ValueTask SyncFromStateToList(Message otherMsg, CancellationToken ct) + { + // Note: We sync only the SelectedItem. Error, Transient and any other axis are **not** expected to flow between the 2 sources. + + var stateSelection = new SelectionFeedUpdate(this, otherMsg.Current.Data); + + if (selectionFromState is not null) + { + feed.Replace(selectionFromState, stateSelection); + } + else + { + feed.Add(stateSelection); + } + + selectionFromState = stateSelection; + } + + async ValueTask SyncFromListToState(Message> implMsg, CancellationToken ct) + { + var selectionInfo = implMsg.Current.GetSelectionInfo() ?? SelectionInfo.Empty; + var items = implMsg.Current.Data.SomeOrDefault(ImmutableList.Empty); + + await _selectionState + .UpdateMessage( + otherMsg => + { + try + { + otherMsg + .Set(SelectionUpdateSource, this) + .Data(_selectionToOther(items, selectionInfo, otherMsg.CurrentData)); + } + catch (Exception error) + { + this.Log().Error(error, $"Failed to push selection from the list to the state for {_name}."); + } + }, + ct) + .ConfigureAwait(false); + } + } + + private class SelectionFeedUpdate : IFeedUpdate> + { + private readonly ListFeedSelection _owner; + private readonly Option _other; + + public SelectionFeedUpdate(ListFeedSelection owner, Option other) + { + _owner = owner; + _other = other; + } + + public bool IsActive(bool parentChanged, MessageBuilder, IImmutableList> msg) + => !parentChanged || !(msg.Parent?.Changes.Contains(MessageAxis.Selection) ?? false); + + public void Apply(bool _, MessageBuilder, IImmutableList> msg) + { + var items = msg.CurrentData.SomeOrDefault(ImmutableList.Empty); + var selection = _owner._otherToSelection(items, _other); + + msg + .Set(MessageAxis.Selection, selection) + .Set(SelectionUpdateSource, _owner._selectionState); + } + } + + /// + public async ValueTask DisposeAsync() + { + _state = State.Disposed; + _ct.Cancel(); + if (_impl is not null) + { + await _impl.DisposeAsync(); + } + } +} diff --git a/src/Uno.Extensions.Reactive/Operators/UpdateFeed.cs b/src/Uno.Extensions.Reactive/Operators/UpdateFeed.cs index e340e365ac..8afb611693 100644 --- a/src/Uno.Extensions.Reactive/Operators/UpdateFeed.cs +++ b/src/Uno.Extensions.Reactive/Operators/UpdateFeed.cs @@ -9,26 +9,43 @@ namespace Uno.Extensions.Reactive.Operators; -internal record FeedUpdate(Predicate> IsActive, Action> Apply); +internal interface IFeedUpdate +{ + bool IsActive(bool parentChanged, MessageBuilder message); + + void Apply(bool parentChanged, MessageBuilder message); +} + +internal record FeedUpdate(Func, bool> IsActive, Action> Apply) : IFeedUpdate +{ + bool IFeedUpdate.IsActive(bool parentChanged, MessageBuilder message) => IsActive(parentChanged, message); + void IFeedUpdate.Apply(bool parentChanged, MessageBuilder message) => Apply(parentChanged, message); +}; internal sealed class UpdateFeed : IFeed { - private readonly AsyncEnumerableSubject<(UpdateAction action, FeedUpdate update)> _updates = new(ReplayMode.Disabled); + private readonly AsyncEnumerableSubject<(IFeedUpdate[]? added, IFeedUpdate[]? removed)> _updates = new(ReplayMode.Disabled); private readonly IFeed _source; - private enum UpdateAction + public UpdateFeed(IFeed source) { - Add, - Remove + _source = source; } - public UpdateFeed(IFeed source) + public async ValueTask Update(Func, bool> predicate, Action> updater, CancellationToken ct) { - _source = source; + var update = new FeedUpdate(predicate, updater); + _updates.SetNext((new[] { update }, null)); } - public async ValueTask Update(Predicate> predicate, Action> updater, CancellationToken ct) - => _updates.SetNext((UpdateAction.Add, new FeedUpdate(predicate, updater))); + internal void Add(IFeedUpdate update) + => _updates.SetNext((new[] { update }, null)); + + internal void Replace(IFeedUpdate previous, IFeedUpdate current) + => _updates.SetNext((new[] { current }, new[] { previous })); + + internal void Remove(IFeedUpdate update) + => _updates.SetNext((null, new[] { update })); /// public IAsyncEnumerable> GetSource(SourceContext context, CancellationToken ct) @@ -40,7 +57,7 @@ private class UpdateFeedSource : IAsyncEnumerable> private readonly AsyncEnumerableSubject> _subject; private readonly MessageManager _message; - private ImmutableList> _activeUpdates; + private ImmutableList> _activeUpdates; private bool _isInError; public UpdateFeedSource(UpdateFeed owner, SourceContext context, CancellationToken ct) @@ -48,7 +65,7 @@ public UpdateFeedSource(UpdateFeed owner, SourceContext context, Cancellation _ct = ct; _subject = new AsyncEnumerableSubject>(ReplayMode.EnabledForFirstEnumeratorOnly); _message = new MessageManager(_subject.SetNext); - _activeUpdates = ImmutableList>.Empty; + _activeUpdates = ImmutableList>.Empty; // mode=AbortPrevious => When we receive a new update, we can abort the update and start a new one owner._updates.ForEachAsync(OnUpdateReceived, ct); @@ -59,29 +76,28 @@ public UpdateFeedSource(UpdateFeed owner, SourceContext context, Cancellation public IAsyncEnumerator> GetAsyncEnumerator(CancellationToken ct = default) => _subject.GetAsyncEnumerator(ct); - private void OnUpdateReceived((UpdateAction action, FeedUpdate update) args) + private void OnUpdateReceived((IFeedUpdate[]? added, IFeedUpdate[]? removed) args) { lock (this) { - switch (args.action) + var canDoIncrementalUpdate = !_isInError; + if (args.removed is { Length: > 0 } removed) { - case UpdateAction.Add: - _activeUpdates = _activeUpdates.Add(args.update); - if (!_isInError) - { - IncrementalUpdate(args.update); - return; - } - break; + canDoIncrementalUpdate = false; + _activeUpdates = _activeUpdates.RemoveRange(removed); + } - case UpdateAction.Remove: - _activeUpdates = _activeUpdates.Remove(args.update); - break; + if (args.added is { Length: > 0 } added) + { + _activeUpdates = _activeUpdates.AddRange(added); - default: - throw new ArgumentOutOfRangeException($"Unkown update action '{args.action}'"); + if (canDoIncrementalUpdate) + { + IncrementalUpdate(added); + return; + } } - + RebuildMessage(parentMsg: default); } } @@ -94,27 +110,31 @@ private void OnParentUpdated(Message parentMsg) } } - private void IncrementalUpdate(FeedUpdate update) - => _message.Update( + private void IncrementalUpdate(IFeedUpdate[] updates) + { + _message.Update( (current, u) => { try { var msg = current.With(); - u.Apply(new (msg.Get, ((IMessageBuilder)msg).Set)); + foreach (var update in u) + { + update.Apply(false, msg); + } return msg; } catch (Exception error) { _isInError = true; - return current - .WithParentOnly(null) + return current.WithParentOnly(null) .Data(Option.Undefined()) .Error(error); } }, - update, + updates, _ct); + } private void RebuildMessage(Message? parentMsg) { @@ -124,13 +144,13 @@ private void RebuildMessage(Message? parentMsg) { try { + var parentChanged = parent is not null; var msg = current.WithParentOnly(parent); foreach (var update in _activeUpdates) { - var builder = new MessageBuilder(msg.Get, ((IMessageBuilder)msg).Set); - if (update.IsActive(builder)) + if (update.IsActive(parentChanged, msg)) { - update.Apply(builder); + update.Apply(parentChanged, msg); } else { diff --git a/src/Uno.Extensions.Reactive/Operators/WhereListFeed.cs b/src/Uno.Extensions.Reactive/Operators/WhereListFeed.cs index 4840a0b66f..a023f8ad29 100644 --- a/src/Uno.Extensions.Reactive/Operators/WhereListFeed.cs +++ b/src/Uno.Extensions.Reactive/Operators/WhereListFeed.cs @@ -74,7 +74,7 @@ private MessageBuilder, IImmutableList> DoUpdate(MessageMan // changes = FeedToListFeedAdapter.GetChangeSet(parentMsg.Previous.Data, data); //} - var updatedFilteredItems = updatedItems.Where(item => _predicate(item)).ToImmutableList(); + var updatedFilteredItems = updatedItems.Where(item => _predicate(item)).ToImmutableList() as IImmutableList; if (updatedFilteredItems is { Count: 0 }) { updated