Merge pull request #579 from Mbucari/master

Bug fixes and more shared code moved to UI base
This commit is contained in:
rmcrackan 2023-04-10 22:47:24 -04:00 committed by GitHub
commit 1a95f2923b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 247 additions and 219 deletions

View File

@ -23,7 +23,7 @@ namespace LibationAvalonia.ViewModels
/// <summary>Backing list of all grid entries</summary>
private readonly AvaloniaList<IGridEntry> SOURCE = new();
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private List<IGridEntry> FilteredInGridEntries;
private HashSet<IGridEntry> FilteredInGridEntries;
public string FilterString { get; private set; }
public DataGridCollectionView GridEntries { get; private set; }
@ -117,8 +117,8 @@ namespace LibationAvalonia.ViewModels
}
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = QueryResults(geList.Union(geList.OfType<ISeriesEntry>().SelectMany(s => s.Children)), FilterString);
SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded));
FilteredInGridEntries = geList.Union(geList.OfType<ISeriesEntry>().SelectMany(s => s.Children)).FilterEntries(FilterString);
SOURCE.AddRange(geList.OrderDescending(new RowComparer(null)));
//Add all children beneath their parent
foreach (var series in SOURCE.OfType<ISeriesEntry>().ToList())
@ -301,7 +301,7 @@ namespace LibationAvalonia.ViewModels
if (SOURCE.Count == 0)
return;
FilteredInGridEntries = QueryResults(SOURCE, searchString);
FilteredInGridEntries = SOURCE.FilterEntries(searchString);
await refreshGrid();
}
@ -318,25 +318,11 @@ namespace LibationAvalonia.ViewModels
return FilteredInGridEntries.Contains(item);
}
private static List<IGridEntry> QueryResults(IEnumerable<IGridEntry> 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)
if (FilteredInGridEntries.SearchSetsDiffer(filterResults))
{
FilteredInGridEntries = filterResults;
await refreshGrid();

View File

@ -1,98 +1,28 @@
using Avalonia.Controls;
using LibationUiBase.GridView;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Reflection;
namespace LibationAvalonia.ViewModels
{
/// <summary>
/// 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.
/// </summary>
internal class RowComparer : IComparer, IComparer<IGridEntry>, IComparer<object>
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)
{
Column = column;
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);
PropertyName = Column?.SortMemberPath ?? nameof(IGridEntry.DateAdded);
}
//Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection
private 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);
}
protected override ListSortDirection GetSortOrder()
=> Column is null ? ListSortDirection.Descending
: CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) is ListSortDirection lsd ? lsd
: ListSortDirection.Descending;
}
}

View File

@ -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; }
private Book Book { get; }
private DateTime lastBookUpdate;
private LiberatedStatus bookStatus;

View File

@ -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),
//EntryStatu's 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

View File

@ -1,4 +1,5 @@
using DataLayer;
using ApplicationServices;
using DataLayer;
using System;
using System.Collections.Generic;
using System.Linq;
@ -39,6 +40,26 @@ namespace LibationUiBase.GridView
return null;
}
}
public static bool SearchSetsDiffer(this HashSet<IGridEntry>? searchSet, HashSet<IGridEntry>? otherSet)
=> searchSet is null != otherSet is null ||
(searchSet is not null &&
otherSet is not null &&
searchSet.Intersect(otherSet).Count() != searchSet.Count);
public static HashSet<IGridEntry>? FilterEntries(this IEnumerable<IGridEntry> 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
}

View File

@ -0,0 +1,91 @@
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
/// <summary>
/// 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.
/// </summary>
public abstract class RowComparerBase : IComparer, IComparer<IGridEntry>, IComparer<object>
{
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)
{
//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..
return PropertyName switch
{
nameof(IGridEntry.DateAdded) or nameof (IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder),
_ => 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);
}
}
}

View File

@ -47,7 +47,7 @@ namespace LibationUiBase.GridView
Children = children
.Select(c => new LibraryBookEntry<TStatus>(c, this))
.OrderBy(c => c.SeriesIndex)
.OrderByDescending(c => c.SeriesOrder)
.ToList<ILibraryBookEntry>();
UpdateLibraryBook(parent);

View File

