diff --git a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs index c312b18539..c6a5ee0f74 100644 --- a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs +++ b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs @@ -72,7 +72,18 @@ protected TreeDataGridAdapter() self._selectionModelsSerialDisposable.Disposable = selectionObservable.Subscribe(self, static (eventArgs, self) => { self.SelectedModels.RemoveRange(eventArgs.DeselectedItems.NotNull()); + foreach (var item in eventArgs.DeselectedItems) + { + if (item is null) continue; + item.IsSelected.Value = false; + } + self.SelectedModels.AddRange(eventArgs.SelectedItems.NotNull()); + foreach (var item in eventArgs.SelectedItems) + { + if (item is null) continue; + item.IsSelected.Value = true; + } }); self.Source.Value = source; diff --git a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs index d9fc4e66f1..d7e39f26fb 100644 --- a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs +++ b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs @@ -22,6 +22,8 @@ public class TreeDataGridItemModel : TreeDataGridItemModel where TModel : TreeDataGridItemModel where TKey : notnull { + public ReactiveProperty IsSelected { get; } = new(value: false); + public IObservable HasChildrenObservable { get; init; } = Observable.Return(false); public BindableReactiveProperty HasChildren { get; } = new(); @@ -130,7 +132,8 @@ protected override void Dispose(bool disposing) _modelActivationDisposable, _childrenObservableSerialDisposable, _childrenCollectionInitializationSerialDisposable, - HasChildren + HasChildren, + IsSelected ); } diff --git a/src/NexusMods.App.UI/Extensions/ObservableExtensions.cs b/src/NexusMods.App.UI/Extensions/ObservableExtensions.cs index 15503f2575..4f0c3eff0f 100644 --- a/src/NexusMods.App.UI/Extensions/ObservableExtensions.cs +++ b/src/NexusMods.App.UI/Extensions/ObservableExtensions.cs @@ -1,6 +1,8 @@ using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; +using DynamicData; +using DynamicData.Binding; namespace NexusMods.App.UI.Extensions; diff --git a/src/NexusMods.App.UI/Extensions/R3Extensions.cs b/src/NexusMods.App.UI/Extensions/R3Extensions.cs index c32183f206..b308b77b04 100644 --- a/src/NexusMods.App.UI/Extensions/R3Extensions.cs +++ b/src/NexusMods.App.UI/Extensions/R3Extensions.cs @@ -150,4 +150,29 @@ public static void ApplyChanges(this ObservableList list, } } } + + public static void ApplyChanges(this ObservableHashSet set, IChangeSet changes) + where TValue : notnull + where TKey : notnull + { + foreach (var change in changes) + { + switch (change.Reason) + { + case ChangeReason.Add: + set.Add(change.Current); + break; + case ChangeReason.Remove: + set.Remove(change.Current); + break; + case ChangeReason.Update: + if (set.Remove(change.Previous.Value)) + { + set.Add(change.Current); + } + + break; + } + } + } } diff --git a/src/NexusMods.App.UI/Extensions/SourceCacheAdapter.cs b/src/NexusMods.App.UI/Extensions/SourceCacheAdapter.cs new file mode 100644 index 0000000000..d623f53ae4 --- /dev/null +++ b/src/NexusMods.App.UI/Extensions/SourceCacheAdapter.cs @@ -0,0 +1,44 @@ +using DynamicData; + +namespace NexusMods.App.UI.Extensions; + +public class SourceCacheAdapter : IChangeSetAdaptor + where TObject : notnull + where TKey : notnull +{ + private readonly SourceCache _sourceCache; + + public SourceCacheAdapter(SourceCache sourceCache) + { + _sourceCache = sourceCache; + } + + public void Adapt(IChangeSet changes) + { + _sourceCache.Edit(updater => + { + foreach (var change in changes) + { + switch (change.Reason) + { + case ChangeReason.Add: + updater.AddOrUpdate(change.Current, change.Key); + break; + case ChangeReason.Update: + updater.AddOrUpdate(change.Current, change.Key); + break; + case ChangeReason.Remove: + updater.RemoveKey(change.Key); + break; + case ChangeReason.Refresh: + updater.Refresh(change.Key); + break; + case ChangeReason.Moved: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + }); + } +} diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj index 744f7ef406..f7ec2ae75b 100644 --- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj +++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj @@ -666,7 +666,4 @@ Language.Designer.cs - - - diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/FakeParentLibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/FakeParentLibraryItemModel.cs index b98334f955..f8a771612c 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/FakeParentLibraryItemModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/FakeParentLibraryItemModel.cs @@ -12,11 +12,13 @@ public class FakeParentLibraryItemModel : LibraryItemModel { public required IObservable NumInstalledObservable { get; init; } public required IObservable> LibraryItemsObservable { get; init; } - protected ObservableList LibraryItems { get; set; } = []; + protected ObservableHashSet LibraryItems { get; set; } = []; public override IReadOnlyCollection GetLoadoutItemIds() => LibraryItems.Select(static item => item.LibraryItemId).ToArray(); private readonly IDisposable _modelActivationDisposable; + private readonly SerialDisposable _libraryItemsDisposable = new(); + public FakeParentLibraryItemModel(LibraryItemId libraryItemId) : base(libraryItemId) { _modelActivationDisposable = WhenModelActivated(this, static (model, disposables) => @@ -53,8 +55,10 @@ public FakeParentLibraryItemModel(LibraryItemId libraryItemId) : base(libraryIte }) .AddTo(disposables); - model.LibraryItemsObservable.OnUI().SubscribeWithErrorLogging(changeSet => model.LibraryItems.ApplyChanges(changeSet)).AddTo(disposables); - Disposable.Create(model.LibraryItems, static libraryFiles => libraryFiles.Clear()).AddTo(disposables); + if (model._libraryItemsDisposable.Disposable is null) + { + model._libraryItemsDisposable.Disposable = model.LibraryItemsObservable.OnUI().SubscribeWithErrorLogging(changeSet => model.LibraryItems.ApplyChanges(changeSet)); + } }); } @@ -65,7 +69,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - _modelActivationDisposable.Dispose(); + Disposable.Dispose(_modelActivationDisposable, _libraryItemsDisposable); } LibraryItems = null!; diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryItemModel.cs index fa49b8510e..b70c13631c 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryItemModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryItemModel.cs @@ -25,7 +25,7 @@ public class LibraryItemModel : TreeDataGridItemModel Version { get; set; } = new("-"); public IObservable> LinkedLoadoutItemsObservable { get; init; } = System.Reactive.Linq.Observable.Empty>(); - private ObservableList LinkedLoadoutItems { get; set; } = []; + private ObservableDictionary LinkedLoadoutItems { get; set; } = []; public ReactiveProperty InstalledDate { get; } = new(DateTime.UnixEpoch); public ReactiveProperty CreatedAtDate { get; } = new(DateTime.UnixEpoch); @@ -43,6 +43,8 @@ public class LibraryItemModel : TreeDataGridItemModel GetLoadoutItemIds() => _fixedId; private readonly IDisposable _modelActivationDisposable; + private readonly SerialDisposable _linkedLoadoutItemsDisposable = new(); + public LibraryItemModel(LibraryItemId libraryItemId) { _fixedId = [libraryItemId]; @@ -67,7 +69,7 @@ public LibraryItemModel(LibraryItemId libraryItemId) { model.InstallText.Value = "Installed"; model.IsInstalledInLoadout.Value = true; - model.InstalledDate.Value = model.LinkedLoadoutItems.Select(static item => item.GetCreatedAt()).Max(); + model.InstalledDate.Value = model.LinkedLoadoutItems.Select(static kv => kv.Value.GetCreatedAt()).Max(); model.FormattedInstalledDate.Value = FormatDate(DateTime.Now, model.InstalledDate.Value); } else @@ -82,8 +84,10 @@ public LibraryItemModel(LibraryItemId libraryItemId) model.FormattedCreatedAtDate.Value = FormatDate(DateTime.Now, model.CreatedAtDate.Value); model.FormattedInstalledDate.Value = FormatDate(DateTime.Now, model.InstalledDate.Value); - model.LinkedLoadoutItemsObservable.OnUI().SubscribeWithErrorLogging(changeSet => model.LinkedLoadoutItems.ApplyChanges(changeSet)).AddTo(disposables); - Disposable.Create(model.LinkedLoadoutItems, static items => items.Clear()).AddTo(disposables); + if (model._linkedLoadoutItemsDisposable.Disposable is null) + { + model._linkedLoadoutItemsDisposable.Disposable = model.LinkedLoadoutItemsObservable.OnUI().SubscribeWithErrorLogging(changeSet => model.LinkedLoadoutItems.ApplyChanges(changeSet)); + } }); } @@ -103,6 +107,7 @@ protected override void Dispose(bool disposing) Disposable.Dispose( InstallCommand, _modelActivationDisposable, + _linkedLoadoutItemsDisposable, FormattedCreatedAtDate, FormattedInstalledDate, ItemSize, diff --git a/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs b/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs index c956602d53..41cad3e01f 100644 --- a/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs +++ b/src/NexusMods.App.UI/Pages/LoadoutPage/FakeParentLoadoutItemModel.cs @@ -12,19 +12,23 @@ public class FakeParentLoadoutItemModel : LoadoutItemModel public required IObservable InstalledAtObservable { get; init; } public required IObservable> LoadoutItemIdsObservable { get; init; } - public ObservableList LoadoutItemIds { get; private set; } = []; + public ObservableHashSet LoadoutItemIds { get; private set; } = []; public override IReadOnlyCollection GetLoadoutItemIds() => LoadoutItemIds; private readonly IDisposable _modelActivationDisposable; + private readonly SerialDisposable _loadoutItemIdsDisposable = new(); + public FakeParentLoadoutItemModel() : base(default(LoadoutItemId)) { _modelActivationDisposable = WhenModelActivated(this, static (model, disposables) => { model.InstalledAtObservable.OnUI().Subscribe(date => model.InstalledAt.Value = date).AddTo(disposables); - model.LoadoutItemIdsObservable.OnUI().SubscribeWithErrorLogging(changeSet => model.LoadoutItemIds.ApplyChanges(changeSet)).AddTo(disposables); - Disposable.Create(model.LoadoutItemIds, static collection => collection.Clear()).AddTo(disposables); + if (model._loadoutItemIdsDisposable.Disposable is null) + { + model._loadoutItemIdsDisposable.Disposable = model.LoadoutItemIdsObservable.OnUI().SubscribeWithErrorLogging(changeSet => model.LoadoutItemIds.ApplyChanges(changeSet)); + } }); } @@ -35,7 +39,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - _modelActivationDisposable.Dispose(); + Disposable.Dispose(_modelActivationDisposable, _loadoutItemIdsDisposable); } LoadoutItemIds = null!; diff --git a/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs b/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs index 3b71287d74..0bf0f3cef5 100644 --- a/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs +++ b/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs @@ -115,29 +115,19 @@ public IObservable> ObserveNestedLoadoutI { var libraryFile = LibraryFile.Load(_connection.Db, entityId); - var observable = _connection + // TODO: dispose + var cache = new SourceCache(static item => item.Id); + var disposable = _connection .ObserveDatoms(LibraryLinkedLoadoutItem.LibraryItemId, entityId) .AsEntityIds() .FilterInStaticLoadout(_connection, loadoutFilter) .Transform((_, e) => LibraryLinkedLoadoutItem.Load(_connection.Db, e)) - .PublishWithFunc(() => - { - var changeSet = new ChangeSet(); - var entities = LibraryLinkedLoadoutItem.FindByLibraryItem(_connection.Db, libraryFile.Id); - - foreach (var entity in entities) - { - if (!entity.AsLoadoutItemGroup().AsLoadoutItem().LoadoutId.Equals(loadoutFilter.LoadoutId)) continue; - changeSet.Add(new Change(ChangeReason.Add, entity.Id, entity)); - } - - return changeSet; - }) - .AutoConnect(); + .Adapt(new SourceCacheAdapter(cache)) + .SubscribeWithErrorLogging(); - var childrenObservable = observable.Transform(libraryLinkedLoadoutItem => LoadoutDataProviderHelper.ToLoadoutItemModel(_connection, libraryLinkedLoadoutItem)); + var childrenObservable = cache.Connect().Transform(libraryLinkedLoadoutItem => LoadoutDataProviderHelper.ToLoadoutItemModel(_connection, libraryLinkedLoadoutItem)); - var installedAtObservable = observable + var installedAtObservable = cache.Connect() .Transform(item => item.GetCreatedAt()) .QueryWhenChanged(query => { @@ -145,9 +135,9 @@ public IObservable> ObserveNestedLoadoutI return query.Items.Max(); }); - var loadoutItemIdsObservable = observable.Transform(item => item.AsLoadoutItemGroup().AsLoadoutItem().LoadoutItemId); + var loadoutItemIdsObservable = cache.Connect().Transform(item => item.AsLoadoutItemGroup().AsLoadoutItem().LoadoutItemId); - var isEnabledObservable = observable + var isEnabledObservable = cache.Connect() .TransformOnObservable(x => LoadoutItem.Observe(_connection, x.Id).Select(item => !item.IsDisabled)) .QueryWhenChanged(query => { diff --git a/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs b/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs index 8ab6ebe220..bf499d02a5 100644 --- a/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs +++ b/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs @@ -72,39 +72,31 @@ private LibraryItemModel ToLibraryItemModel(NexusModsLibraryFile.ReadOnly nexusM private LibraryItemModel ToLibraryItemModel(NexusModsModPageMetadata.ReadOnly modPageMetadata, LibraryFilter libraryFilter) { - var nexusModsLibraryFileObservable = _connection + // TODO: dispose + var cache = new SourceCache(static datom => datom.E); + var disposable = _connection .ObserveDatoms(NexusModsLibraryFile.ModPageMetadataId, modPageMetadata.Id) .AsEntityIds() - .PublishWithFunc(initialValueFunc: () => - { - var changeSet = new ChangeSet(); - var datoms = _connection.Db.Datoms(NexusModsLibraryFile.ModPageMetadataId, modPageMetadata.Id); - foreach (var datom in datoms) - { - changeSet.Add(new Change(ChangeReason.Add, datom.E, datom)); - } - - return changeSet; - }) - .AutoConnect(); + .Adapt(new SourceCacheAdapter(cache)) + .SubscribeWithErrorLogging(); - var hasChildrenObservable = nexusModsLibraryFileObservable.IsNotEmpty(); - var childrenObservable = nexusModsLibraryFileObservable.Transform((_, e) => + var hasChildrenObservable = cache.Connect().IsNotEmpty(); + var childrenObservable = cache.Connect().Transform((_, e) => { var libraryFile = NexusModsLibraryFile.Load(_connection.Db, e); return ToLibraryItemModel(libraryFile, libraryFilter); }); - var linkedLoadoutItemsObservable = nexusModsLibraryFileObservable + var linkedLoadoutItemsObservable = cache.Connect() // NOTE(erri120): DynamicData 9.0.4 is broken for value types because it uses ReferenceEquals. Temporary workaround is a custom equality comparer. .MergeManyChangeSets((_, e) => _connection.ObserveDatoms(LibraryLinkedLoadoutItem.LibraryItemId, e).AsEntityIds(), equalityComparer: DatomEntityIdEqualityComparer.Instance) .FilterInObservableLoadout(_connection, libraryFilter) .Transform((_, e) => LibraryLinkedLoadoutItem.Load(_connection.Db, e)); - var libraryFilesObservable = nexusModsLibraryFileObservable + var libraryFilesObservable = cache.Connect() .Transform((_, e) => NexusModsLibraryFile.Load(_connection.Db, e).AsDownloadedFile().AsLibraryFile().AsLibraryItem()); - var numInstalledObservable = nexusModsLibraryFileObservable.TransformOnObservable((_, e) => _connection + var numInstalledObservable = cache.Connect().TransformOnObservable((_, e) => _connection .ObserveDatoms(LibraryLinkedLoadoutItem.LibraryItemId, e) .AsEntityIds() .FilterInObservableLoadout(_connection, libraryFilter) @@ -141,39 +133,25 @@ public IObservable> ObserveNestedLoadoutI ) .Transform(modPage => { - var observable = _connection + // TODO: dispose + var cache = new SourceCache(static datom => datom.E); + var disposable = _connection .ObserveDatoms(NexusModsLibraryFile.ModPageMetadataId, modPage.Id).AsEntityIds() .FilterOnObservable((_, e) => _connection.ObserveDatoms(LibraryLinkedLoadoutItem.LibraryItemId, e).IsNotEmpty()) // NOTE(erri120): DynamicData 9.0.4 is broken for value types because it uses ReferenceEquals. Temporary workaround is a custom equality comparer. .MergeManyChangeSets((_, e) => _connection.ObserveDatoms(LibraryLinkedLoadoutItem.LibraryItemId, e).AsEntityIds(), equalityComparer: DatomEntityIdEqualityComparer.Instance) .FilterInStaticLoadout(_connection, loadoutFilter) - .PublishWithFunc(() => - { - var changeSet = new ChangeSet(); - - var libraryFileDatoms = _connection.Db.Datoms(NexusModsLibraryFile.ModPageMetadataId, modPage.Id); - foreach (var entityIdDatom in libraryFileDatoms) - { - var libraryLinkedLoadoutItemDatoms = _connection.Db.Datoms(LibraryLinkedLoadoutItem.LibraryItemId, entityIdDatom.E); - foreach (var datom in libraryLinkedLoadoutItemDatoms) - { - if (!LoadoutItem.Load(_connection.Db, datom.E).LoadoutId.Equals(loadoutFilter.LoadoutId)) continue; - changeSet.Add(new Change(ChangeReason.Add, datom.E, datom)); - } - } - - return changeSet; - }) - .AutoConnect(); + .Adapt(new SourceCacheAdapter(cache)) + .SubscribeWithErrorLogging(); - var hasChildrenObservable = observable.IsNotEmpty(); - var childrenObservable = observable.Transform(libraryLinkedLoadoutItemDatom => + var hasChildrenObservable = cache.Connect().IsNotEmpty(); + var childrenObservable = cache.Connect().Transform(libraryLinkedLoadoutItemDatom => { var libraryLinkedLoadoutItem = LibraryLinkedLoadoutItem.Load(_connection.Db, libraryLinkedLoadoutItemDatom.E); return LoadoutDataProviderHelper.ToLoadoutItemModel(_connection, libraryLinkedLoadoutItem); }); - var installedAtObservable = observable + var installedAtObservable = cache.Connect() .Transform((_, e) => LibraryLinkedLoadoutItem.Load(_connection.Db, e).GetCreatedAt()) .QueryWhenChanged(query => { @@ -181,9 +159,9 @@ public IObservable> ObserveNestedLoadoutI return query.Items.Max(); }); - var loadoutItemIdsObservable = observable.Transform((_, e) => (LoadoutItemId) e); + var loadoutItemIdsObservable = cache.Connect().Transform((_, e) => (LoadoutItemId) e); - var isEnabledObservable = observable + var isEnabledObservable = cache.Connect() .TransformOnObservable(datom => LoadoutItem.Observe(_connection, datom.E).Select(item => !item.IsDisabled)) .QueryWhenChanged(query => {