From a01a8c4b19decb10f1eb55032f5ad39d92beb0ed Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 7 Dec 2022 10:15:12 -0700 Subject: [PATCH 01/11] Update Avalonia to v.11-Preview-4 --- Source/LibationAvalonia/App.axaml | 6 ++--- .../Controls/LinkLabel.axaml.cs | 8 +++--- .../Dialogs/BookDetailsDialog.axaml.cs | 3 ++- .../Login/LoginChoiceEagerDialog.axaml.cs | 2 +- .../MessageBoxAlertAdminDialog.axaml.cs | 4 +-- .../Dialogs/MessageBoxWindow.axaml | 2 +- .../LibationAvalonia/LibationAvalonia.csproj | 20 ++++++++------- .../ViewModels/ProductsDisplayViewModel.cs | 1 + .../Views/MainWindow/MainWindow.axaml | 23 ++++++++++++------ .../Views/ProductsDisplay.axaml | 8 +++--- .../Views/ProductsDisplay.axaml.cs | 9 ++++--- .../Visualizers/attribcache140.bin | Bin 0 -> 7853 bytes 12 files changed, 48 insertions(+), 38 deletions(-) create mode 100644 Source/Visual Studio 2022/Visualizers/attribcache140.bin diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index cbfd5ab2..9e8d399d 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -8,9 +8,9 @@ - - - + + + diff --git a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs index 0d75021e..30b0d74a 100644 --- a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs +++ b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs @@ -15,15 +15,15 @@ namespace LibationAvalonia.Controls { InitializeComponent(); } - protected override void OnPointerEnter(PointerEventArgs e) + protected override void OnPointerEntered(PointerEventArgs e) { this.Cursor = HandCursor; - base.OnPointerEnter(e); + base.OnPointerEntered(e); } - protected override void OnPointerLeave(PointerEventArgs e) + protected override void OnPointerExited(PointerEventArgs e) { this.Cursor = Cursor.Default; - base.OnPointerLeave(e); + base.OnPointerExited(e); } private void InitializeComponent() diff --git a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs index 180c736a..a48d2075 100644 --- a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs @@ -10,6 +10,7 @@ using LibationAvalonia.ViewModels; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System; namespace LibationAvalonia.Dialogs { @@ -54,7 +55,7 @@ namespace LibationAvalonia.Dialogs base.SaveAndClose(); } - public void GoToAudible_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e) + public void GoToAudible_Tapped(object sender, Avalonia.Input.TappedEventArgs e) { var locale = AudibleApi.Localization.Get(_libraryBook.Book.Locale); var link = $"https://www.audible.{locale.TopDomain}/pd/{_libraryBook.Book.AudibleProductId}"; diff --git a/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs index 4871ab16..bbc873d9 100644 --- a/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs @@ -31,7 +31,7 @@ namespace LibationAvalonia.Dialogs.Login DataContext = this; } - public async void ExternalLoginLink_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e) + public async void ExternalLoginLink_Tapped(object sender, Avalonia.Input.TappedEventArgs e) { LoginMethod = LoginMethod.External; await SaveAndCloseAsync(); diff --git a/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs index e139605b..df263867 100644 --- a/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs @@ -28,7 +28,7 @@ namespace LibationAvalonia.Dialogs DataContext = this; } - private async void GoToGithub_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e) + private async void GoToGithub_Tapped(object sender, Avalonia.Input.TappedEventArgs e) { var url = "https://github.com/rmcrackan/Libation/issues"; try @@ -41,7 +41,7 @@ namespace LibationAvalonia.Dialogs } } - private async void GoToLogs_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e) + private async void GoToLogs_Tapped(object sender, Avalonia.Input.TappedEventArgs e) { LongPath dir = ""; try diff --git a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml index 416eebb8..293a6218 100644 --- a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml +++ b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml @@ -6,7 +6,7 @@ mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110" MinWidth="265" MinHeight="110" x:Class="LibationAvalonia.Dialogs.MessageBoxWindow" - Title="{Binding Caption}" HasSystemDecorations="True" ShowInTaskbar="True" + Title="{Binding Caption}" IsExtendedIntoWindowDecorations="True" ShowInTaskbar="True" Icon="/Assets/1x1.png"> diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index 41b319df..9e38445c 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -128,17 +128,19 @@ + + + + + + + + + + - - - - - - - - - + Always diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 8468ba55..af318103 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -13,6 +13,7 @@ using ApplicationServices; using AudibleUtilities; using LibationAvalonia.Views; using LibationAvalonia.Dialogs.Login; +using Avalonia.Collections; namespace LibationAvalonia.ViewModels { diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml index 37a28f7b..d0b9a900 100644 --- a/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml @@ -12,7 +12,7 @@ Name="Form1" Icon="/Assets/libation.ico"> - + @@ -143,23 +143,30 @@ + - - + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index b5a7c532..6f015b8c 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -29,6 +29,7 @@ namespace LibationAvalonia.Views using var context = DbContexts.GetContext(); List sampleEntries = new() { + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU")), new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G")), new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG")), new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q")), @@ -40,8 +41,8 @@ namespace LibationAvalonia.Views DataContext = new ProductsDisplayViewModel(sampleEntries); return; } - Configure_ColumnCustomization(); + foreach (var column in productsGrid.Columns) { column.CustomSortComparer = new RowComparer(column); @@ -198,7 +199,7 @@ namespace LibationAvalonia.Views //Expanding and collapsing reset the list, which will cause focus to shift //to the topright cell. Reset focus onto the clicked button's cell. - ((sender as Control).Parent.Parent as DataGridCell)?.Focus(); + (sender as Button).Parent?.Focus(); } else if (button.DataContext is LibraryBookEntry lbEntry) { @@ -212,7 +213,7 @@ namespace LibationAvalonia.Views imageDisplayDialog.Close(); } - public void Cover_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) { if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry) return; @@ -252,7 +253,7 @@ namespace LibationAvalonia.Views imageDisplayDialog.Show(); } - public void Description_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args) { if (sender is TextBlock tblock && tblock.DataContext is GridEntry gEntry) { diff --git a/Source/Visual Studio 2022/Visualizers/attribcache140.bin b/Source/Visual Studio 2022/Visualizers/attribcache140.bin new file mode 100644 index 0000000000000000000000000000000000000000..06f7ff3fe7ebfd35eaa43d08130aa0b24697139b GIT binary patch literal 7853 zcmeHMTXWk)6qXt2bXqQ@H=0sFEu}ykOw$WxI%Jq6jvLclOzd1rUFcd~J4hQ+GcTXontAQ*+&{nk zdF+^RveNYgZRZO_kmG+mt8LHR`S;gXjaLlAI1`zY^A#$V)|kaz&uxnQHuHR9SA^eS zZoWY4eyc^fUKdQVXK5q5xEJt>tl28fn%k6n%ys7H?&WXd->kXh+rsB`-l4wW#GW(`pNI z00o}mToU*ysoV4=^k869gZ?Z+FL6zPe>MsJHlv3xv7aXPXApaYH39v*Y0$3`hk#>w zsex%~;G))mLN}s_<89A!xy|ZfG`Jw6!40LHCD*p8CBRdA`3KaYoLTwOvg5ZYM=$ks z?s4_W!*NbHLNIWBQ8t6oTKd}QMT~E!C~Ocpn&~1>J9?atY!gu+1duSQSGyf*!eN1c~gG-X3@88J)+zFJ`x(AW~dng?>4c0S|*G?y=CNPYQ4uj7@T!}>d%@zIfv~h z0jpDr)$)-=J7_RQ?)6%)w?;h=ZnWA@-}!}32SPi+WtrQ&a@8_BReFZ`$r*rcmeA9>FJbskS`4Q>!kCYT%uSS>NyB0=!)b3omWti_Fd@ z#RU=4P>%s=itgh(C&)ijhXe4G%gzs)DaRPTotHH{)bcqK-F!tgGLd>qQ5C37TSR~d zI$MKlk7`FB6tn=HlRM05xQE^%@#yinzrNi5?ThuhIT>yA$<+#|FFF*A*|EAC^&cVp zd^OH#?{u0~PU#?ysCorUlHK9S>4lST{=a0=z#t1K6_xA5az>XL7lcPEeF7M3B?99%LXcM?;bg#Xd~il~`q$pOJ{m2`CV?jx4FKq6LXKGi5*yqr;YQA^^(x@DEkPSlBgmXF(kDu| ztD}WOh_r0!%DEmVUJ-3OR4aB$PRAFT1zcd6SEAj@hLS+XHih;O6IGfh#zyX9tyglO zTG3dA9mVhCF+w5T0;fcpaH>R~jNu@Ug=|K%n4N^hz?dMfPe?M_oIam~bCaR@pyjMEID zyqa*6_VtV-j`vu(sdsn``6uXfdCN8HBYr6IE4`5Hbwpk1QOs;TI&X=fLc8rGCKR;dFR11126 z%qL+1HZ$SF@U{dm75g#x;G*LM8Z=6y+lrH}eB;NN&`YFafyDTNikcYZDbjt^n(KQ} zOM?6%ks+Hw?8SowEp?1Ae~6*FjxT@A+UgYCL&(RT*Y?weg_kCH;u+}c3S|E_>x79i z$`~6^qK1iMOw=-WTWA0;bV<`$$0FzCDcJ~S%-S2{8`EPT_#lc!()YFAB0ds%QirnD zH+NjV59J&tF*qFQ_RTV9ZNj@au9|(bKo6KDUn6jM&{GhnD=f$r;SoS zAsc;j*^${Ud=#hjI9P%AKp`l&(^ZI~*eSan!}mcHCpG}?l)gVx-Cbscu|9q zS~T51=p7^W204%%l{X}4^-cMe)nw38$4I~BWAvzB4&$z&%HU2JIq)ha2Ot1Hk7v;B hOxUU%p1xklz|+-%Zv^XZ1EPL Date: Fri, 9 Dec 2022 12:27:52 -0700 Subject: [PATCH 02/11] Refactor ProductsDisplay --- .../Dialogs/MessageBoxWindow.axaml | 8 +- .../LibationAvalonia/LibationAvalonia.csproj | 3 +- .../ViewModels/GridEntryCollection.cs | 177 ------------ .../ViewModels/ProductsDisplayViewModel.cs | 258 +++++++----------- .../ViewModels/RowComparer.cs | 28 +- .../ViewModels/SeriesEntry.cs | 2 +- .../Views/MainWindow/MainWindow.axaml.cs | 7 +- .../Views/ProductsDisplay.axaml | 27 +- .../Views/ProductsDisplay.axaml.cs | 27 +- 9 files changed, 151 insertions(+), 386 deletions(-) delete mode 100644 Source/LibationAvalonia/ViewModels/GridEntryCollection.cs diff --git a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml index 293a6218..a911a335 100644 --- a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml +++ b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml @@ -6,7 +6,7 @@ mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110" MinWidth="265" MinHeight="110" x:Class="LibationAvalonia.Dialogs.MessageBoxWindow" - Title="{Binding Caption}" IsExtendedIntoWindowDecorations="True" ShowInTaskbar="True" + Title="{Binding Caption}" ShowInTaskbar="True" Icon="/Assets/1x1.png"> @@ -34,13 +34,13 @@ - - - diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index 9e38445c..d47d5916 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -136,7 +136,8 @@ - + + diff --git a/Source/LibationAvalonia/ViewModels/GridEntryCollection.cs b/Source/LibationAvalonia/ViewModels/GridEntryCollection.cs deleted file mode 100644 index 38427e3e..00000000 --- a/Source/LibationAvalonia/ViewModels/GridEntryCollection.cs +++ /dev/null @@ -1,177 +0,0 @@ -using ApplicationServices; -using LibationSearchEngine; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.Linq; - -namespace LibationAvalonia.ViewModels -{ - /* - * Allows filtering of the underlying ObservableCollection - * - * When filtering is applied, the filtered-out items are removed - * from the base list and added to the private FilterRemoved list. - * When filtering is removed, items in the FilterRemoved list are - * added back to the base list. - * - * Items are added and removed to/from the ObservableCollection's - * internal list instead of the ObservableCollection itself to - * avoid ObservableCollection firing CollectionChanged for every - * item. Editing the list this way improve's display performance, - * but requires ResetCollection() to be called after all changes - * have been made. - */ - public class GridEntryCollection : ObservableCollection - { - public GridEntryCollection(IEnumerable enumeration) - : base(new List(enumeration)) { } - public GridEntryCollection(List list) - : base(list) { } - - public List InternalList => Items as List; - /// All items in the list, including those filtered out. - public List AllItems() => Items.Concat(FilterRemoved).ToList(); - - /// When true, itms will not be checked filtered by search criteria on item changed - public bool SuspendFilteringOnUpdate { get; set; } - public string Filter { get => FilterString; set => ApplyFilter(value); } - - /// Items that were removed from the base list due to filtering - private readonly List FilterRemoved = new(); - private string FilterString; - private SearchResultSet SearchResults; - - #region Items Management - - /// - /// Removes all items from the collection, both visible and hidden, adds new items to the visible collection. - /// - public void ReplaceList(IEnumerable newItems) - { - Items.Clear(); - FilterRemoved.Clear(); - ((List)Items).AddRange(newItems); - ResetCollection(); - } - public void ResetCollection() - => OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - - #endregion - - #region Filtering - - - private void ApplyFilter(string filterString) - { - 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) => (GridEntry)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) - { - FilterRemoved.Add(item); - Items.Remove(item); - } - ResetCollection(); - } - - public void RemoveFilter() - { - if (FilterString is null) return; - - int visibleCount = Items.Count; - - foreach (var item in FilterRemoved.ToList()) - { - if (item is SeriesEntry || item is LibraryBookEntry lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded)) - { - - FilterRemoved.Remove(item); - Items.Insert(visibleCount++, item); - } - } - - FilterString = null; - SearchResults = null; - ResetCollection(); - } - - #endregion - - #region Expand/Collapse - - public void CollapseAll() - { - foreach (var series in Items.SeriesEntries().ToList()) - CollapseItem(series); - } - - public void ExpandAll() - { - foreach (var series in Items.SeriesEntries().ToList()) - ExpandItem(series); - } - - public void CollapseItem(SeriesEntry sEntry) - { - foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) - { - /* - * Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't - * fired. When adding or removing many items at once, Avalonia's CollectionChanged - * event handler causes serious performance problems. And unfotrunately, Avalonia - * doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) - * overload that would fire only once for all changed items. - * - * Doing this requires resetting the list so the view knows it needs to rebuild its display. - */ - - FilterRemoved.Add(episode); - Items.Remove(episode); - } - - sEntry.Liberate.Expanded = false; - ResetCollection(); - } - - public void ExpandItem(SeriesEntry sEntry) - { - var sindex = Items.IndexOf(sEntry); - - foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) - { - if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) - { - /* - * Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't - * fired. When adding or removing many items at once, Avalonia's CollectionChanged - * event handler causes serious performance problems. And unfotrunately, Avalonia - * doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) - * overload that would fire only once for all changed items. - * - * Doing this requires resetting the list so the view knows it needs to rebuild its display. - */ - - FilterRemoved.Remove(episode); - Items.Insert(++sindex, episode); - } - } - - sEntry.Liberate.Expanded = true; - ResetCollection(); - } - - #endregion - } -} diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index af318103..f45b1f57 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -1,4 +1,3 @@ -using Avalonia.Controls; using DataLayer; using System; using System.Collections.Generic; @@ -6,12 +5,9 @@ using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using ReactiveUI; -using System.Reflection; -using System.Collections; using Avalonia.Threading; using ApplicationServices; using AudibleUtilities; -using LibationAvalonia.Views; using LibationAvalonia.Dialogs.Login; using Avalonia.Collections; @@ -24,81 +20,35 @@ namespace LibationAvalonia.ViewModels public event EventHandler RemovableCountChanged; public event EventHandler InitialLoaded; - private DataGridColumn _currentSortColumn; - private DataGrid productsDataGrid; + /// Backing list of all grid entries + private readonly List SOURCE = new(); + /// Grid entries included in the filter set. If null, all grid entries are shown + private List FilteredInGridEntries; + public string FilterString { get; private set; } + public DataGridCollectionView GridEntries { get; } - private GridEntryCollection _gridEntries; private bool _removeColumnVisivle; - public GridEntryCollection GridEntries { get => _gridEntries; private set => this.RaiseAndSetIfChanged(ref _gridEntries, value); } public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); } public List GetVisibleBookEntries() - => GridEntries.InternalList + => GridEntries + .Cast() .BookEntries() .Select(lbe => lbe.LibraryBook) .ToList(); - public IEnumerable GetAllBookEntries() - => GridEntries - .AllItems() + + private IEnumerable GetAllBookEntries() + => SOURCE .BookEntries(); - public ProductsDisplayViewModel() { } - public ProductsDisplayViewModel(List items) + + public ProductsDisplayViewModel() { - GridEntries = new GridEntryCollection(items); + GridEntries = new(SOURCE); + GridEntries.Filter = CollectionFilter; } #region Display Functions - /// - /// Call once on load so we can modify access a private member with reflection - /// - public void RegisterCollectionChanged(ProductsDisplay productsDisplay = null) - { - productsDataGrid ??= productsDisplay?.productsGrid; - - if (GridEntries is null) - return; - - //Avalonia displays items in the DataConncetion from an internal copy of - //the bound list, not the actual bound list. So we need to reflect to get - //the current display order and set each GridEntry.ListIndex correctly. - var DataConnection_PI = typeof(DataGrid).GetProperty("DataConnection", BindingFlags.NonPublic | BindingFlags.Instance); - var DataSource_PI = DataConnection_PI.PropertyType.GetProperty("DataSource", BindingFlags.Public | BindingFlags.Instance); - - GridEntries.CollectionChanged += (s, e) => - { - if (s != GridEntries) return; - - var displayListGE = ((IEnumerable)DataSource_PI.GetValue(DataConnection_PI.GetValue(productsDataGrid))).Cast(); - int index = 0; - foreach (var di in displayListGE) - { - di.ListIndex = index++; - } - }; - } - - /// - /// Only call once per lifetime - /// - public void InitialDisplay(List dbBooks) - { - try - { - GridEntries = new GridEntryCollection(CreateGridEntries(dbBooks)); - GridEntries.CollapseAll(); - - InitialLoaded?.Invoke(this, EventArgs.Empty); - VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); - - RegisterCollectionChanged(); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel)); - } - } - /// /// Call when there's been a change to the library /// @@ -106,29 +56,25 @@ namespace LibationAvalonia.ViewModels { try { - //List is already displayed. Replace all items with new ones, refilter, and re-sort - string existingFilter = GridEntries?.Filter; - var newEntries = CreateGridEntries(dbBooks); + var existingSeriesEntries = SOURCE.SeriesEntries().ToList(); - var existingSeriesEntries = GridEntries.AllItems().SeriesEntries().ToList(); + SOURCE.Clear(); + SOURCE.AddRange(CreateGridEntries(dbBooks)); - await Dispatcher.UIThread.InvokeAsync(() => + //If replacing the list, preserve user's existing collapse/expand + //state. When resetting a list, default state is cosed. + foreach (var series in existingSeriesEntries) { - GridEntries.ReplaceList(newEntries); + var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId); + if (sEntry is SeriesEntry se) + se.Liberate.Expanded = series.Liberate.Expanded; + } - //We're replacing the list, so preserve usere's existing collapse/expand - //state. When resetting a list, default state is open. - foreach (var series in existingSeriesEntries) - { - var sEntry = GridEntries.InternalList.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId); - if (sEntry is SeriesEntry se && !series.Liberate.Expanded) - GridEntries.CollapseItem(se); - } + //Run query on new list + FilteredInGridEntries = QueryResults(SOURCE, FilterString); + + await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); - GridEntries.Filter = existingFilter; - ReSort(); - VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); - }); } catch (Exception ex) { @@ -136,7 +82,7 @@ namespace LibationAvalonia.ViewModels } } - private static IEnumerable CreateGridEntries(IEnumerable dbBooks) + private static List CreateGridEntries(IEnumerable dbBooks) { var geList = dbBooks .Where(lb => lb.Book.IsProduct()) @@ -159,81 +105,74 @@ namespace LibationAvalonia.ViewModels geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); } - return geList.OrderByDescending(e => e.DateAdded); + + var bookList = geList.OrderByDescending(e => e.DateAdded).ToList(); + + //ListIndex is used by RowComparer to make column sort stable + int index = 0; + foreach (GridEntry di in bookList) + di.ListIndex = index++; + + return bookList; } public void ToggleSeriesExpanded(SeriesEntry seriesEntry) { - if (seriesEntry.Liberate.Expanded) - GridEntries.CollapseItem(seriesEntry); - else - GridEntries.ExpandItem(seriesEntry); - - VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); + seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded; + GridEntries.Refresh(); } #endregion #region Filtering + public async Task Filter(string searchString) { - await Dispatcher.UIThread.InvokeAsync(() => - { - int visibleCount = GridEntries.Count; + if (searchString == FilterString) + return; - if (string.IsNullOrEmpty(searchString)) - GridEntries.RemoveFilter(); - else - GridEntries.Filter = searchString; + FilteredInGridEntries = QueryResults(SOURCE, searchString); - if (visibleCount != GridEntries.Count) - VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); + FilterString = searchString; - //Re-sort after filtering - ReSort(); - }); + await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); } + private bool CollectionFilter(object item) + { + if (item is LibraryBookEntry lbe + && lbe.IsEpisode + && lbe.Parent?.Liberate?.Expanded != true) + return false; + + if (FilteredInGridEntries is null) return true; + + return FilteredInGridEntries.Contains(item); + } + + private static List QueryResults(List entries, string searchString) + { + if (string.IsNullOrEmpty(searchString)) return null; + + var SearchResults = SearchEngineCommands.Search(searchString); + + var booksFilteredIn = entries.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe); + + //Find all series containing children that match the search criteria + var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); + + return booksFilteredIn.Concat(seriesFilteredIn).ToList(); + } + #endregion - #region Sorting - - public void Sort(DataGridColumn sortColumn) - { - //Force the comparer to get the current sort order. We can't - //retrieve it from inside this event handler because Avalonia - //doesn't set the property until after this event. - var comparer = sortColumn.CustomSortComparer as RowComparer; - comparer.SortDirection = null; - - _currentSortColumn = sortColumn; - } - - //Must be invoked on UI thread - private void ReSort() - { - if (_currentSortColumn is null) - { - //Sort ascending and reverse. That's how the comparer is designed to work to be compatible with Avalonia. - var defaultComparer = new RowComparer(ListSortDirection.Descending, nameof(GridEntry.DateAdded)); - GridEntries.InternalList.Sort(defaultComparer); - GridEntries.InternalList.Reverse(); - GridEntries.ResetCollection(); - } - else - { - _currentSortColumn.Sort(((RowComparer)_currentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); - } - } - - #endregion #region Scan and Remove Books public void DoneRemovingBooks() { - foreach (var item in GridEntries.AllItems()) - item.PropertyChanged -= Item_PropertyChanged; + foreach (var item in SOURCE) + item.PropertyChanged -= GridEntry_PropertyChanged; RemoveColumnVisivle = false; } @@ -248,49 +187,47 @@ namespace LibationAvalonia.ViewModels var result = await MessageBox.ShowConfirmationDialog( null, libraryBooks, - // do not use `$` string interpolation. See impl. - "Are you sure you want to remove {0} from Libation's library?", + // do not use `$` string interpolation. See impl. + "Are you sure you want to remove {0} from Libation's library?", "Remove books from Libation?"); if (result != DialogResult.Yes) return; foreach (var book in selectedBooks) - book.PropertyChanged -= Item_PropertyChanged; + book.PropertyChanged -= GridEntry_PropertyChanged; var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + + void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset) + return; + + //After DisplayBooks() re-creates the list, + //re-subscribe to all items' PropertyChanged events. + + foreach (var b in GetAllBookEntries()) + b.PropertyChanged += GridEntry_PropertyChanged; + + GridEntries.CollectionChanged -= BindingList_CollectionChanged; + } + GridEntries.CollectionChanged += BindingList_CollectionChanged; //The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(), //so there's no need to remove books from the grid display here. var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); - foreach (var b in GetAllBookEntries()) - b.Remove = false; - RemovableCountChanged?.Invoke(this, 0); } - void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset) - return; - - //After ProductsDisplay2.Display() re-creates the list, - //re-subscribe to all items' PropertyChanged events. - - foreach (var b in GetAllBookEntries()) - b.PropertyChanged += Item_PropertyChanged; - - GridEntries.CollectionChanged -= BindingList_CollectionChanged; - } - public async Task ScanAndRemoveBooksAsync(params Account[] accounts) { - foreach (var item in GridEntries.AllItems()) + foreach (var item in SOURCE) { item.Remove = false; - item.PropertyChanged += Item_PropertyChanged; + item.PropertyChanged += GridEntry_PropertyChanged; } RemoveColumnVisivle = true; @@ -303,9 +240,6 @@ namespace LibationAvalonia.ViewModels var allBooks = GetAllBookEntries(); - foreach (var b in allBooks) - b.Remove = false; - var lib = allBooks .Select(lbe => lbe.LibraryBook) .Where(lb => !lb.Book.HasLiberated()); @@ -327,7 +261,7 @@ namespace LibationAvalonia.ViewModels } } - private void Item_PropertyChanged(object sender, PropertyChangedEventArgs e) + private void GridEntry_PropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry lbEntry) { diff --git a/Source/LibationAvalonia/ViewModels/RowComparer.cs b/Source/LibationAvalonia/ViewModels/RowComparer.cs index 73effeea..71a912ee 100644 --- a/Source/LibationAvalonia/ViewModels/RowComparer.cs +++ b/Source/LibationAvalonia/ViewModels/RowComparer.cs @@ -13,25 +13,19 @@ namespace LibationAvalonia.ViewModels /// 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 + internal class RowComparer : IComparer, IComparer, IComparer { 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; } - public ListSortDirection? SortDirection { get; set; } public RowComparer(DataGridColumn column) { Column = column; PropertyName = Column.SortMemberPath; } - public RowComparer(ListSortDirection direction, string propertyName) - { - SortDirection = direction; - PropertyName = propertyName; - } public int Compare(object x, object y) { @@ -42,7 +36,7 @@ namespace LibationAvalonia.ViewModels var geA = (GridEntry)x; var geB = (GridEntry)y; - SortDirection ??= GetSortOrder(); + var sortDirection = GetSortOrder(); SeriesEntry parentA = null; SeriesEntry parentB = null; @@ -54,16 +48,16 @@ namespace LibationAvalonia.ViewModels //both a and b are top-level grid entries if (parentA is null && parentB is null) - return InternalCompare(geA, geB); + return InternalCompare(geA, geB, sortDirection); //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; + return sortDirection is ListSortDirection.Ascending ? -1 : 1; else - return InternalCompare(geA, parentB); + return InternalCompare(geA, parentB, sortDirection); } //a is a child, b is a top-level @@ -71,24 +65,24 @@ namespace LibationAvalonia.ViewModels { // a is a child of b, parent is always first if (parentA == geB) - return SortDirection is ListSortDirection.Ascending ? 1 : -1; + return sortDirection is ListSortDirection.Ascending ? 1 : -1; else - return InternalCompare(parentA, geB); + return InternalCompare(parentA, geB, sortDirection); } //both are children of the same series, always present in order of series index, ascending if (parentA == parentB) - return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); + return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1); //a and b are children of different series. - return InternalCompare(parentA, parentB); + return InternalCompare(parentA, parentB, sortDirection); } //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(GridEntry x, GridEntry y) + private int InternalCompare(GridEntry x, GridEntry y, ListSortDirection? sortDirection) { var val1 = x.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName); @@ -98,7 +92,7 @@ namespace LibationAvalonia.ViewModels //If items compare equal, compare them by their positions in the the list. //This is how you achieve a stable sort. if (compareResult == 0) - return x.ListIndex.CompareTo(y.ListIndex); + return x.ListIndex.CompareTo(y.ListIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1); else return compareResult; } diff --git a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs b/Source/LibationAvalonia/ViewModels/SeriesEntry.cs index 7589b3a2..bd457171 100644 --- a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs +++ b/Source/LibationAvalonia/ViewModels/SeriesEntry.cs @@ -55,7 +55,7 @@ namespace LibationAvalonia.ViewModels public SeriesEntry(LibraryBook parent, IEnumerable children) { - Liberate = new LiberateButtonStatus(IsSeries) { Expanded = true }; + Liberate = new LiberateButtonStatus(IsSeries); SeriesIndex = -1; LibraryBook = parent; diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml.cs index 7fde4711..19a3c17a 100644 --- a/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml.cs @@ -174,13 +174,12 @@ namespace LibationAvalonia.Views public void ProductsDisplay_Initialized1(object sender, EventArgs e) { - if (sender is ProductsDisplay products) - _viewModel.ProductsDisplay.RegisterCollectionChanged(products); + } - private void MainWindow_LibraryLoaded(object sender, List dbBooks) + private async void MainWindow_LibraryLoaded(object sender, List dbBooks) { - _viewModel.ProductsDisplay.InitialDisplay(dbBooks); + await _viewModel.ProductsDisplay.DisplayBooks(dbBooks); } private void InitializeComponent() diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index fb5a9939..82e1277d 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -20,16 +20,25 @@ CanUserReorderColumns="True"> - - + IsVisible="{Binding RemoveColumnVisivle}" + PropertyChanged="RemoveColumn_PropertyChanged" + Header="Remove" + IsReadOnly="False" + SortMemberPath="Remove" + Width="75"> + + + + + + + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 6f015b8c..b09fc6e4 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -27,20 +27,25 @@ namespace LibationAvalonia.Views if (Design.IsDesignMode) { using var context = DbContexts.GetContext(); - List sampleEntries = new() + List sampleEntries = new() { - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU")), - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G")), - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG")), - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q")), - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO")), - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4")), - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0")), - new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")), + //context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"), + context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), + context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"), + context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"), + context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"), + context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"), + context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"), + context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6") }; - DataContext = new ProductsDisplayViewModel(sampleEntries); + + var pdvm = new ProductsDisplayViewModel(); + pdvm.DisplayBooks(sampleEntries); + DataContext = pdvm; + return; } + Configure_ColumnCustomization(); foreach (var column in productsGrid.Columns) @@ -51,7 +56,7 @@ namespace LibationAvalonia.Views private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) { - _viewModel.Sort(e.Column); + } private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) From 48e2d91fc8f75149929dda630ae491aed0f2ee46 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Sun, 11 Dec 2022 15:47:04 -0700 Subject: [PATCH 03/11] Implement Illegal Char Replace dialog in Avalonia --- Source/FileManager/ReplacementCharacters.cs | 8 +- .../Dialogs/EditReplacementChars.axaml | 112 ++++++------ .../Dialogs/EditReplacementChars.axaml.cs | 165 ++++++++++++++++-- .../Dialogs/SettingsDialog.axaml | 9 +- .../Dialogs/SettingsDialog.axaml.cs | 9 +- .../LibationAvalonia/LibationAvalonia.csproj | 16 +- .../ViewModels/ProductsDisplayViewModel.cs | 12 +- .../MainWindow.QuickFilters.axaml.cs | 6 - .../Views/MainWindow/MainWindow.axaml | 1 - .../Views/MainWindow/MainWindow.axaml.cs | 13 +- .../Views/ProductsDisplay.axaml.cs | 1 - 11 files changed, 235 insertions(+), 117 deletions(-) diff --git a/Source/FileManager/ReplacementCharacters.cs b/Source/FileManager/ReplacementCharacters.cs index 550a54c6..c3982286 100644 --- a/Source/FileManager/ReplacementCharacters.cs +++ b/Source/FileManager/ReplacementCharacters.cs @@ -15,7 +15,7 @@ namespace FileManager [JsonIgnore] public bool Mandatory { get; internal set; } [JsonProperty] public char CharacterToReplace { get; private set; } [JsonProperty] public string ReplacementString { get; set; } - [JsonProperty] public string Description { get; private set; } + [JsonProperty] public string Description { get; set; } public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})"; public Replacement(char charToReplace, string replacementString, string description) @@ -24,7 +24,7 @@ namespace FileManager ReplacementString = replacementString; Description = description; } - private Replacement(char charToReplace, string replacementString, string description, bool mandatory) + private Replacement(char charToReplace, string replacementString, string description, bool mandatory = false) : this(charToReplace, replacementString, description) { Mandatory = mandatory; @@ -169,9 +169,9 @@ namespace FileManager public static bool ContainsInvalidPathChar(string path) - => path.Any(c => invalidChars.Contains(c)); + => path.Any(c => invalidChars?.Contains(c) == true); public static bool ContainsInvalidFilenameChar(string path) - => path.Any(c => invalidChars.Concat(new char[] { '\\', '/' }).Contains(c)); + => path.Any(c => invalidChars?.Concat(new char[] { '\\', '/' })?.Contains(c) == true); public string ReplaceInvalidFilenameChars(string fileName) { diff --git a/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml b/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml index 84d7a3e5..4926794e 100644 --- a/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml +++ b/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml @@ -2,60 +2,68 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" + MinWidth="500" MinHeight="450" x:Class="LibationAvalonia.Dialogs.EditReplacementChars" - Title="EditReplacementChars"> + Title="Illegal Character Replacement" + Icon="/Assets/libation.ico"> - + + + + + + + + + + + + + + + + + +