diff --git a/StabilityMatrix.Avalonia/Collections/SearchCollection.cs b/StabilityMatrix.Avalonia/Collections/SearchCollection.cs new file mode 100644 index 00000000..6a268ec2 --- /dev/null +++ b/StabilityMatrix.Avalonia/Collections/SearchCollection.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData; +using DynamicData.Binding; +using JetBrains.Annotations; + +namespace StabilityMatrix.Avalonia.Collections; + +[PublicAPI] +public class SearchCollection : AbstractNotifyPropertyChanged, IDisposable + where TObject : notnull + where TKey : notnull +{ + private readonly IDisposable cleanUp; + + private Func>? PredicateSelector { get; } + private Func>? ScorerSelector { get; } + private Func? Scorer { get; set; } + + private TQuery? _query; + public TQuery? Query + { + get => _query; + set => SetAndRaise(ref _query, value); + } + + private SortExpressionComparer _sortComparer = []; + public SortExpressionComparer SortComparer + { + get => _sortComparer; + set => SetAndRaise(ref _sortComparer, value); + } + + /// + /// Converts to . + /// + private SortExpressionComparer SearchItemSortComparer => + [ + ..SortComparer + .Select(sortExpression => new SortExpression( + item => sortExpression.Expression(item.Item), + sortExpression.Direction + )).Prepend(new SortExpression(item => item.Score, SortDirection.Descending)) + ]; + + public IObservableCollection Items { get; } = new ObservableCollectionExtended(); + + public IObservableCollection FilteredItems { get; } = + new ObservableCollectionExtended(); + + public SearchCollection( + IObservable> source, + Func> predicateSelector, + SortExpressionComparer? sortComparer = null + ) + { + PredicateSelector = predicateSelector; + + if (sortComparer is not null) + { + SortComparer = sortComparer; + } + + // Observable which creates a new predicate whenever Query property changes + var dynamicPredicate = this.WhenValueChanged(@this => @this.Query).Select(predicateSelector); + + cleanUp = source + .Bind(Items) + .Filter(dynamicPredicate) + .Sort(SortComparer) + .Bind(FilteredItems) + .Subscribe(); + } + + public SearchCollection( + IObservable> source, + Func> scorerSelector, + SortExpressionComparer? sortComparer = null + ) + { + ScorerSelector = scorerSelector; + + if (sortComparer is not null) + { + SortComparer = sortComparer; + } + + // Monitor Query property for changes + var queryChanged = this.WhenValueChanged(@this => @this.Query).Select(_ => Unit.Default); + + cleanUp = new CompositeDisposable( + // Update Scorer property whenever Query property changes + queryChanged.Subscribe(_ => Scorer = scorerSelector(Query)), + // Transform source items into SearchItems + source + .Transform( + obj => + { + var (isMatch, score) = Scorer?.Invoke(obj) ?? (true, 0); + + return new SearchItem + { + Item = obj, + IsMatch = isMatch, + Score = score + }; + }, + forceTransform: queryChanged + ) + .Filter(item => item.IsMatch) + .Sort(SearchItemSortComparer, SortOptimisations.ComparesImmutableValuesOnly) + .Transform(searchItem => searchItem.Item) + .Bind(FilteredItems) + .Subscribe() + ); + } + + /// + /// Clears property by setting it to default value. + /// + public void ClearQuery() + { + Query = default; + } + + public void Dispose() + { + cleanUp.Dispose(); + GC.SuppressFinalize(this); + } + + private readonly record struct SearchItem + { + public TObject Item { get; init; } + public int Score { get; init; } + public bool IsMatch { get; init; } + } +}