From 666b5d83dfaf0dfa300945e9fc303ecb2a3ee401 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 10 Apr 2023 13:05:38 -0600 Subject: [PATCH 1/7] Move filter query and RowComparer into UI base --- .../ViewModels/ProductsDisplayViewModel.cs | 22 +---- .../ViewModels/RowComparer.cs | 80 +----------------- .../GridView/QueryExtensions.cs | 17 +++- .../GridView/RowComparerBase.cs | 82 +++++++++++++++++++ 4 files changed, 106 insertions(+), 95 deletions(-) create mode 100644 Source/LibationUiBase/GridView/RowComparerBase.cs diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 127a3b00..a973b63c 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -23,7 +23,7 @@ namespace LibationAvalonia.ViewModels /// Backing list of all grid entries private readonly AvaloniaList SOURCE = new(); /// Grid entries included in the filter set. If null, all grid entries are shown - private List FilteredInGridEntries; + private HashSet FilteredInGridEntries; public string FilterString { get; private set; } public DataGridCollectionView GridEntries { get; private set; } @@ -117,7 +117,7 @@ namespace LibationAvalonia.ViewModels } //Create the filtered-in list before adding entries to avoid a refresh - FilteredInGridEntries = QueryResults(geList.Union(geList.OfType().SelectMany(s => s.Children)), FilterString); + FilteredInGridEntries = geList.Union(geList.OfType().SelectMany(s => s.Children)).FilterEntries(FilterString); SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); //Add all children beneath their parent @@ -301,7 +301,7 @@ namespace LibationAvalonia.ViewModels if (SOURCE.Count == 0) return; - FilteredInGridEntries = QueryResults(SOURCE, searchString); + FilteredInGridEntries = SOURCE.FilterEntries(searchString); await refreshGrid(); } @@ -318,23 +318,9 @@ namespace LibationAvalonia.ViewModels return FilteredInGridEntries.Contains(item); } - private static List QueryResults(IEnumerable entries, string searchString) - { - if (string.IsNullOrEmpty(searchString)) return null; - - var searchResultSet = SearchEngineCommands.Search(searchString); - - var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe); - - //Find all series containing children that match the search criteria - var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); - - return booksFilteredIn.Concat(seriesFilteredIn).ToList(); - } - private async void SearchEngineCommands_SearchEngineUpdated(object sender, EventArgs e) { - var filterResults = QueryResults(SOURCE, FilterString); + var filterResults = SOURCE.FilterEntries(FilterString); if (filterResults is not null && FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count) { diff --git a/Source/LibationAvalonia/ViewModels/RowComparer.cs b/Source/LibationAvalonia/ViewModels/RowComparer.cs index 743a610f..ef3170d8 100644 --- a/Source/LibationAvalonia/ViewModels/RowComparer.cs +++ b/Source/LibationAvalonia/ViewModels/RowComparer.cs @@ -1,25 +1,17 @@ using Avalonia.Controls; using LibationUiBase.GridView; -using System.Collections; -using System.Collections.Generic; using System.ComponentModel; using System.Reflection; namespace LibationAvalonia.ViewModels { - /// - /// This compare class ensures that all top-level grid entries (standalone books or series parents) - /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain - /// sorted by series index, ascending. Stable sorting is achieved by comparing the GridEntry.ListIndex - /// properties when 2 items compare equal. - /// - internal class RowComparer : IComparer, IComparer, IComparer + internal class RowComparer : RowComparerBase { private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance); private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance); - public DataGridColumn Column { get; init; } - public string PropertyName { get; private set; } + private DataGridColumn Column { get; init; } + public override string PropertyName { get; set; } public RowComparer(DataGridColumn column) { @@ -27,72 +19,8 @@ namespace LibationAvalonia.ViewModels PropertyName = Column.SortMemberPath; } - public int Compare(object x, object y) - { - if (x is null && y is not null) return -1; - if (x is not null && y is null) return 1; - if (x is null && y is null) return 0; - - var geA = (IGridEntry)x; - var geB = (IGridEntry)y; - - var sortDirection = GetSortOrder(); - - ISeriesEntry parentA = null; - ISeriesEntry parentB = null; - - if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA) - parentA = seA; - if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB) - parentB = seB; - - //both a and b are top-level grid entries - if (parentA is null && parentB is null) - return InternalCompare(geA, geB); - - //a is top-level, b is a child - if (parentA is null && parentB is not null) - { - // b is a child of a, parent is always first - if (parentB == geA) - return sortDirection is ListSortDirection.Ascending ? -1 : 1; - else - return InternalCompare(geA, parentB); - } - - //a is a child, b is a top-level - if (parentA is not null && parentB is null) - { - // a is a child of b, parent is always first - if (parentA == geB) - return sortDirection is ListSortDirection.Ascending ? 1 : -1; - else - return InternalCompare(parentA, geB); - } - - //both are children of the same series - if (parentA == parentB) - return InternalCompare(geA, geB); - - //a and b are children of different series. - return InternalCompare(parentA, parentB); - } - //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection - private ListSortDirection? GetSortOrder() + protected override ListSortDirection? GetSortOrder() => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; - - private int InternalCompare(IGridEntry x, IGridEntry y) - { - var val1 = x.GetMemberValue(PropertyName); - var val2 = y.GetMemberValue(PropertyName); - - return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); ; - } - - public int Compare(IGridEntry x, IGridEntry y) - { - return Compare((object)x, y); - } } } diff --git a/Source/LibationUiBase/GridView/QueryExtensions.cs b/Source/LibationUiBase/GridView/QueryExtensions.cs index 9f899212..827d791b 100644 --- a/Source/LibationUiBase/GridView/QueryExtensions.cs +++ b/Source/LibationUiBase/GridView/QueryExtensions.cs @@ -1,4 +1,5 @@ -using DataLayer; +using ApplicationServices; +using DataLayer; using System; using System.Collections.Generic; using System.Linq; @@ -39,6 +40,20 @@ namespace LibationUiBase.GridView return null; } } + + public static HashSet? FilterEntries(this IEnumerable entries, string searchString) + { + if (string.IsNullOrEmpty(searchString)) return null; + + var searchResultSet = SearchEngineCommands.Search(searchString); + + var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe); + + //Find all series containing children that match the search criteria + var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); + + return booksFilteredIn.Concat(seriesFilteredIn).ToHashSet(); + } } #nullable disable } diff --git a/Source/LibationUiBase/GridView/RowComparerBase.cs b/Source/LibationUiBase/GridView/RowComparerBase.cs new file mode 100644 index 00000000..6dbad15c --- /dev/null +++ b/Source/LibationUiBase/GridView/RowComparerBase.cs @@ -0,0 +1,82 @@ +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; + +namespace LibationUiBase.GridView +{ + /// + /// This compare class ensures that all top-level grid entries (standalone books or series parents) + /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain + /// sorted by series index, ascending. + /// + public abstract class RowComparerBase : IComparer, IComparer, IComparer + { + public abstract string PropertyName { get; set; } + + public int Compare(object x, object y) + { + if (x is null && y is not null) return -1; + if (x is not null && y is null) return 1; + if (x is null && y is null) return 0; + + var geA = (IGridEntry)x; + var geB = (IGridEntry)y; + + var sortDirection = GetSortOrder(); + + ISeriesEntry parentA = null; + ISeriesEntry parentB = null; + + if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA) + parentA = seA; + if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB) + parentB = seB; + + //both a and b are top-level grid entries + if (parentA is null && parentB is null) + return InternalCompare(geA, geB); + + //a is top-level, b is a child + if (parentA is null && parentB is not null) + { + // b is a child of a, parent is always first + if (parentB == geA) + return sortDirection is ListSortDirection.Ascending ? -1 : 1; + else + return InternalCompare(geA, parentB); + } + + //a is a child, b is a top-level + if (parentA is not null && parentB is null) + { + // a is a child of b, parent is always first + if (parentA == geB) + return sortDirection is ListSortDirection.Ascending ? 1 : -1; + else + return InternalCompare(parentA, geB); + } + + //both are children of the same series + if (parentA == parentB) + return InternalCompare(geA, geB); + + //a and b are children of different series. + return InternalCompare(parentA, parentB); + } + + protected abstract ListSortDirection? GetSortOrder(); + + private int InternalCompare(IGridEntry x, IGridEntry y) + { + var val1 = x.GetMemberValue(PropertyName); + var val2 = y.GetMemberValue(PropertyName); + + return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); ; + } + + public int Compare(IGridEntry x, IGridEntry y) + { + return Compare((object)x, y); + } + } +} From 6110b08d161cc8f6d1663e23c3e1aa39d67c4fa5 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 10 Apr 2023 13:05:50 -0600 Subject: [PATCH 2/7] Fix typo --- Source/LibationUiBase/GridView/GridEntry[TStatus].cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index c731121d..3e41283b 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -155,7 +155,7 @@ namespace LibationUiBase.GridView return; //If UserDefinedItem was changed on a different Book instance (such as when batch liberating via menus), - //EntryStatu's Book instance will not have the current DB state. + //EntryStatus' Book instance will not have the current DB state. Liberate.Book = udi.Book; // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view. From 6800986f258ebbafeac54bd58b2e26233b45fa8f Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 10 Apr 2023 14:10:50 -0600 Subject: [PATCH 3/7] Update GridEntryBindingList to behave move like Chardonnay --- .../GridView/RowComparerBase.cs | 17 +- .../GridView/GridEntryBindingList.cs | 191 +++++++++--------- .../LibationWinForms/GridView/RowComparer.cs | 12 ++ 3 files changed, 120 insertions(+), 100 deletions(-) create mode 100644 Source/LibationWinForms/GridView/RowComparer.cs diff --git a/Source/LibationUiBase/GridView/RowComparerBase.cs b/Source/LibationUiBase/GridView/RowComparerBase.cs index 6dbad15c..ed33e985 100644 --- a/Source/LibationUiBase/GridView/RowComparerBase.cs +++ b/Source/LibationUiBase/GridView/RowComparerBase.cs @@ -58,7 +58,22 @@ namespace LibationUiBase.GridView //both are children of the same series if (parentA == parentB) - return InternalCompare(geA, geB); + { + //Podcast episodes usually all have the same PurchaseDate and DateAdded property: + //the date that the series was added to the library. So when sorting by PurchaseDate + //and DateAdded, compare SeriesOrder instead. + // + //Note that DateAdded is not a grid column and users cannot sort by that property. + //Entries are only sorted by DateAdded as a default sorting order when no other + //sorting column has been chose. We negate the comparison so that episodes are listed + //in ascending order. + return PropertyName switch + { + nameof(IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder), + nameof(IGridEntry.DateAdded) => geA.SeriesOrder.CompareTo(geB.SeriesOrder) * (GetSortOrder() is ListSortDirection.Descending ? 1 : -1), + _ => InternalCompare(geA, geB), + }; + } //a and b are children of different series. return InternalCompare(parentA, parentB); diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 5cf68f66..871d4513 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -1,6 +1,5 @@ using ApplicationServices; using Dinah.Core.DataBinding; -using LibationSearchEngine; using LibationUiBase.GridView; using System; using System.Collections.Generic; @@ -23,10 +22,9 @@ namespace LibationWinForms.GridView */ internal class GridEntryBindingList : BindingList, IBindingListView { - public GridEntryBindingList() : base(new List()) { } public GridEntryBindingList(IEnumerable enumeration) : base(new List(enumeration)) { - SearchEngineCommands.SearchEngineUpdated += (_,_) => ApplyFilter(FilterString); + SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; } @@ -35,22 +33,32 @@ namespace LibationWinForms.GridView /// All items that pass the current filter public IEnumerable GetFilteredInItems() - => SearchResults is null - ? FilterRemoved + => FilteredInGridEntries? .OfType() - .Union(Items.OfType()) - : FilterRemoved + ?? FilterRemoved .OfType() - .Join(SearchResults.Docs, o => o.Book.AudibleProductId, i => i.ProductId, (o, _) => o) .Union(Items.OfType()); public bool SupportsFiltering => true; - public string Filter { get => FilterString; set => ApplyFilter(value); } + public string Filter + { + get => FilterString; + set + { + FilterString = value; + + if (Items.Count + FilterRemoved.Count == 0) + return; + + FilteredInGridEntries = Items.Concat(FilterRemoved).FilterEntries(FilterString); + refreshEntries(); + } + } /// When true, itms will not be checked filtered by search criteria on item changed public bool SuspendFilteringOnUpdate { get; set; } - protected MemberComparer Comparer { get; } = new(); + protected RowComparer Comparer { get; } = new(); protected override bool SupportsSortingCore => true; protected override bool SupportsSearchingCore => true; protected override bool IsSortedCore => isSorted; @@ -60,10 +68,11 @@ namespace LibationWinForms.GridView /// Items that were removed from the base list due to filtering private readonly List FilterRemoved = new(); private string FilterString; - private SearchResultSet SearchResults; private bool isSorted; private ListSortDirection listSortDirection; private PropertyDescriptor propertyDescriptor; + /// All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice) + private HashSet FilteredInGridEntries; #region Unused - Advanced Filtering @@ -82,25 +91,65 @@ namespace LibationWinForms.GridView base.Remove(entry); } - private void ApplyFilter(string filterString) + /// + /// This method should be called whenever there's been a change to the + /// set of all GridEntries that affects sort order or filter status + /// + private void refreshEntries() { - if (filterString != FilterString) - RemoveFilter(); - - FilterString = filterString; - SearchResults = SearchEngineCommands.Search(filterString); - - var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe); - - //Find all series containing children that match the search criteria - var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); - - var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList(); - - foreach (var item in filteredOut) + if (FilteredInGridEntries is null) { - FilterRemoved.Add(item); - base.Remove(item); + addRemovedItemsBack(FilterRemoved.ToList()); + } + else + { + var addBackEntries = FilterRemoved.Intersect(FilteredInGridEntries).ToList(); + var toRemoveEntries = Items.Except(FilteredInGridEntries).ToList(); + + addRemovedItemsBack(addBackEntries); + + foreach (var newRemove in toRemoveEntries) + { + FilterRemoved.Add(newRemove); + Items.Remove(newRemove); + } + } + + if (IsSortedCore) + Sort(); + else + { + //No user sort is applied, so do default sorting by DateAdded, descending + Comparer.PropertyName = nameof(IGridEntry.DateAdded); + Comparer.SortOrder = ListSortDirection.Descending; + Sort(); + } + + OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + + void addRemovedItemsBack(List addBackEntries) + { + //Add removed entries back into Items so they are displayed + //(except for episodes that are collapsed) + foreach (var addBack in addBackEntries) + { + if (addBack is ILibraryBookEntry lbe && lbe.Parent is ISeriesEntry se && !se.Liberate.Expanded) + continue; + + FilterRemoved.Remove(addBack); + Items.Add(addBack); + } + } + } + + private void SearchEngineCommands_SearchEngineUpdated(object sender, EventArgs e) + { + var filterResults = AllItems().FilterEntries(FilterString); + + if (filterResults is not null && FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count) + { + FilteredInGridEntries = filterResults; + refreshEntries(); } } @@ -118,7 +167,7 @@ namespace LibationWinForms.GridView public void CollapseItem(ISeriesEntry sEntry) { - foreach (var episode in sEntry.Children.Join(Items.BookEntries(), o => o, i => i, (_, i) => i).ToList()) + foreach (var episode in sEntry.Children.Intersect(Items.BookEntries()).ToList()) { FilterRemoved.Add(episode); base.Remove(episode); @@ -131,9 +180,9 @@ namespace LibationWinForms.GridView { var sindex = Items.IndexOf(sEntry); - foreach (var episode in sEntry.Children.Join(FilterRemoved.BookEntries(), o => o, i => i, (_, i) => i).ToList()) + foreach (var episode in sEntry.Children.Intersect(FilterRemoved.BookEntries()).ToList()) { - if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) + if (FilteredInGridEntries?.Contains(episode) ?? true) { FilterRemoved.Remove(episode); InsertItem(++sindex, episode); @@ -144,39 +193,15 @@ namespace LibationWinForms.GridView public void RemoveFilter() { - if (FilterString is null) return; - - int visibleCount = Items.Count; - - foreach (var item in FilterRemoved.ToList()) - { - if (item is ISeriesEntry || (item is ILibraryBookEntry lbe && (lbe.Liberate.IsBook || lbe.Parent.Liberate.Expanded))) - { - FilterRemoved.Remove(item); - InsertItem(visibleCount++, item); - } - } - - if (IsSortedCore) - Sort(); - else - //No user sort is applied, so do default sorting by DateAdded, descending - { - Comparer.PropertyName = nameof(IGridEntry.DateAdded); - Comparer.Direction = ListSortDirection.Descending; - Sort(); - } - - OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); - FilterString = null; - SearchResults = null; + FilteredInGridEntries = null; + refreshEntries(); } protected override void ApplySortCore(PropertyDescriptor property, ListSortDirection direction) { Comparer.PropertyName = property.Name; - Comparer.Direction = direction; + Comparer.SortOrder = direction; Sort(); @@ -187,56 +212,24 @@ namespace LibationWinForms.GridView OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); } - protected void Sort() + private void Sort() { var itemsList = (List)Items; - - var children = itemsList.BookEntries().Where(i => i.Liberate.IsEpisode).ToList(); - - var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList(); + //User Order/OrderDescending and replace items in list instead of using List.Sort() to achieve stable sorting. + var sortedItems + = Comparer.SortOrder == ListSortDirection.Ascending ? itemsList.Order(Comparer).ToList() + : itemsList.OrderDescending(Comparer).ToList(); itemsList.Clear(); - - //Only add parentless items at this stage. After these items are added in the - //correct sorting order, go back and add the children beneath their parents. itemsList.AddRange(sortedItems); - - foreach (var parent in children.Select(c => c.Parent).Distinct()) - { - var pIndex = itemsList.IndexOf(parent); - - //children are sorted beneath their series parent - foreach (var c in children.Where(c => c.Parent == parent).OrderBy(c => c, Comparer)) - itemsList.Insert(++pIndex, c); - } } protected override void OnListChanged(ListChangedEventArgs e) { - if (e.ListChangedType == ListChangedType.ItemChanged) - { - if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is ILibraryBookEntry lbItem) - { - SearchResults = SearchEngineCommands.Search(FilterString); - if (!SearchResults.Docs.Any(d => d.ProductId == lbItem.AudibleProductId)) - { - FilterRemoved.Add(lbItem); - base.Remove(lbItem); - return; - } - } - - if (isSorted && e.PropertyDescriptor == SortPropertyCore) - { - var item = Items[e.NewIndex]; - Sort(); - var newIndex = Items.IndexOf(item); - - base.OnListChanged(new ListChangedEventArgs(ListChangedType.ItemMoved, newIndex, e.NewIndex)); - return; - } - } - base.OnListChanged(e); + if (e.ListChangedType == ListChangedType.ItemChanged && isSorted && e.PropertyDescriptor == SortPropertyCore) + refreshEntries(); + else + base.OnListChanged(e); } protected override void RemoveSortCore() diff --git a/Source/LibationWinForms/GridView/RowComparer.cs b/Source/LibationWinForms/GridView/RowComparer.cs new file mode 100644 index 00000000..3758fa00 --- /dev/null +++ b/Source/LibationWinForms/GridView/RowComparer.cs @@ -0,0 +1,12 @@ +using LibationUiBase.GridView; +using System.ComponentModel; + +namespace LibationWinForms.GridView +{ + internal class RowComparer : RowComparerBase + { + public ListSortDirection? SortOrder { get; set; } + public override string PropertyName { get; set; } + protected override ListSortDirection? GetSortOrder() => SortOrder; + } +} From 8a1b375f0dc307e9b7a3dd4d8720b6ab7a8b51db Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 10 Apr 2023 15:00:32 -0600 Subject: [PATCH 4/7] Fix #574 (for realsies this time) --- Source/LibationUiBase/GridView/EntryStatus.cs | 2 +- Source/LibationUiBase/GridView/GridEntry[TStatus].cs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs index 4e319597..71f04ccf 100644 --- a/Source/LibationUiBase/GridView/EntryStatus.cs +++ b/Source/LibationUiBase/GridView/EntryStatus.cs @@ -58,7 +58,7 @@ namespace LibationUiBase.GridView public abstract object BackgroundBrush { get; } public object ButtonImage => GetLiberateIcon(); public string ToolTip => GetTooltip(); - protected internal Book Book { get; internal set; } + internal Book Book { get; } private DateTime lastBookUpdate; private LiberatedStatus bookStatus; diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index 3e41283b..52513304 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -154,9 +154,13 @@ namespace LibationUiBase.GridView if (udi.Book.AudibleProductId != Book.AudibleProductId) return; - //If UserDefinedItem was changed on a different Book instance (such as when batch liberating via menus), - //EntryStatus' Book instance will not have the current DB state. - Liberate.Book = udi.Book; + if (udi.Book != LibraryBook.Book) + { + //If UserDefinedItem was changed on a different Book instance (such as when batch liberating via menus), + //Liberate.Book and LibraryBook.Book instances will not have the current DB state. + Invoke(() => UpdateLibraryBook(new LibraryBook(udi.Book, LibraryBook.DateAdded, LibraryBook.Account))); + return; + } // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view. // - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs From af8e1cd5eff9794f34888177f0f3550818ffdbca Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 10 Apr 2023 15:50:19 -0600 Subject: [PATCH 5/7] Change episode default sorting to SeriesOrder descending --- .../ViewModels/ProductsDisplayViewModel.cs | 2 +- Source/LibationAvalonia/ViewModels/RowComparer.cs | 8 +++++--- Source/LibationUiBase/GridView/EntryStatus.cs | 2 +- Source/LibationUiBase/GridView/RowComparerBase.cs | 12 +++--------- .../GridView/SeriesEntry[TStatus].cs | 2 +- .../GridView/GridEntryBindingList.cs | 7 +++---- Source/LibationWinForms/GridView/ProductsGrid.cs | 2 +- Source/LibationWinForms/GridView/RowComparer.cs | 14 +++++++++++--- 8 files changed, 26 insertions(+), 23 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index a973b63c..242c965a 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -118,7 +118,7 @@ namespace LibationAvalonia.ViewModels //Create the filtered-in list before adding entries to avoid a refresh FilteredInGridEntries = geList.Union(geList.OfType().SelectMany(s => s.Children)).FilterEntries(FilterString); - SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); + SOURCE.AddRange(geList.OrderDescending(new RowComparer(null))); //Add all children beneath their parent foreach (var series in SOURCE.OfType().ToList()) diff --git a/Source/LibationAvalonia/ViewModels/RowComparer.cs b/Source/LibationAvalonia/ViewModels/RowComparer.cs index ef3170d8..a9d40378 100644 --- a/Source/LibationAvalonia/ViewModels/RowComparer.cs +++ b/Source/LibationAvalonia/ViewModels/RowComparer.cs @@ -16,11 +16,13 @@ namespace LibationAvalonia.ViewModels public RowComparer(DataGridColumn column) { Column = column; - PropertyName = Column.SortMemberPath; + PropertyName = Column?.SortMemberPath ?? nameof(IGridEntry.DateAdded); } //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection - protected override ListSortDirection? GetSortOrder() - => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; + protected override ListSortDirection GetSortOrder() + => Column is null ? ListSortDirection.Descending + : CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) is ListSortDirection lsd ? lsd + : ListSortDirection.Descending; } } diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs index 71f04ccf..a7fbcc78 100644 --- a/Source/LibationUiBase/GridView/EntryStatus.cs +++ b/Source/LibationUiBase/GridView/EntryStatus.cs @@ -58,7 +58,7 @@ namespace LibationUiBase.GridView public abstract object BackgroundBrush { get; } public object ButtonImage => GetLiberateIcon(); public string ToolTip => GetTooltip(); - internal Book Book { get; } + private Book Book { get; } private DateTime lastBookUpdate; private LiberatedStatus bookStatus; diff --git a/Source/LibationUiBase/GridView/RowComparerBase.cs b/Source/LibationUiBase/GridView/RowComparerBase.cs index ed33e985..a24cf5d3 100644 --- a/Source/LibationUiBase/GridView/RowComparerBase.cs +++ b/Source/LibationUiBase/GridView/RowComparerBase.cs @@ -61,16 +61,10 @@ namespace LibationUiBase.GridView { //Podcast episodes usually all have the same PurchaseDate and DateAdded property: //the date that the series was added to the library. So when sorting by PurchaseDate - //and DateAdded, compare SeriesOrder instead. - // - //Note that DateAdded is not a grid column and users cannot sort by that property. - //Entries are only sorted by DateAdded as a default sorting order when no other - //sorting column has been chose. We negate the comparison so that episodes are listed - //in ascending order. + //and DateAdded, compare SeriesOrder instead.. return PropertyName switch { - nameof(IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder), - nameof(IGridEntry.DateAdded) => geA.SeriesOrder.CompareTo(geB.SeriesOrder) * (GetSortOrder() is ListSortDirection.Descending ? 1 : -1), + nameof(IGridEntry.DateAdded) or nameof (IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder), _ => InternalCompare(geA, geB), }; } @@ -79,7 +73,7 @@ namespace LibationUiBase.GridView return InternalCompare(parentA, parentB); } - protected abstract ListSortDirection? GetSortOrder(); + protected abstract ListSortDirection GetSortOrder(); private int InternalCompare(IGridEntry x, IGridEntry y) { diff --git a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs index f3ed63fc..eaf49fcb 100644 --- a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs @@ -47,7 +47,7 @@ namespace LibationUiBase.GridView Children = children .Select(c => new LibraryBookEntry(c, this)) - .OrderBy(c => c.SeriesIndex) + .OrderByDescending(c => c.SeriesOrder) .ToList(); UpdateLibraryBook(parent); diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 871d4513..463ce41a 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -1,5 +1,4 @@ using ApplicationServices; -using Dinah.Core.DataBinding; using LibationUiBase.GridView; using System; using System.Collections.Generic; @@ -25,6 +24,8 @@ namespace LibationWinForms.GridView public GridEntryBindingList(IEnumerable enumeration) : base(new List(enumeration)) { SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; + + refreshEntries(); } @@ -216,9 +217,7 @@ namespace LibationWinForms.GridView { var itemsList = (List)Items; //User Order/OrderDescending and replace items in list instead of using List.Sort() to achieve stable sorting. - var sortedItems - = Comparer.SortOrder == ListSortDirection.Ascending ? itemsList.Order(Comparer).ToList() - : itemsList.OrderDescending(Comparer).ToList(); + var sortedItems = Comparer.OrderEntries(itemsList).ToList(); itemsList.Clear(); itemsList.AddRange(sortedItems); diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 54a82439..ba24cd8e 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -181,7 +181,7 @@ namespace LibationWinForms.GridView geList.AddRange(seriesEntry.Children); } - bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded)); + bindingList = new GridEntryBindingList(geList); bindingList.CollapseAll(); syncBindingSource.DataSource = bindingList; VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); diff --git a/Source/LibationWinForms/GridView/RowComparer.cs b/Source/LibationWinForms/GridView/RowComparer.cs index 3758fa00..0e344578 100644 --- a/Source/LibationWinForms/GridView/RowComparer.cs +++ b/Source/LibationWinForms/GridView/RowComparer.cs @@ -1,12 +1,20 @@ using LibationUiBase.GridView; +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; namespace LibationWinForms.GridView { internal class RowComparer : RowComparerBase { - public ListSortDirection? SortOrder { get; set; } - public override string PropertyName { get; set; } - protected override ListSortDirection? GetSortOrder() => SortOrder; + public ListSortDirection SortOrder { get; set; } = ListSortDirection.Descending; + public override string PropertyName { get; set; } = nameof(IGridEntry.DateAdded); + protected override ListSortDirection GetSortOrder() => SortOrder; + + /// + /// Helper method for ordering grid entries + /// + public IOrderedEnumerable OrderEntries(IEnumerable entries) + => SortOrder is ListSortDirection.Descending ? entries.OrderDescending(this) : entries.Order(this); } } From 9a663fda15bf0de15baec73e1b6cb88b5c8b91b1 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 10 Apr 2023 16:42:10 -0600 Subject: [PATCH 6/7] Filtering bugfix --- .../ViewModels/ProductsDisplayViewModel.cs | 2 +- .../LibationWinForms/GridView/GridEntryBindingList.cs | 11 ++++------- Source/LibationWinForms/GridView/ProductsGrid.cs | 4 ---- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 242c965a..fdb94086 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -322,7 +322,7 @@ namespace LibationAvalonia.ViewModels { var filterResults = SOURCE.FilterEntries(FilterString); - if (filterResults is not null && FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count) + if (filterResults is not null && (FilteredInGridEntries is null || FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count)) { FilteredInGridEntries = filterResults; await refreshGrid(); diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 463ce41a..8dd178d5 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -56,9 +56,6 @@ namespace LibationWinForms.GridView } } - /// When true, itms will not be checked filtered by search criteria on item changed - public bool SuspendFilteringOnUpdate { get; set; } - protected RowComparer Comparer { get; } = new(); protected override bool SupportsSortingCore => true; protected override bool SupportsSearchingCore => true; @@ -112,7 +109,7 @@ namespace LibationWinForms.GridView foreach (var newRemove in toRemoveEntries) { FilterRemoved.Add(newRemove); - Items.Remove(newRemove); + base.Remove(newRemove); } } @@ -138,7 +135,7 @@ namespace LibationWinForms.GridView continue; FilterRemoved.Remove(addBack); - Items.Add(addBack); + Add(addBack); } } } @@ -147,7 +144,7 @@ namespace LibationWinForms.GridView { var filterResults = AllItems().FilterEntries(FilterString); - if (filterResults is not null && FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count) + if (filterResults is not null && (FilteredInGridEntries is null || FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count)) { FilteredInGridEntries = filterResults; refreshEntries(); @@ -181,7 +178,7 @@ namespace LibationWinForms.GridView { var sindex = Items.IndexOf(sEntry); - foreach (var episode in sEntry.Children.Intersect(FilterRemoved.BookEntries()).ToList()) + foreach (var episode in Comparer.OrderEntries(sEntry.Children.Intersect(FilterRemoved.BookEntries())).ToList()) { if (FilteredInGridEntries?.Contains(episode) ?? true) { diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index ba24cd8e..95d287e5 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -195,8 +195,6 @@ namespace LibationWinForms.GridView string existingFilter = syncBindingSource.Filter; Filter(null); - bindingList.SuspendFilteringOnUpdate = true; - //Add absent entries to grid, or update existing entry var allEntries = bindingList.AllItems().BookEntries(); @@ -219,8 +217,6 @@ namespace LibationWinForms.GridView } } - bindingList.SuspendFilteringOnUpdate = false; - //Re-apply filter after adding new/updating existing books to capture any changes Filter(existingFilter); From 1939aae81c95b613756ab5ff513cb92b3d8642cd Mon Sep 17 00:00:00 2001 From: Mbucari <37587114+Mbucari@users.noreply.github.com> Date: Mon, 10 Apr 2023 18:51:11 -0600 Subject: [PATCH 7/7] Simplify and comment --- .../ViewModels/ProductsDisplayViewModel.cs | 2 +- .../GridView/QueryExtensions.cs | 6 +++ .../GridView/GridEntryBindingList.cs | 39 +++++++------------ 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index fdb94086..8f6f1485 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -322,7 +322,7 @@ namespace LibationAvalonia.ViewModels { var filterResults = SOURCE.FilterEntries(FilterString); - if (filterResults is not null && (FilteredInGridEntries is null || FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count)) + if (FilteredInGridEntries.SearchSetsDiffer(filterResults)) { FilteredInGridEntries = filterResults; await refreshGrid(); diff --git a/Source/LibationUiBase/GridView/QueryExtensions.cs b/Source/LibationUiBase/GridView/QueryExtensions.cs index 827d791b..77cf2a8a 100644 --- a/Source/LibationUiBase/GridView/QueryExtensions.cs +++ b/Source/LibationUiBase/GridView/QueryExtensions.cs @@ -41,6 +41,12 @@ namespace LibationUiBase.GridView } } + public static bool SearchSetsDiffer(this HashSet? searchSet, HashSet? otherSet) + => searchSet is null != otherSet is null || + (searchSet is not null && + otherSet is not null && + searchSet.Intersect(otherSet).Count() != searchSet.Count); + public static HashSet? FilterEntries(this IEnumerable entries, string searchString) { if (string.IsNullOrEmpty(searchString)) return null; diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 8dd178d5..25f878ff 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -18,17 +18,21 @@ namespace LibationWinForms.GridView * * Remove is overridden to ensure that removed items are removed from * the base list (visible items) as well as the FilterRemoved list. + * + * Using BindingList.Add/Insert and BindingList.Remove will cause the + * BindingList to subscribe/unsibscribe to/from the item's PropertyChanged + * event. Adding or removing from the underlying list will not change the + * BindingList's subscription to that item. */ internal class GridEntryBindingList : BindingList, IBindingListView { public GridEntryBindingList(IEnumerable enumeration) : base(new List(enumeration)) { SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; - + ListChanged += GridEntryBindingList_ListChanged; refreshEntries(); } - /// All items in the list, including those filtered out. public List AllItems() => Items.Concat(FilterRemoved).ToList(); @@ -51,7 +55,7 @@ namespace LibationWinForms.GridView if (Items.Count + FilterRemoved.Count == 0) return; - FilteredInGridEntries = Items.Concat(FilterRemoved).FilterEntries(FilterString); + FilteredInGridEntries = AllItems().FilterEntries(FilterString); refreshEntries(); } } @@ -61,18 +65,16 @@ namespace LibationWinForms.GridView protected override bool SupportsSearchingCore => true; protected override bool IsSortedCore => isSorted; protected override PropertyDescriptor SortPropertyCore => propertyDescriptor; - protected override ListSortDirection SortDirectionCore => listSortDirection; + protected override ListSortDirection SortDirectionCore => Comparer.SortOrder; /// Items that were removed from the base list due to filtering private readonly List FilterRemoved = new(); private string FilterString; private bool isSorted; - private ListSortDirection listSortDirection; private PropertyDescriptor propertyDescriptor; /// All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice) private HashSet FilteredInGridEntries; - #region Unused - Advanced Filtering public bool SupportsAdvancedSorting => false; @@ -113,15 +115,7 @@ namespace LibationWinForms.GridView } } - if (IsSortedCore) - Sort(); - else - { - //No user sort is applied, so do default sorting by DateAdded, descending - Comparer.PropertyName = nameof(IGridEntry.DateAdded); - Comparer.SortOrder = ListSortDirection.Descending; - Sort(); - } + SortInternal(); OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); @@ -144,7 +138,7 @@ namespace LibationWinForms.GridView { var filterResults = AllItems().FilterEntries(FilterString); - if (filterResults is not null && (FilteredInGridEntries is null || FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count)) + if (FilteredInGridEntries.SearchSetsDiffer(filterResults)) { FilteredInGridEntries = filterResults; refreshEntries(); @@ -201,16 +195,15 @@ namespace LibationWinForms.GridView Comparer.PropertyName = property.Name; Comparer.SortOrder = direction; - Sort(); + SortInternal(); propertyDescriptor = property; - listSortDirection = direction; isSorted = true; OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); } - private void Sort() + private void SortInternal() { var itemsList = (List)Items; //User Order/OrderDescending and replace items in list instead of using List.Sort() to achieve stable sorting. @@ -220,19 +213,17 @@ namespace LibationWinForms.GridView itemsList.AddRange(sortedItems); } - protected override void OnListChanged(ListChangedEventArgs e) + private void GridEntryBindingList_ListChanged(object sender, ListChangedEventArgs e) { - if (e.ListChangedType == ListChangedType.ItemChanged && isSorted && e.PropertyDescriptor == SortPropertyCore) + if (e.ListChangedType == ListChangedType.ItemChanged && IsSortedCore && e.PropertyDescriptor == SortPropertyCore) refreshEntries(); - else - base.OnListChanged(e); } protected override void RemoveSortCore() { isSorted = false; propertyDescriptor = base.SortPropertyCore; - listSortDirection = base.SortDirectionCore; + Comparer.SortOrder = base.SortDirectionCore; OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); }