@ -1,6 +1,4 @@
using ApplicationServices;
using Dinah.Core.DataBinding;
using LibationSearchEngine;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
@ -20,51 +18,62 @@ 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<IGridEntry>, IBindingListView
{
public GridEntryBindingList() : base(new List<IGridEntry>()) { }
public GridEntryBindingList(IEnumerable<IGridEntry> enumeration) : base(new List<IGridEntry>(enumeration))
{
SearchEngineCommands.SearchEngineUpdated += (_,_) => ApplyFilter(FilterString);
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
ListChanged += GridEntryBindingList_ListChanged;
refreshEntries();
}
/// <returns>All items in the list, including those filtered out.</returns>
public List<IGridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
/// <summary>All items that pass the current filter</summary>
public IEnumerable<ILibraryBookEntry> GetFilteredInItems()
=> SearchResults is null
? FilterRemoved
=> FilteredInGridEntries?
.OfType<ILibraryBookEntry>()
.Union(Items.OfType<ILibraryBookEntry>())
: FilterRemoved
?? FilterRemoved
.OfType<ILibraryBookEntry>()
.Join(SearchResults.Docs, o => o.Book.AudibleProductId, i => i.ProductId, (o, _) => o)
.Union(Items.OfType<ILibraryBookEntry>());
public bool SupportsFiltering => true;
public string Filter { get => FilterString; set => ApplyFilter(value); }
public string Filter
{
get => FilterString;
set
{
FilterString = value;
/// <summary>When true, itms will not be checked filtered by search criteria on item changed<summary>
public bool SuspendFilteringOnUpdate { get; set; }
if (Items.Count + FilterRemoved.Count == 0)
return;
protected MemberComparer<IGridEntry> Comparer { get; } = new();
FilteredInGridEntries = AllItems().FilterEntries(FilterString);
refreshEntries();
}
}
protected RowComparer Comparer { get; } = new();
protected override bool SupportsSortingCore => true;
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;
/// <summary> Items that were removed from the base list due to filtering </summary>
private readonly List<IGridEntry> FilterRemoved = new();
private string FilterString;
private SearchResultSet SearchResults;
private bool isSorted;
private ListSortDirection listSortDirection;
private PropertyDescriptor propertyDescriptor;
/// <summary> 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)</summary>
private HashSet<IGridEntry> FilteredInGridEntries;
#region Unused - Advanced Filtering
public bool SupportsAdvancedSorting => false;
@ -82,25 +91,57 @@ namespace LibationWinForms.GridView
base.Remove(entry);
}
private void ApplyFilter(string filterString)
/// <summary>
/// This method should be called whenever there's been a change to the
/// set of all GridEntries that affects sort order or filter status
/// </summary>
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);
base.Remove(newRemove);
}
}
SortInternal();
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
void addRemovedItemsBack(List<IGridEntry> 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);
Add(addBack);
}
}
}
private void SearchEngineCommands_SearchEngineUpdated(object sender, EventArgs e)
{
var filterResults = AllItems().FilterEntries(FilterString);
if (FilteredInGridEntries.SearchSetsDiffer(filterResults))
{
FilteredInGridEntries = filterResults;
refreshEntries();
}
}
@ -118,7 +159,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 +172,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 Comparer.OrderEntries(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,106 +185,45 @@ 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();
SortInternal();
propertyDescriptor = property;
listSortDirection = direction;
isSorted = true;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}
protected void Sort()
private void SortInternal()
{
var itemsList = (List<IGridEntry>)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.OrderEntries(itemsList).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)
private void GridEntryBindingList_ListChanged(object sender, 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 && IsSortedCore && e.PropertyDescriptor == SortPropertyCore)
refreshEntries();
}
protected override void RemoveSortCore()
{
isSorted = false;
propertyDescriptor = base.SortPropertyCore;
listSortDirection = base.SortDirectionCore;
Comparer.SortOrder = base.SortDirectionCore;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}

View File

@ -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());
@ -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);

View File

@ -0,0 +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; } = ListSortDirection.Descending;
public override string PropertyName { get; set; } = nameof(IGridEntry.DateAdded);
protected override ListSortDirection GetSortOrder() => SortOrder;
/// <summary>
/// Helper method for ordering grid entries
/// </summary>
public IOrderedEnumerable<IGridEntry> OrderEntries(IEnumerable<IGridEntry> entries)
=> SortOrder is ListSortDirection.Descending ? entries.OrderDescending(this) : entries.Order(this);
}
}