diff --git a/Source/LibationWinForms/SyncBindingSource.cs b/Source/LibationWinForms/SyncBindingSource.cs index c11307ec..4aa589ef 100644 --- a/Source/LibationWinForms/SyncBindingSource.cs +++ b/Source/LibationWinForms/SyncBindingSource.cs @@ -21,31 +21,7 @@ namespace LibationWinForms => syncContext = SynchronizationContext.Current; public override bool SupportsFiltering => true; - public override string Filter { get => FilterString; set => SetFilter(value); } - private string FilterString; - - private void SetFilter(string filterString) - { - if (filterString != FilterString) - RemoveFilter(); - - FilterString = filterString; - - var searchResults = SearchEngineCommands.Search(filterString); - var productIds = searchResults.Docs.Select(d => d.ProductId).ToList(); - - var allItems = ((SortableBindingList)DataSource).InnerList; - var filterList = productIds.Join(allItems, s => s, ge => ge.AudibleProductId, (pid, ge) => ge).ToList(); - - ((SortableBindingList)DataSource).SetFilteredItems(filterList); - } - - public override void RemoveFilter() - { - ((SortableBindingList)DataSource).RemoveFilter(); - base.RemoveFilter(); - } protected override void OnListChanged(ListChangedEventArgs e) { if (syncContext is not null) diff --git a/Source/LibationWinForms/grid/ProductsGrid.cs b/Source/LibationWinForms/grid/ProductsGrid.cs index 9fd4836a..a654d960 100644 --- a/Source/LibationWinForms/grid/ProductsGrid.cs +++ b/Source/LibationWinForms/grid/ProductsGrid.cs @@ -159,7 +159,7 @@ namespace LibationWinForms #region UI display functions - private SortableBindingList bindingList; + private SortableFilterableBindingList bindingList; private bool hasBeenDisplayed; public event EventHandler InitialLoaded; @@ -168,69 +168,56 @@ namespace LibationWinForms // don't return early if lib size == 0. this will not update correctly if all books are removed var lib = DbContexts.GetLibrary_Flat_NoTracking(); - var orderedBooks = lib - // default load order - .OrderByDescending(lb => lb.DateAdded) - //// more advanced example: sort by author, then series, then title - //.OrderBy(lb => lb.Book.AuthorNames) - // .ThenBy(lb => lb.Book.SeriesSortable) - // .ThenBy(lb => lb.Book.TitleSortable) - .ToList(); - - // bind - if (bindingList?.Count > 0) - updateGrid(orderedBooks); - else - bindToGrid(orderedBooks); - - if (!hasBeenDisplayed) { + // bind + bindToGrid(lib); hasBeenDisplayed = true; InitialLoaded?.Invoke(this, new()); + VisibleCountChanged?.Invoke(this, bindingList.Count); } + else + updateGrid(lib); } - private void bindToGrid(List orderedBooks) + private void bindToGrid(List orderedBooks) { - bindingList = new SortableBindingList(orderedBooks.Select(lb => toGridEntry(lb))); + bindingList = new SortableFilterableBindingList(orderedBooks.OrderByDescending(lb => lb.DateAdded).Select(lb => new GridEntry(lb))); gridEntryBindingSource.DataSource = bindingList; } - private void updateGrid(List orderedBooks) + private void updateGrid(List dbBooks) { - for (var i = orderedBooks.Count - 1; i >= 0; i--) + int visibleCount = bindingList.Count; + + //Add absent books to grid, or update current books + for (var i = dbBooks.Count - 1; i >= 0; i--) { - var libraryBook = orderedBooks[i]; - var existingItem = bindingList.FirstOrDefault(i => i.AudibleProductId == libraryBook.Book.AudibleProductId); + var libraryBook = dbBooks[i]; + var existingItem = bindingList.AllItems.FirstOrDefault(i => i.AudibleProductId == libraryBook.Book.AudibleProductId); // add new to top if (existingItem is null) - bindingList.Insert(0, toGridEntry(libraryBook)); + bindingList.Insert(0, new GridEntry(libraryBook)); // update existing else existingItem.UpdateLibraryBook(libraryBook); } - // remove deleted from grid. note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this - var oldIds = bindingList.Select(ge => ge.AudibleProductId).ToList(); - var newIds = orderedBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - var remove = oldIds.Except(newIds).ToList(); - foreach (var id in remove) - { - var oldItem = bindingList.FirstOrDefault(ge => ge.AudibleProductId == id); - if (oldItem is not null) - bindingList.Remove(oldItem); - } - } + // remove deleted from grid. + // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this + var removedBooks = + bindingList + .AllItems + .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId) + .ToList(); - private GridEntry toGridEntry(DataLayer.LibraryBook libraryBook) - { - var entry = new GridEntry(libraryBook); - // see also notes in Libation/Source/__ARCHITECTURE NOTES.txt :: MVVM - entry.LibraryBookUpdated += (sender, _) => _dataGridView.InvalidateRow(_dataGridView.GetRowIdOfBoundItem((GridEntry)sender)); - return entry; + foreach (var removed in removedBooks) + bindingList.Remove(removed); + + if (bindingList.Count != visibleCount) + VisibleCountChanged?.Invoke(this, bindingList.Count); } #endregion @@ -239,23 +226,21 @@ namespace LibationWinForms public void Filter(string searchString) { - if (_dataGridView.Rows.Count == 0) - return; + int visibleCount = bindingList.Count; if (string.IsNullOrEmpty(searchString)) gridEntryBindingSource.RemoveFilter(); else gridEntryBindingSource.Filter = searchString; - - VisibleCountChanged?.Invoke(this, bindingList.Count); + if (visibleCount != bindingList.Count) + VisibleCountChanged?.Invoke(this, bindingList.Count); } #endregion internal List GetVisible() => bindingList - .InnerList .Select(row => row.LibraryBook) .ToList(); diff --git a/Source/LibationWinForms/grid/SortableFilterableBindingList.cs b/Source/LibationWinForms/grid/SortableFilterableBindingList.cs new file mode 100644 index 00000000..dd69d1b9 --- /dev/null +++ b/Source/LibationWinForms/grid/SortableFilterableBindingList.cs @@ -0,0 +1,126 @@ +using ApplicationServices; +using Dinah.Core.DataBinding; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms +{ + /* + * Allows filtering of the underlying SortableBindingList + * by implementing IBindingListView aud using SearchEngineCommands + * + * When filtering is applied, the filtered-out items are removed + * from the base list and added to the private FilterRemoved list. + * All items, filtered or not, are stored in the private AllItems + * list. When filtering is removed, items in the FilterRemoved list + * are added back to the base list. + * + * Remove and InsertItem are overridden to ensure that the current + * filter remains applied when items are removed/added to the list. + */ + internal class SortableFilterableBindingList : SortableBindingList, IBindingListView + { + /// + /// Items that were removed from the list due to filtering + /// + private readonly List FilterRemoved = new(); + /// + /// Tracks all items in the list, both filtered and not. + /// + public readonly List AllItems; + private string FilterString; + private Action Sort; + public SortableFilterableBindingList(IEnumerable enumeration) : base(enumeration) + { + AllItems = new List(Items); + + //This is only necessary because SortableBindingList doesn't expose Sort() + //You should make SortableBindingList.Sort protected and remove reflection + var method = typeof(SortableBindingList).GetMethod("Sort", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Sort = method.CreateDelegate(this); + } + + public bool SupportsFiltering => true; + public string Filter { get => FilterString; set => ApplyFilter(value); } + + #region Unused - Advanced Filtering + public bool SupportsAdvancedSorting => false; + + + //This ApplySort overload is only called is SupportsAdvancedSorting is true. + //Otherwise BindingList.ApplySort() is used + public void ApplySort(ListSortDescriptionCollection sorts) => throw new NotImplementedException(); + + public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException(); + #endregion + + public new void Remove(GridEntry entry) + { + AllItems.Remove(entry); + FilterRemoved.Remove(entry); + base.Remove(entry); + } + + protected override void InsertItem(int index, GridEntry item) + { + AllItems.Insert(index, item); + + if (FilterString is not null) + { + var searchResults = SearchEngineCommands.Search(FilterString); + //Decide if the new item matches the filter, and either insert it in the + //displayed items or the filtered out items list + if (searchResults.Docs.Any(d => d.ProductId == item.AudibleProductId)) + base.InsertItem(index, item); + else + FilterRemoved.Add(item); + } + else + base.InsertItem(index, item); + } + + private void ApplyFilter(string filterString) + { + if (filterString != FilterString) + RemoveFilter(); + + FilterString = filterString; + + var searchResults = SearchEngineCommands.Search(filterString); + var productIds = searchResults.Docs.Select(d => d.ProductId).ToList(); + + for (int i = Items.Count - 1; i >= 0; i--) + { + if (!productIds.Contains(Items[i].AudibleProductId)) + { + FilterRemoved.Add(Items[i]); + Items.RemoveAt(i); + base.OnListChanged(new ListChangedEventArgs(ListChangedType.ItemDeleted, i)); + } + } + } + + public void RemoveFilter() + { + if (FilterString is null) return; + + for (int i = 0; i < FilterRemoved.Count; i++) + { + Items.Insert(i, FilterRemoved[i]); + base.OnListChanged(new ListChangedEventArgs(ListChangedType.ItemAdded, i)); + } + + FilterRemoved.Clear(); + + if (IsSortedCore) + Sort(); + else + //No user-defined sort is applied, so do default sorting by date added, descending + ((List)Items).Sort((i1,i2) =>i2.LibraryBook.DateAdded.CompareTo(i1.LibraryBook.DateAdded)); + + FilterString = null; + } + } +}