diff --git a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs index f6a5e74ef..cc3f38fbc 100644 --- a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs +++ b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs @@ -12,7 +12,7 @@ namespace NexusMods.App.UI.Controls; /// Adapter class for working with . /// public abstract class TreeDataGridAdapter : ReactiveR3Object - where TModel : TreeDataGridItemModel + where TModel : class, ITreeDataGridItemModel where TKey : notnull { public Subject<(TModel model, bool isActivating)> ModelActivationSubject { get; } = new(); diff --git a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs index acb8f1830..22bfd4f53 100644 --- a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs +++ b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs @@ -10,23 +10,51 @@ namespace NexusMods.App.UI.Controls; [PublicAPI] -public interface ITreeDataGridItemModel : IReactiveR3Object; +public interface ITreeDataGridItemModel : IReactiveR3Object +{ + ReactiveProperty IsSelected { get; } +} /// /// Base class for models of items. /// -public class TreeDataGridItemModel : ReactiveR3Object, ITreeDataGridItemModel; +public class TreeDataGridItemModel : ReactiveR3Object, ITreeDataGridItemModel +{ + public ReactiveProperty IsSelected { get; } = new(value: false); +} + +public interface ITreeDataGridItemModel : ITreeDataGridItemModel + where TModel : class, ITreeDataGridItemModel + where TKey : notnull +{ + BindableReactiveProperty HasChildren { get; } + + IEnumerable Children { get; } + + bool IsExpanded { get; [UsedImplicitly] set; } + + public static HierarchicalExpanderColumn CreateExpanderColumn(IColumn innerColumn) + { + return new HierarchicalExpanderColumn( + inner: innerColumn, + childSelector: static model => model.Children, + hasChildrenSelector: static model => model.HasChildren.Value, + isExpandedSelector: static model => model.IsExpanded + ) + { + Tag = "expander", + }; + } +} /// /// Generic variant of . /// [PublicAPI] -public class TreeDataGridItemModel : TreeDataGridItemModel - where TModel : TreeDataGridItemModel +public class TreeDataGridItemModel : TreeDataGridItemModel, ITreeDataGridItemModel + where TModel : class, ITreeDataGridItemModel where TKey : notnull { - public ReactiveProperty IsSelected { get; } = new(value: false); - public IObservable HasChildrenObservable { get; init; } = Observable.Return(false); public BindableReactiveProperty HasChildren { get; } = new(); @@ -152,17 +180,4 @@ [MustDisposeResource] protected static IDisposable WhenModelActivated CreateExpanderColumn(IColumn innerColumn) - { - return new HierarchicalExpanderColumn( - inner: innerColumn, - childSelector: static model => model.Children, - hasChildrenSelector: static model => model.HasChildren.Value, - isExpandedSelector: static model => model.IsExpanded - ) - { - Tag = "expander", - }; - } } diff --git a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridViewHelper.cs b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridViewHelper.cs index 425cdc4c7..2073f3b1e 100644 --- a/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridViewHelper.cs +++ b/src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridViewHelper.cs @@ -16,7 +16,7 @@ public static void SetupTreeDataGridAdapter Func> getAdapter) where TView : ReactiveUserControl where TViewModel : class, IViewModelInterface - where TItemModel : TreeDataGridItemModel + where TItemModel : class, ITreeDataGridItemModel where TKey : notnull { treeDataGrid.ElementFactory = new CustomElementFactory(); diff --git a/src/NexusMods.App.UI/Extensions/FormatExtensions.cs b/src/NexusMods.App.UI/Extensions/FormatExtensions.cs new file mode 100644 index 000000000..cb56c41d9 --- /dev/null +++ b/src/NexusMods.App.UI/Extensions/FormatExtensions.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using Humanizer; +using Humanizer.Bytes; +using NexusMods.Paths; +using R3; + +namespace NexusMods.App.UI.Extensions; + +public static class FormatExtensions +{ + public static string FormatDate(this DateTimeOffset date, DateTimeOffset now) + { + if (date == DateTimeOffset.MinValue || date == DateTimeOffset.MaxValue || date == DateTimeOffset.UnixEpoch) return "-"; + return date.Humanize(dateToCompareAgainst: now, culture: CultureInfo.CurrentUICulture); + } + + public static BindableReactiveProperty ToFormattedProperty(this Observable source) + { + return source + .Select(static date => date.FormatDate(now: TimeProvider.System.GetLocalNow())) + .ToBindableReactiveProperty(initialValue: ""); + } + + public static BindableReactiveProperty ToFormattedProperty(this Observable source) + { + return source + .Select(static size => ByteSize.FromBytes(size.Value).Humanize()) + .ToBindableReactiveProperty(initialValue: ""); + } +} diff --git a/src/NexusMods.App.UI/Extensions/R3Extensions.cs b/src/NexusMods.App.UI/Extensions/R3Extensions.cs index d2d2f07fd..18b3923cc 100644 --- a/src/NexusMods.App.UI/Extensions/R3Extensions.cs +++ b/src/NexusMods.App.UI/Extensions/R3Extensions.cs @@ -16,15 +16,32 @@ [MustDisposeResource] public static IDisposable WhenActivated( this T obj, Action block) where T : IReactiveR3Object + { + return WhenActivated(obj, state: block, static (obj, block, disposables) => + { + block(obj, disposables); + }); + } + + /// + /// Provides an activation block for . + /// + [MustDisposeResource] + public static IDisposable WhenActivated( + this T obj, + TState state, + Action block) + where T : IReactiveR3Object + where TState : notnull { var d = Disposable.CreateBuilder(); var serialDisposable = new SerialDisposable(); serialDisposable.AddTo(ref d); - obj.Activation.DistinctUntilChanged().Subscribe((obj, serialDisposable, block), onNext: static (isActivated, state) => + obj.Activation.DistinctUntilChanged().Subscribe(((obj, state), serialDisposable, block), onNext: static (isActivated, state) => { - var (model, serialDisposable, block) = state; + var (wrapper, serialDisposable, block) = state; serialDisposable.Disposable = null; if (isActivated) @@ -32,7 +49,7 @@ [MustDisposeResource] public static IDisposable WhenActivated( var compositeDisposable = new CompositeDisposable(); serialDisposable.Disposable = compositeDisposable; - block(model, compositeDisposable); + block(wrapper.obj, wrapper.state, compositeDisposable); } }, onCompleted: static (_, state) => { diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj index 10b6f3cfd..86512949e 100644 --- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj +++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj @@ -596,12 +596,6 @@ ILibraryViewModel.cs - - LibraryItemModel.cs - - - LibraryItemModel.cs - LoadoutItemModel.cs diff --git a/src/NexusMods.App.UI/Pages/ILibraryDataProvider.cs b/src/NexusMods.App.UI/Pages/ILibraryDataProvider.cs index bd7adba73..1e2408663 100644 --- a/src/NexusMods.App.UI/Pages/ILibraryDataProvider.cs +++ b/src/NexusMods.App.UI/Pages/ILibraryDataProvider.cs @@ -12,9 +12,9 @@ namespace NexusMods.App.UI.Pages; public interface ILibraryDataProvider { - IObservable> ObserveFlatLibraryItems(LibraryFilter libraryFilter); + IObservable> ObserveFlatLibraryItems(LibraryFilter libraryFilter); - IObservable> ObserveNestedLibraryItems(LibraryFilter libraryFilter); + IObservable> ObserveNestedLibraryItems(LibraryFilter libraryFilter); } public class LibraryFilter diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/FakeParentLibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/FakeParentLibraryItemModel.cs deleted file mode 100644 index 61c793ca3..000000000 --- a/src/NexusMods.App.UI/Pages/LibraryPage/FakeParentLibraryItemModel.cs +++ /dev/null @@ -1,83 +0,0 @@ -using DynamicData; -using NexusMods.Abstractions.Library.Models; -using NexusMods.App.UI.Extensions; -using NexusMods.MnemonicDB.Abstractions; -using ObservableCollections; -using R3; -using ReactiveUI; - -namespace NexusMods.App.UI.Pages.LibraryPage; - -public class FakeParentLibraryItemModel : LibraryItemModel -{ - public required IObservable NumInstalledObservable { get; init; } - public IObservable> LibraryItemsObservable { get; } - protected ObservableHashSet LibraryItems { get; set; } = []; - - public override IReadOnlyCollection GetLoadoutItemIds() => LibraryItems.Select(static item => item.LibraryItemId).ToArray(); - - private readonly IDisposable _modelActivationDisposable; - private readonly IDisposable _libraryItemsDisposable; - - public FakeParentLibraryItemModel(LibraryItemId libraryItemId, IObservable> libraryItemsObservable) : base(libraryItemId) - { - LibraryItemsObservable = libraryItemsObservable; - - // NOTE(Al12rs): This needs to be set up even if model is never activated, - // as it is possible for items to get selected and interacted with without their model being activated - // (e.g. by quick scrolling to bottom with scrollbar and shift-selecting all items) - _libraryItemsDisposable = LibraryItemsObservable.OnUI().SubscribeWithErrorLogging(changeSet => LibraryItems.ApplyChanges(changeSet)); - - _modelActivationDisposable = WhenModelActivated(this, static (model, disposables) => - { - model.NumInstalledObservable - .ToObservable() - .CombineLatest( - source2: model.LibraryItems.ObserveCountChanged(notifyCurrentCount: true), - source3: model.WhenAnyValue(static model => model.IsExpanded).ToObservable(), - source4: model.IsInstalledInLoadout, - static (a,b,c , _) => (a,b,c) - ) - .ObserveOnUIThreadDispatcher() - .Subscribe(model, static (tuple, model) => - { - var (numInstalled, numCount, isExpanded) = tuple; - - if (numInstalled > 0) - { - if (numInstalled == numCount) - { - model.InstallText.Value = "Installed"; - } else { - model.InstallText.Value = $"Installed {numInstalled}/{numCount}"; - } - } else { - if (!isExpanded && numCount == 1) - { - model.InstallText.Value = "Install"; - } else { - model.InstallText.Value = $"Install ({numCount})"; - } - } - }) - .AddTo(disposables); - }); - } - - private bool _isDisposed; - protected override void Dispose(bool disposing) - { - if (!_isDisposed) - { - if (disposing) - { - Disposable.Dispose(_modelActivationDisposable, _libraryItemsDisposable); - } - - LibraryItems = null!; - _isDisposed = true; - } - - base.Dispose(disposing); - } -} diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemModel.cs index e6f347190..7b2a36c33 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemModel.cs @@ -1,5 +1,79 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using DynamicData; +using JetBrains.Annotations; +using NexusMods.Abstractions.Library.Models; +using NexusMods.Abstractions.Loadouts; +using NexusMods.Abstractions.MnemonicDB.Attributes.Extensions; using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; +using NexusMods.MnemonicDB.Abstractions; +using ObservableCollections; +using R3; namespace NexusMods.App.UI.Pages.LibraryPage; -public interface ILibraryItemModel : ITreeDataGridItemModel; +public interface ILibraryItemModel : ITreeDataGridItemModel; + +public interface IHasTicker +{ + Observable? Ticker { get; set; } +} + +public interface IHasLinkedLoadoutItems +{ + IObservable> LinkedLoadoutItemsObservable { get; } + ObservableDictionary LinkedLoadoutItems { get; } + + [MustDisposeResource] static IDisposable SetupLinkedLoadoutItems(TModel self, SerialDisposable serialDisposable) + where TModel : IHasLinkedLoadoutItems, ILibraryItemWithInstallAction, ILibraryItemWithInstalledDate + { + var disposable = self.LinkedLoadoutItems + .ObserveCountChanged(notifyCurrentCount: true) + .Subscribe(self, static (count, self) => + { + var isInstalled = count > 0; + self.IsInstalled.Value = isInstalled; + self.InstallButtonText.Value = ILibraryItemWithInstallAction.GetButtonText(isInstalled); + self.InstalledDate.Value = isInstalled ? self.LinkedLoadoutItems.Select(static kv => kv.Value.GetCreatedAt()).Max() : DateTimeOffset.MinValue; + }); + + if (serialDisposable.Disposable is null) + { + serialDisposable.Disposable = self.LinkedLoadoutItemsObservable.OnUI().SubscribeWithErrorLogging(changes => self.LinkedLoadoutItems.ApplyChanges(changes)); + } + + return disposable; + } +} + +public interface IIsParentLibraryItemModel : ILibraryItemModel +{ + IReadOnlyList LibraryItemIds { get; } +} + +public interface IIsChildLibraryItemModel : ILibraryItemModel +{ + LibraryItemId LibraryItemId { get; } +} + +[SuppressMessage("ReSharper", "PossibleInterfaceMemberAmbiguity")] +public interface ILibraryItemWithDates : IHasTicker, ILibraryItemWithDownloadedDate, ILibraryItemWithInstalledDate +{ + [MustDisposeResource] + static IDisposable SetupDates(TModel self) where TModel : class, ILibraryItemWithDates + { + return self.WhenActivated(static (self, disposables) => + { + Debug.Assert(self.Ticker is not null, "should've been set before activation"); + self.Ticker.Subscribe(self, static (now, self) => + { + ILibraryItemWithDownloadedDate.FormatDate(self, now: now); + ILibraryItemWithInstalledDate.FormatDate(self, now: now); + }).AddTo(disposables); + + ILibraryItemWithDownloadedDate.FormatDate(self, now: TimeProvider.System.GetLocalNow()); + ILibraryItemWithInstalledDate.FormatDate(self, now: TimeProvider.System.GetLocalNow()); + }); + } +} diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs index 16cd32e8b..2b05717b5 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using NexusMods.App.UI.Controls; using R3; @@ -22,11 +23,44 @@ int IComparable.CompareTo(ILibraryItemWithAction? other) public interface ILibraryItemWithInstallAction : ILibraryItemWithAction { - ReactiveCommand InstallItemCommand { get; } + ReactiveCommand InstallItemCommand { get; } BindableReactiveProperty IsInstalled { get; } BindableReactiveProperty InstallButtonText { get; } + + public static ReactiveCommand CreateCommand(TModel model) + where TModel : ILibraryItemModel, ILibraryItemWithInstallAction + { + var canInstall = model.IsInstalled.Select(static isInstalled => !isInstalled); + return canInstall.ToReactiveCommand(_ => model, initialCanExecute: false); + } + + public static string GetButtonText(bool isInstalled) => isInstalled ? "Installed" : "Install"; + + [SuppressMessage("ReSharper", "RedundantIfElseBlock")] + [SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")] + public static string GetButtonText(int numInstalled, int numTotal, bool isExpanded) + { + if (numInstalled > 0) + { + if (numInstalled == numTotal) + { + return "Installed"; + } else { + return $"Installed {numInstalled}/{numTotal}"; + } + } + else + { + if (!isExpanded && numTotal == 1) + { + return "Install"; + } else { + return $"Install ({numTotal})"; + } + } + } } public interface ILibraryItemWithDownloadAction : ILibraryItemWithAction diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithDownloadedDate.cs b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithDownloadedDate.cs index b1a7ca9b1..e4ee4a9f9 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithDownloadedDate.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithDownloadedDate.cs @@ -1,14 +1,17 @@ using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; using R3; namespace NexusMods.App.UI.Pages.LibraryPage; public interface ILibraryItemWithDownloadedDate : ILibraryItemModel, IComparable, IColumnDefinition { - ReactiveProperty DownloadedDate { get; } + ReactiveProperty DownloadedDate { get; } BindableReactiveProperty FormattedDownloadedDate { get; } - int IComparable.CompareTo(ILibraryItemWithDownloadedDate? other) => other is null ? 1 : DateTime.Compare(DownloadedDate.Value, other.DownloadedDate.Value); + static void FormatDate(ILibraryItemWithDownloadedDate self, DateTimeOffset now) => self.FormattedDownloadedDate.Value = self.DownloadedDate.Value.FormatDate(now: now); + + int IComparable.CompareTo(ILibraryItemWithDownloadedDate? other) => other is null ? 1 : DateTimeOffset.Compare(DownloadedDate.Value, other.DownloadedDate.Value); public const string ColumnTemplateResourceKey = "LibraryItemDownloadedDateColumn"; static string IColumnDefinition.GetColumnHeader() => "Downloaded"; diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithInstalledDate.cs b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithInstalledDate.cs index 9f4fa3d64..4afcf49a2 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithInstalledDate.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithInstalledDate.cs @@ -1,14 +1,17 @@ using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; using R3; namespace NexusMods.App.UI.Pages.LibraryPage; public interface ILibraryItemWithInstalledDate : ILibraryItemModel, IComparable, IColumnDefinition { - ReactiveProperty InstalledDate { get; } + ReactiveProperty InstalledDate { get; } BindableReactiveProperty FormattedInstalledDate { get; } - int IComparable.CompareTo(ILibraryItemWithInstalledDate? other) => other is null ? 1 : DateTime.Compare(InstalledDate.Value, other.InstalledDate.Value); + static void FormatDate(ILibraryItemWithInstalledDate self, DateTimeOffset now) => self.FormattedInstalledDate.Value = self.InstalledDate.Value.FormatDate(now: now); + + int IComparable.CompareTo(ILibraryItemWithInstalledDate? other) => other is null ? 1 : DateTimeOffset.Compare(InstalledDate.Value, other.InstalledDate.Value); public const string ColumnTemplateResourceKey = "LibraryItemInstalledDateColumn"; static string IColumnDefinition.GetColumnHeader() => "Installed"; diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryItemModel.cs deleted file mode 100644 index a0f477fa0..000000000 --- a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryItemModel.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using Avalonia.Controls.Models.TreeDataGrid; -using DynamicData; -using Humanizer; -using NexusMods.Abstractions.Library.Models; -using NexusMods.Abstractions.Loadouts; -using NexusMods.Abstractions.MnemonicDB.Attributes.Extensions; -using NexusMods.App.UI.Controls; -using NexusMods.App.UI.Extensions; -using NexusMods.MnemonicDB.Abstractions; -using NexusMods.Paths; -using ObservableCollections; -using R3; - -namespace NexusMods.App.UI.Pages.LibraryPage; - -public class LibraryItemModel : TreeDataGridItemModel -{ - public required string Name { get; init; } - - // TODO: turn this back into a `Size` - // NOTE(erri120): requires https://github.com/AvaloniaUI/Avalonia.Controls.TreeDataGrid/pull/304 - public BindableReactiveProperty ItemSize { get; } = new(Size.Zero.ToString()); - public BindableReactiveProperty Version { get; set; } = new("-"); - - public IObservable> LinkedLoadoutItemsObservable { get; init; } = System.Reactive.Linq.Observable.Empty>(); - private ObservableDictionary LinkedLoadoutItems { get; set; } = []; - - public ReactiveProperty InstalledDate { get; } = new(DateTime.UnixEpoch); - public ReactiveProperty CreatedAtDate { get; } = new(DateTime.UnixEpoch); - - public Observable? Ticker { get; set; } - public BindableReactiveProperty FormattedCreatedAtDate { get; } = new("-"); - public BindableReactiveProperty FormattedInstalledDate { get; } = new("-"); - - public BindableReactiveProperty InstallText { get; } = new("Install"); - public BindableReactiveProperty IsInstalledInLoadout { get; } = new(false); - - public ReactiveCommand> InstallCommand { get; } - - private readonly LibraryItemId[] _fixedId; - public virtual IReadOnlyCollection GetLoadoutItemIds() => _fixedId; - - private readonly IDisposable _modelActivationDisposable; - private readonly SerialDisposable _linkedLoadoutItemsDisposable = new(); - - public LibraryItemModel(LibraryItemId libraryItemId) - { - _fixedId = [libraryItemId]; - - var canInstall = IsInstalledInLoadout.Select(static b => !b); - InstallCommand = canInstall.ToReactiveCommand>(_ => GetLoadoutItemIds(), initialCanExecute: false); - - _modelActivationDisposable = WhenModelActivated(this, static (model, disposables) => - { - - Debug.Assert(model.Ticker is not null, "should've been set before activation"); - model.Ticker.Subscribe(model, static (now, model) => - { - model.FormattedCreatedAtDate.Value = FormatDate(now, model.CreatedAtDate.Value); - model.FormattedInstalledDate.Value = FormatDate(now, model.InstalledDate.Value); - }).AddTo(disposables); - - model.LinkedLoadoutItems - // Observe Count Changed defaults to not notifying the current count on a new subscription - // Because this chain will be destroyed when the model is deactivated, we want to know the current count - // when we reactivate a gain. Rows in a TreeDataGrid are virtualized and so they will be repeatedly activated and deactivated - .ObserveCountChanged(notifyCurrentCount: true) - .Subscribe(model, static (count, model) => - { - if (count > 0) - { - model.InstallText.Value = "Installed"; - model.IsInstalledInLoadout.Value = true; - model.InstalledDate.Value = model.LinkedLoadoutItems.Select(static kv => kv.Value.GetCreatedAt()).Max(); - model.FormattedInstalledDate.Value = FormatDate(DateTime.Now, model.InstalledDate.Value); - } - else - { - model.InstallText.Value = "Install"; - model.IsInstalledInLoadout.Value = false; - model.InstalledDate.Value = DateTime.UnixEpoch; - model.FormattedInstalledDate.Value = "-"; - } - } - ) - .AddTo(disposables); - - model.FormattedCreatedAtDate.Value = FormatDate(DateTime.Now, model.CreatedAtDate.Value); - model.FormattedInstalledDate.Value = FormatDate(DateTime.Now, model.InstalledDate.Value); - - if (model._linkedLoadoutItemsDisposable.Disposable is null) - { - model._linkedLoadoutItemsDisposable.Disposable = model.LinkedLoadoutItemsObservable - .OnUI() - .SubscribeWithErrorLogging(changeSet => model.LinkedLoadoutItems.ApplyChanges(changeSet)); - } - }); - } - - protected static string FormatDate(DateTime now, DateTime date) - { - if (date == DateTime.UnixEpoch || date == default(DateTime)) return "-"; - return date.Humanize(dateToCompareAgainst: now > date ? now : DateTime.Now); - } - - private bool _isDisposed; - protected override void Dispose(bool disposing) - { - if (!_isDisposed) - { - if (disposing) - { - Disposable.Dispose( - InstallCommand, - _modelActivationDisposable, - _linkedLoadoutItemsDisposable, - FormattedCreatedAtDate, - FormattedInstalledDate, - ItemSize, - IsInstalledInLoadout, - InstalledDate, - InstallText - ); - } - - LinkedLoadoutItems = null!; - _isDisposed = true; - } - - base.Dispose(disposing); - } - - public override string ToString() => Name; - - public static IColumn CreateNameColumn() - { - return new CustomTextColumn( - header: "NAME", - getter: model => model.Name, - options: new TextColumnOptions - { - CompareAscending = static (a, b) => string.Compare(a?.Name, b?.Name, StringComparison.OrdinalIgnoreCase), - CompareDescending = static (a, b) => string.Compare(b?.Name, a?.Name, StringComparison.OrdinalIgnoreCase), - IsTextSearchEnabled = true, - CanUserResizeColumn = true, - CanUserSortColumn = true, - } - ) - { - SortDirection = ListSortDirection.Ascending, - Id = "name", - }; - } - - public static IColumn CreateVersionColumn() - { - return new CustomTextColumn( - header: "VERSION", - getter: model => model.Version.Value, - options: new TextColumnOptions - { - CompareAscending = static (a, b) => string.Compare(a?.Version.Value, b?.Version.Value, StringComparison.OrdinalIgnoreCase), - CompareDescending = static (a, b) => string.Compare(b?.Version.Value, a?.Version.Value, StringComparison.OrdinalIgnoreCase), - IsTextSearchEnabled = true, - CanUserResizeColumn = true, - CanUserSortColumn = true, - } - ) - { - Id = "version", - }; - } - - public static IColumn CreateSizeColumn() - { - return new CustomTextColumn( - header: "SIZE", - getter: model => model.ItemSize.Value, - options: new TextColumnOptions - { - CompareAscending = static (a, b) => a is null ? -1 : a.ItemSize.Value.CompareTo(b?.ItemSize.Value ?? "0 B"), - CompareDescending = static (a, b) => b is null ? -1 : b.ItemSize.Value.CompareTo(a?.ItemSize.Value ?? "0 B"), - IsTextSearchEnabled = false, - CanUserResizeColumn = true, - CanUserSortColumn = true, - } - ) - { - Id = "size", - }; - } - - public static IColumn CreateAddedAtColumn() - { - return new CustomTextColumn( - header: "ADDED", - getter: model => model.FormattedCreatedAtDate.Value, - options: new TextColumnOptions - { - CompareAscending = static (a, b) => a?.CreatedAtDate.Value.CompareTo(b?.CreatedAtDate.Value ?? DateTime.UnixEpoch) ?? 1, - CompareDescending = static (a, b) => b?.CreatedAtDate.Value.CompareTo(a?.CreatedAtDate.Value ?? DateTime.UnixEpoch) ?? 1, - IsTextSearchEnabled = false, - CanUserResizeColumn = true, - CanUserSortColumn = true, - } - ) - { - Id = "AddedAt", - }; - } - - public static IColumn CreateInstalledAtColumn() - { - return new CustomTextColumn( - header: "INSTALLED", - getter: model => model.FormattedInstalledDate.Value, - options: new TextColumnOptions - { - CompareAscending = static (a, b) => a?.InstalledDate.Value.CompareTo(b?.InstalledDate.Value ?? DateTime.UnixEpoch) ?? 1, - CompareDescending = static (a, b) => b?.InstalledDate.Value.CompareTo(a?.InstalledDate.Value ?? DateTime.UnixEpoch) ?? 1, - IsTextSearchEnabled = false, - CanUserResizeColumn = true, - CanUserSortColumn = true, - } - ) - { - Id = "InstalledAt", - }; - } - - public static IColumn CreateInstallColumn() - { - return new CustomTemplateColumn( - header: "ACTIONS", - cellTemplateResourceKey: "InstallColumnTemplate", - options: new TemplateColumnOptions - { - CompareAscending = static (a, b) => a?.IsInstalledInLoadout.Value.CompareTo(b?.IsInstalledInLoadout.Value ?? false) ?? 1, - CompareDescending = static (a, b) => b?.IsInstalledInLoadout.Value.CompareTo(a?.IsInstalledInLoadout.Value ?? false) ?? 1, - IsTextSearchEnabled = false, - CanUserResizeColumn = true, - CanUserSortColumn = true, - } - ) - { - Id = "Install", - }; - } -} diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml index c1569ca64..6f03bba78 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml +++ b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml @@ -211,21 +211,6 @@ - - - - - diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml.cs b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml.cs index 8da333238..7f3549efa 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml.cs @@ -15,7 +15,7 @@ public LibraryView() { InitializeComponent(); - TreeDataGridViewHelper.SetupTreeDataGridAdapter(this, TreeDataGrid, vm => vm.Adapter); + TreeDataGridViewHelper.SetupTreeDataGridAdapter(this, TreeDataGrid, vm => vm.Adapter); this.WhenActivated(disposables => { diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryViewModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryViewModel.cs index 32effe704..f48c0f3bf 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/LibraryViewModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/LibraryViewModel.cs @@ -23,6 +23,7 @@ using NexusMods.MnemonicDB.Abstractions; using NexusMods.Paths; using ObservableCollections; +using OneOf; using R3; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -71,8 +72,8 @@ public LibraryViewModel( var ticker = R3.Observable .Interval(period: TimeSpan.FromSeconds(30), timeProvider: ObservableSystem.DefaultTimeProvider) .ObserveOnUIThreadDispatcher() - .Select(_ => DateTime.Now) - .Publish(initialValue: DateTime.Now); + .Select(_ => TimeProvider.System.GetLocalNow()) + .Publish(initialValue: TimeProvider.System.GetLocalNow()); var loadoutObservable = LoadoutSubject .Where(static id => id.HasValue) @@ -171,10 +172,19 @@ public LibraryViewModel( Adapter.MessageSubject.SubscribeAwait( onNextAsync: async (message, cancellationToken) => { - foreach (var id in message.Ids) + if (message.Payload.TryPickT0(out var multipleIds, out var singleId)) { - var libraryItem = LibraryItem.Load(_connection.Db, id); - if (!libraryItem.IsValid()) continue; + foreach (var id in multipleIds) + { + var libraryItem = LibraryItem.Load(_connection.Db, id); + if (!libraryItem.IsValid()) continue; + await InstallLibraryItem(libraryItem, _loadout, cancellationToken); + } + } + else + { + var libraryItem = LibraryItem.Load(_connection.Db, singleId); + if (!libraryItem.IsValid()) return; await InstallLibraryItem(libraryItem, _loadout, cancellationToken); } }, @@ -202,10 +212,15 @@ await Parallel.ForAsync( private LibraryItemId[] GetSelectedIds() { - return Adapter.SelectedModels - .SelectMany(model => model.GetLoadoutItemIds()) - .Distinct() - .ToArray(); + var ids1 = Adapter.SelectedModels + .OfType() + .SelectMany(static model => model.LibraryItemIds); + + var ids2 = Adapter.SelectedModels + .OfType() + .Select(static model => model.LibraryItemId); + + return ids1.Concat(ids2).Distinct().ToArray(); } private ValueTask InstallSelectedItems(bool useAdvancedInstaller, CancellationToken cancellationToken) @@ -265,22 +280,22 @@ await Parallel.ForAsync( } } -public readonly record struct InstallMessage(IReadOnlyCollection Ids); +public readonly record struct InstallMessage(OneOf, LibraryItemId> Payload); -public class LibraryTreeDataGridAdapter : TreeDataGridAdapter, +public class LibraryTreeDataGridAdapter : TreeDataGridAdapter, ITreeDataGirdMessageAdapter { private readonly ILibraryDataProvider[] _libraryDataProviders; - private readonly ConnectableObservable _ticker; + private readonly ConnectableObservable _ticker; private readonly LibraryFilter _libraryFilter; public Subject MessageSubject { get; } = new(); - private readonly Dictionary _commandDisposables = new(); + private readonly Dictionary _commandDisposables = new(); private readonly IDisposable _activationDisposable; public LibraryTreeDataGridAdapter( IServiceProvider serviceProvider, - ConnectableObservable ticker, + ConnectableObservable ticker, LibraryFilter libraryFilter) { _libraryDataProviders = serviceProvider.GetServices().ToArray(); @@ -302,33 +317,53 @@ public LibraryTreeDataGridAdapter( }); } - protected override void BeforeModelActivationHook(LibraryItemModel model) + protected override void BeforeModelActivationHook(ILibraryItemModel model) { - model.Ticker = _ticker; + if (model is IHasTicker hasTicker) + { + hasTicker.Ticker = _ticker; + } - var disposable = model.InstallCommand.Subscribe(MessageSubject, static (ids, subject) => + if (model is ILibraryItemWithInstallAction withInstallAction) { - subject.OnNext(new InstallMessage(ids)); - }); + var disposable = withInstallAction.InstallItemCommand.Subscribe(MessageSubject, static (model, subject) => + { + var payload = model switch + { + IIsParentLibraryItemModel parent => OneOf, LibraryItemId>.FromT0(parent.LibraryItemIds), + IIsChildLibraryItemModel child => child.LibraryItemId, + _ => throw new NotSupportedException(), + }; + + subject.OnNext(new InstallMessage(payload)); + }); + + var didAdd = _commandDisposables.TryAdd(model, disposable); + Debug.Assert(didAdd, "subscription for the model shouldn't exist yet"); + } - var didAdd = _commandDisposables.TryAdd(model, disposable); - Debug.Assert(didAdd, "subscription for the model shouldn't exist yet"); base.BeforeModelActivationHook(model); } - protected override void BeforeModelDeactivationHook(LibraryItemModel model) + protected override void BeforeModelDeactivationHook(ILibraryItemModel model) { - model.Ticker = null; + if (model is IHasTicker hasTicker) + { + hasTicker.Ticker = null; + } - var didRemove = _commandDisposables.Remove(model, out var disposable); - Debug.Assert(didRemove, "subscription for the model should exist"); - disposable?.Dispose(); + if (model is ILibraryItemWithAction) + { + var didRemove = _commandDisposables.Remove(model, out var disposable); + Debug.Assert(didRemove, "subscription for the model should exist"); + disposable?.Dispose(); + } base.BeforeModelDeactivationHook(model); } - protected override IObservable> GetRootsObservable(bool viewHierarchical) + protected override IObservable> GetRootsObservable(bool viewHierarchical) { var observables = viewHierarchical ? _libraryDataProviders.Select(provider => provider.ObserveNestedLibraryItems(_libraryFilter)) @@ -337,18 +372,18 @@ protected override IObservable> GetRootsO return observables.MergeChangeSets(); } - protected override IColumn[] CreateColumns(bool viewHierarchical) + protected override IColumn[] CreateColumns(bool viewHierarchical) { - var nameColumn = LibraryItemModel.CreateNameColumn(); + var nameColumn = ColumnCreator.CreateColumn(); return [ - viewHierarchical ? LibraryItemModel.CreateExpanderColumn(nameColumn) : nameColumn, - LibraryItemModel.CreateVersionColumn(), - LibraryItemModel.CreateSizeColumn(), - LibraryItemModel.CreateAddedAtColumn(), - LibraryItemModel.CreateInstalledAtColumn(), - LibraryItemModel.CreateInstallColumn(), + viewHierarchical ? ILibraryItemModel.CreateExpanderColumn(nameColumn) : nameColumn, + ColumnCreator.CreateColumn(), + ColumnCreator.CreateColumn(), + ColumnCreator.CreateColumn(), + ColumnCreator.CreateColumn(), + ColumnCreator.CreateColumn(), ]; } diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/LocalFileLibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/LocalFileLibraryItemModel.cs new file mode 100644 index 000000000..7420e2cb1 --- /dev/null +++ b/src/NexusMods.App.UI/Pages/LibraryPage/LocalFileLibraryItemModel.cs @@ -0,0 +1,101 @@ +using DynamicData; +using NexusMods.Abstractions.Library.Models; +using NexusMods.Abstractions.Loadouts; +using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; +using ObservableCollections; +using R3; + +namespace NexusMods.App.UI.Pages.LibraryPage; + +public class LocalFileLibraryItemModel : TreeDataGridItemModel, + ILibraryItemWithName, + ILibraryItemWithSize, + ILibraryItemWithDates, + ILibraryItemWithInstallAction, + IHasLinkedLoadoutItems, + IIsChildLibraryItemModel +{ + public LocalFileLibraryItemModel(LocalFile.ReadOnly localFile) + { + LibraryItemId = localFile.Id; + + FormattedSize = ItemSize.ToFormattedProperty(); + FormattedDownloadedDate = DownloadedDate.ToFormattedProperty(); + FormattedInstalledDate = InstalledDate.ToFormattedProperty(); + InstallItemCommand = ILibraryItemWithInstallAction.CreateCommand(this); + + // ReSharper disable once NotDisposedResource + var datesDisposable = ILibraryItemWithDates.SetupDates(this); + + var linkedLoadoutItemsDisposable = new SerialDisposable(); + + // ReSharper disable once NotDisposedResource + var modelActivationDisposable = this.WhenActivated(linkedLoadoutItemsDisposable, static (self, linkedLoadoutItemsDisposable, disposables) => + { + // ReSharper disable once NotDisposedResource + IHasLinkedLoadoutItems.SetupLinkedLoadoutItems(self, linkedLoadoutItemsDisposable).AddTo(disposables); + }); + + _modelDisposable = Disposable.Combine( + datesDisposable, + linkedLoadoutItemsDisposable, + modelActivationDisposable, + Name, + ItemSize, + FormattedSize, + DownloadedDate, + FormattedDownloadedDate, + InstalledDate, + FormattedInstalledDate, + InstallItemCommand, + IsInstalled, + InstallButtonText + ); + } + + public LibraryItemId LibraryItemId { get; } + + public Observable? Ticker { get; set; } + + public required IObservable> LinkedLoadoutItemsObservable { get; init; } + public ObservableDictionary LinkedLoadoutItems { get; private set; } = []; + + public BindableReactiveProperty Name { get; } = new(value: "-"); + + public ReactiveProperty ItemSize { get; } = new(); + public BindableReactiveProperty FormattedSize { get; } + + public ReactiveProperty DownloadedDate { get; } = new(); + public BindableReactiveProperty FormattedDownloadedDate { get; } + + public ReactiveProperty InstalledDate { get; } = new(); + public BindableReactiveProperty FormattedInstalledDate { get; } + + public ReactiveCommand InstallItemCommand { get; } + public BindableReactiveProperty IsInstalled { get; } = new(); + public BindableReactiveProperty InstallButtonText { get; } = new(value: ILibraryItemWithInstallAction.GetButtonText(isInstalled: false)); + + private bool _isDisposed; + private readonly IDisposable _modelDisposable; + + protected override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _modelDisposable.Dispose(); + } + + LinkedLoadoutItems = null!; + _isDisposed = true; + } + + base.Dispose(disposing); + } + + public override string ToString() => $"Local File Child: {Name.Value}"; +} diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/LocalFileParentLibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/LocalFileParentLibraryItemModel.cs new file mode 100644 index 000000000..ca5377d12 --- /dev/null +++ b/src/NexusMods.App.UI/Pages/LibraryPage/LocalFileParentLibraryItemModel.cs @@ -0,0 +1,113 @@ +using DynamicData; +using NexusMods.Abstractions.Library.Models; +using NexusMods.Abstractions.Loadouts; +using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; +using ObservableCollections; +using R3; + +namespace NexusMods.App.UI.Pages.LibraryPage; + +public class LocalFileParentLibraryItemModel : TreeDataGridItemModel, + ILibraryItemWithName, + ILibraryItemWithSize, + ILibraryItemWithDates, + ILibraryItemWithInstallAction, + IHasLinkedLoadoutItems, + IIsParentLibraryItemModel +{ + public LocalFileParentLibraryItemModel(LocalFile.ReadOnly localFile) + { + LibraryItemIds = [localFile.Id]; + + FormattedSize = ItemSize.ToFormattedProperty(); + FormattedDownloadedDate = DownloadedDate.ToFormattedProperty(); + FormattedInstalledDate = InstalledDate.ToFormattedProperty(); + InstallItemCommand = ILibraryItemWithInstallAction.CreateCommand(this); + + // ReSharper disable once NotDisposedResource + var datesDisposable = ILibraryItemWithDates.SetupDates(this); + + var linkedLoadoutItemsDisposable = new SerialDisposable(); + + // ReSharper disable once NotDisposedResource + var modelActivationDisposable = this.WhenActivated(linkedLoadoutItemsDisposable, static (self, linkedLoadoutItemsDisposable, disposables) => + { + // ReSharper disable once NotDisposedResource + IHasLinkedLoadoutItems.SetupLinkedLoadoutItems(self, linkedLoadoutItemsDisposable).AddTo(disposables); + + self.IsInstalled.AsObservable() + .CombineLatest( + source2: ReactiveUI.WhenAnyMixin.WhenAnyValue(self, static self => self.IsExpanded).ToObservable(), + resultSelector: (a, b) => (a, b) + ) + .Subscribe(self, static (tuple, self) => + { + var (isInstalled, isExpanded) = tuple; + self.InstallButtonText.Value = ILibraryItemWithInstallAction.GetButtonText(numInstalled: isInstalled ? 1 : 0, numTotal: 1, isExpanded: isExpanded); + }) + .AddTo(disposables); + }); + + _modelDisposable = Disposable.Combine( + datesDisposable, + linkedLoadoutItemsDisposable, + modelActivationDisposable, + Name, + ItemSize, + FormattedSize, + DownloadedDate, + FormattedDownloadedDate, + InstalledDate, + FormattedInstalledDate, + InstallItemCommand, + IsInstalled, + InstallButtonText + ); + } + + public IReadOnlyList LibraryItemIds { get; } + + public Observable? Ticker { get; set; } + + public required IObservable> LinkedLoadoutItemsObservable { get; init; } + public ObservableDictionary LinkedLoadoutItems { get; private set; } = []; + + public BindableReactiveProperty Name { get; } = new(value: "-"); + + public ReactiveProperty ItemSize { get; } = new(); + public BindableReactiveProperty FormattedSize { get; } + + public ReactiveProperty DownloadedDate { get; } = new(); + public BindableReactiveProperty FormattedDownloadedDate { get; } + + public ReactiveProperty InstalledDate { get; } = new(); + public BindableReactiveProperty FormattedInstalledDate { get; } + + public ReactiveCommand InstallItemCommand { get; } + public BindableReactiveProperty IsInstalled { get; } = new(); + public BindableReactiveProperty InstallButtonText { get; } = new(value: ILibraryItemWithInstallAction.GetButtonText(numInstalled: 0, numTotal: 1, isExpanded: false)); + + private bool _isDisposed; + private readonly IDisposable _modelDisposable; + + protected override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _modelDisposable.Dispose(); + } + + LinkedLoadoutItems = null!; + _isDisposed = true; + } + + base.Dispose(disposing); + } + + public override string ToString() => $"Local File Parent: {Name.Value}"; +} diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsFileLibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsFileLibraryItemModel.cs new file mode 100644 index 000000000..ac07efee0 --- /dev/null +++ b/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsFileLibraryItemModel.cs @@ -0,0 +1,105 @@ +using DynamicData; +using NexusMods.Abstractions.Library.Models; +using NexusMods.Abstractions.Loadouts; +using NexusMods.Abstractions.NexusModsLibrary; +using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; +using ObservableCollections; +using R3; + +namespace NexusMods.App.UI.Pages.LibraryPage; + +public class NexusModsFileLibraryItemModel : TreeDataGridItemModel, + ILibraryItemWithName, + ILibraryItemWithVersion, + ILibraryItemWithSize, + ILibraryItemWithDates, + ILibraryItemWithInstallAction, + IHasLinkedLoadoutItems, + IIsChildLibraryItemModel +{ + public NexusModsFileLibraryItemModel(NexusModsLibraryItem.ReadOnly nexusModsLibraryItem) + { + LibraryItemId = nexusModsLibraryItem.Id; + + FormattedSize = ItemSize.ToFormattedProperty(); + FormattedDownloadedDate = DownloadedDate.ToFormattedProperty(); + FormattedInstalledDate = InstalledDate.ToFormattedProperty(); + InstallItemCommand = ILibraryItemWithInstallAction.CreateCommand(this); + + // ReSharper disable once NotDisposedResource + var datesDisposable = ILibraryItemWithDates.SetupDates(this); + + var linkedLoadoutItemsDisposable = new SerialDisposable(); + + // ReSharper disable once NotDisposedResource + var modelActivationDisposable = this.WhenActivated(linkedLoadoutItemsDisposable, static (self, linkedLoadoutItemsDisposable, disposables) => + { + // ReSharper disable once NotDisposedResource + IHasLinkedLoadoutItems.SetupLinkedLoadoutItems(self, linkedLoadoutItemsDisposable).AddTo(disposables); + }); + + _modelDisposable = Disposable.Combine( + datesDisposable, + linkedLoadoutItemsDisposable, + modelActivationDisposable, + Name, + Version, + ItemSize, + FormattedSize, + DownloadedDate, + FormattedDownloadedDate, + InstalledDate, + FormattedInstalledDate, + InstallItemCommand, + IsInstalled, + InstallButtonText + ); + } + + public LibraryItemId LibraryItemId { get; } + + public Observable? Ticker { get; set; } + + public required IObservable> LinkedLoadoutItemsObservable { get; init; } + public ObservableDictionary LinkedLoadoutItems { get; private set; } = []; + + public BindableReactiveProperty Name { get; } = new(value: "-"); + public BindableReactiveProperty Version { get; } = new(value: "-"); + + public ReactiveProperty ItemSize { get; } = new(); + public BindableReactiveProperty FormattedSize { get; } + + public ReactiveProperty DownloadedDate { get; } = new(); + public BindableReactiveProperty FormattedDownloadedDate { get; } + + public ReactiveProperty InstalledDate { get; } = new(); + public BindableReactiveProperty FormattedInstalledDate { get; } + + public ReactiveCommand InstallItemCommand { get; } + public BindableReactiveProperty IsInstalled { get; } = new(); + public BindableReactiveProperty InstallButtonText { get; } = new(value: "Install"); + + private bool _isDisposed; + private readonly IDisposable _modelDisposable; + + protected override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _modelDisposable.Dispose(); + } + + LinkedLoadoutItems = null!; + _isDisposed = true; + } + + base.Dispose(disposing); + } + + public override string ToString() => $"Nexus Mods File: {Name.Value}"; +} diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsModPageLibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsModPageLibraryItemModel.cs index 2265238f6..047f072af 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsModPageLibraryItemModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsModPageLibraryItemModel.cs @@ -1,6 +1,10 @@ using DynamicData; using NexusMods.Abstractions.Library.Models; +using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.MnemonicDB.Attributes.Extensions; +using NexusMods.Abstractions.NexusModsLibrary; +using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; using NexusMods.MnemonicDB.Abstractions; using NexusMods.Paths; using ObservableCollections; @@ -8,54 +12,130 @@ namespace NexusMods.App.UI.Pages.LibraryPage; -public class NexusModsModPageLibraryItemModel : FakeParentLibraryItemModel +public class NexusModsModPageLibraryItemModel : TreeDataGridItemModel, + ILibraryItemWithName, + ILibraryItemWithSize, + ILibraryItemWithDates, + ILibraryItemWithInstallAction, + IHasLinkedLoadoutItems, + IIsParentLibraryItemModel { - private readonly IDisposable _modelActivationDisposable; - public NexusModsModPageLibraryItemModel(IObservable> libraryItemsObservable) - : base(default(LibraryItemId), libraryItemsObservable) + public required IObservable NumInstalledObservable { get; init; } + private ObservableHashSet LibraryItems { get; set; } = []; + + public NexusModsModPageLibraryItemModel(IObservable> libraryItemsObservable) { - _modelActivationDisposable = WhenModelActivated(this, static (model, disposables) => + FormattedSize = ItemSize.ToFormattedProperty(); + FormattedDownloadedDate = DownloadedDate.ToFormattedProperty(); + FormattedInstalledDate = InstalledDate.ToFormattedProperty(); + InstallItemCommand = ILibraryItemWithInstallAction.CreateCommand(this); + + // ReSharper disable once NotDisposedResource + var datesDisposable = ILibraryItemWithDates.SetupDates(this); + + // NOTE(erri120): This subscription needs to be set up in the constructor and kept alive + // until the entire model gets disposed. Without this, selection would break for off-screen items. + var libraryItemsDisposable = libraryItemsObservable.OnUI().SubscribeWithErrorLogging(changeSet => LibraryItems.ApplyChanges(changeSet)); + + var linkedLoadoutItemsDisposable = new SerialDisposable(); + + // ReSharper disable once NotDisposedResource + var modelActivationDisposable = this.WhenActivated(linkedLoadoutItemsDisposable, static (self, linkedLoadoutItemsDisposable, disposables) => { - model.LibraryItems + // ReSharper disable once NotDisposedResource + IHasLinkedLoadoutItems.SetupLinkedLoadoutItems(self, linkedLoadoutItemsDisposable).AddTo(disposables); + + self.LibraryItems .ObserveCountChanged(notifyCurrentCount: true) - .Subscribe(model, static (count, model) => + .Subscribe(self, static (count, self) => { - if (count == 0) + if (count > 0) { - model.CreatedAtDate.Value = DateTime.UnixEpoch; - model.ItemSize.Value = Size.Zero.ToString(); - model.Version.Value = "-"; + self.DownloadedDate.Value = self.LibraryItems.Max(static item => item.GetCreatedAt()); + self.ItemSize.Value = self.LibraryItems.Sum(static item => item.AsLibraryItem().TryGetAsLibraryFile(out var libraryFile) ? libraryFile.Size : Size.Zero); } else { - model.CreatedAtDate.Value = model.LibraryItems.Max(x => x.GetCreatedAt()); - model.ItemSize.Value = model.LibraryItems.Sum(x => x.ToLibraryFile().Size).ToString(); - - // TODO: "mod page"-version, whatever that means - model.Version.Value = "-"; + self.DownloadedDate.Value = DateTimeOffset.UnixEpoch; + self.ItemSize.Value = Size.Zero; } + }).AddTo(disposables); - model.FormattedCreatedAtDate.Value = FormatDate(DateTime.Now, model.CreatedAtDate.Value); + self.NumInstalledObservable + .ToObservable() + .CombineLatest( + source2: self.LibraryItems.ObserveCountChanged(notifyCurrentCount: true), + source3: ReactiveUI.WhenAnyMixin.WhenAnyValue(self, static self => self.IsExpanded).ToObservable(), + source4: self.IsInstalled, + static (numInstalled,numTotal,isExpanded , _) => (numInstalled, numTotal, isExpanded) + ) + .ObserveOnUIThreadDispatcher() + .Subscribe(self, static (tuple, self) => + { + var (numInstalled, numTotal, isExpanded) = tuple; + self.InstallButtonText.Value = ILibraryItemWithInstallAction.GetButtonText(numInstalled, numTotal, isExpanded); }) .AddTo(disposables); }); + + _modelDisposable = Disposable.Combine( + datesDisposable, + modelActivationDisposable, + libraryItemsDisposable, + Name, + ItemSize, + FormattedSize, + DownloadedDate, + FormattedDownloadedDate, + InstalledDate, + FormattedInstalledDate, + InstallItemCommand, + IsInstalled, + InstallButtonText + ); } + public IReadOnlyList LibraryItemIds => LibraryItems.Select(static x => (LibraryItemId)x.Id).ToArray(); + + public Observable? Ticker { get; set; } + + public required IObservable> LinkedLoadoutItemsObservable { get; init; } + public ObservableDictionary LinkedLoadoutItems { get; private set; } = []; + + public BindableReactiveProperty Name { get; } = new(value: "-"); + + public ReactiveProperty ItemSize { get; } = new(); + public BindableReactiveProperty FormattedSize { get; } + + public ReactiveProperty DownloadedDate { get; } = new(); + public BindableReactiveProperty FormattedDownloadedDate { get; } + + public ReactiveProperty InstalledDate { get; } = new(); + public BindableReactiveProperty FormattedInstalledDate { get; } + + public ReactiveCommand InstallItemCommand { get; } + public BindableReactiveProperty IsInstalled { get; } = new(); + public BindableReactiveProperty InstallButtonText { get; } = new(value: ILibraryItemWithInstallAction.GetButtonText(numInstalled: 0, numTotal: 1, isExpanded: false)); + private bool _isDisposed; + private readonly IDisposable _modelDisposable; + protected override void Dispose(bool disposing) { if (!_isDisposed) { if (disposing) { - _modelActivationDisposable.Dispose(); + _modelDisposable.Dispose(); } + LinkedLoadoutItems = null!; + LibraryItems = null!; _isDisposed = true; } base.Dispose(disposing); } - public override string ToString() => $"{base.ToString()} (Mod Page)"; + public override string ToString() => $"Nexus Mods Mod Page: {Name.Value}"; } diff --git a/src/NexusMods.App.UI/Pages/LoadoutPage/LoadoutViewModel.cs b/src/NexusMods.App.UI/Pages/LoadoutPage/LoadoutViewModel.cs index dfc64f671..e5dd9112b 100644 --- a/src/NexusMods.App.UI/Pages/LoadoutPage/LoadoutViewModel.cs +++ b/src/NexusMods.App.UI/Pages/LoadoutPage/LoadoutViewModel.cs @@ -268,7 +268,7 @@ protected override IColumn[] CreateColumns(bool viewHierarchic return [ - viewHierarchical ? LoadoutItemModel.CreateExpanderColumn(nameColumn) : nameColumn, + viewHierarchical ? ITreeDataGridItemModel.CreateExpanderColumn(nameColumn) : nameColumn, // TODO: LoadoutItemModel.CreateVersionColumn(), // TODO: LoadoutItemModel.CreateSizeColumn(), LoadoutItemModel.CreateInstalledAtColumn(), diff --git a/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs b/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs index 8d42d8ed6..8a535873e 100644 --- a/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs +++ b/src/NexusMods.App.UI/Pages/LocalFileDataProvider.cs @@ -27,7 +27,7 @@ public LocalFileDataProvider(IServiceProvider serviceProvider) _connection = serviceProvider.GetRequiredService(); } - public IObservable> ObserveFlatLibraryItems(LibraryFilter libraryFilter) + public IObservable> ObserveFlatLibraryItems(LibraryFilter libraryFilter) { // NOTE(erri120): For the flat library view, we just get all LocalFiles return _connection @@ -40,22 +40,23 @@ public IObservable> ObserveFlatLibraryIte }); } - private LibraryItemModel ToLibraryItemModel(LibraryFile.ReadOnly libraryFile, LibraryFilter libraryFilter) + private ILibraryItemModel ToLibraryItemModel(LibraryFile.ReadOnly libraryFile, LibraryFilter libraryFilter) { var linkedLoadoutItemsObservable = QueryHelper.GetLinkedLoadoutItems(_connection, libraryFile.Id, libraryFilter); - var model = new LibraryItemModel(libraryFile.Id) + var model = new LocalFileLibraryItemModel(new LocalFile.ReadOnly(libraryFile.Db, libraryFile.IndexSegment, libraryFile.Id)) { - Name = libraryFile.AsLibraryItem().Name, LinkedLoadoutItemsObservable = linkedLoadoutItemsObservable, }; - model.CreatedAtDate.Value = libraryFile.GetCreatedAt(); - model.ItemSize.Value = libraryFile.Size.ToString(); + model.Name.Value = libraryFile.AsLibraryItem().Name; + model.DownloadedDate.Value = libraryFile.GetCreatedAt(); + model.ItemSize.Value = libraryFile.Size; + return model; } - public IObservable> ObserveNestedLibraryItems(LibraryFilter libraryFilter) + public IObservable> ObserveNestedLibraryItems(LibraryFilter libraryFilter) { // NOTE(erri120): For the nested library view, design wanted to have a // parent for the LocalFile, we create a parent with one child that will @@ -67,9 +68,8 @@ public IObservable> ObserveNestedLibraryI { var libraryFile = LibraryFile.Load(_connection.Db, entityId); - var hasChildrenObservable = Observable.Return(true); - var childrenObservable = UIObservableExtensions.ReturnFactory(() => new ChangeSet([ - new Change( + var childrenObservable = UIObservableExtensions.ReturnFactory(() => new ChangeSet([ + new Change( reason: ChangeReason.Add, key: entityId, current: ToLibraryItemModel(libraryFile, libraryFilter) @@ -78,23 +78,19 @@ public IObservable> ObserveNestedLibraryI var linkedLoadoutItemsObservable = QueryHelper.GetLinkedLoadoutItems(_connection, entityId, libraryFilter); - // NOTE(erri120): LocalFiles have only one child, this can only be 0 or 1. - var numInstalledObservable = linkedLoadoutItemsObservable.IsEmpty().Select(isEmpty => isEmpty ? 0 : 1); - - var model = new FakeParentLibraryItemModel( - libraryFile.Id, - libraryItemsObservable: UIObservableExtensions.ReturnFactory(() => new ChangeSet([new Change(ChangeReason.Add, entityId, LibraryItem.Load(_connection.Db, entityId))]))) + var model = new LocalFileParentLibraryItemModel(new LocalFile.ReadOnly(libraryFile.Db, libraryFile.IndexSegment, libraryFile.Id)) { - Name = libraryFile.AsLibraryItem().Name, - HasChildrenObservable = hasChildrenObservable, + HasChildrenObservable = Observable.Return(true), ChildrenObservable = childrenObservable, + LinkedLoadoutItemsObservable = linkedLoadoutItemsObservable, - NumInstalledObservable = numInstalledObservable, }; - model.CreatedAtDate.Value = libraryFile.GetCreatedAt(); - model.ItemSize.Value = libraryFile.Size.ToString(); - return (LibraryItemModel)model; + model.Name.Value = libraryFile.AsLibraryItem().Name; + model.DownloadedDate.Value = libraryFile.GetCreatedAt(); + model.ItemSize.Value = libraryFile.Size; + + return (ILibraryItemModel)model; }); } diff --git a/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs b/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs index c57b732a2..3d5e278a1 100644 --- a/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs +++ b/src/NexusMods.App.UI/Pages/NexusModsDataProvider.cs @@ -26,7 +26,7 @@ public NexusModsDataProvider(IServiceProvider serviceProvider) _connection = serviceProvider.GetRequiredService(); } - public IObservable> ObserveFlatLibraryItems(LibraryFilter libraryFilter) + public IObservable> ObserveFlatLibraryItems(LibraryFilter libraryFilter) { // NOTE(erri120): For the flat library view, we display each NexusModsLibraryFile return NexusModsLibraryItem @@ -36,7 +36,7 @@ public IObservable> ObserveFlatLibraryIte .Transform((file, _) => ToLibraryItemModel(file, libraryFilter)); } - public IObservable> ObserveNestedLibraryItems(LibraryFilter libraryFilter) + public IObservable> ObserveNestedLibraryItems(LibraryFilter libraryFilter) { // NOTE(erri120): For the nested library view, the parents are "fake" library // models that represent the Nexus Mods mod page, with each child being a @@ -53,23 +53,24 @@ public IObservable> ObserveNestedLibraryI .Transform((modPage, _) => ToLibraryItemModel(modPage, libraryFilter)); } - private LibraryItemModel ToLibraryItemModel(NexusModsLibraryItem.ReadOnly nexusModsLibraryFile, LibraryFilter libraryFilter) + private ILibraryItemModel ToLibraryItemModel(NexusModsLibraryItem.ReadOnly nexusModsLibraryItem, LibraryFilter libraryFilter) { - var linkedLoadoutItemsObservable = QueryHelper.GetLinkedLoadoutItems(_connection, nexusModsLibraryFile.Id, libraryFilter); + var linkedLoadoutItemsObservable = QueryHelper.GetLinkedLoadoutItems(_connection, nexusModsLibraryItem.Id, libraryFilter); - var model = new LibraryItemModel(nexusModsLibraryFile.Id) + var model = new NexusModsFileLibraryItemModel(nexusModsLibraryItem) { - Name = nexusModsLibraryFile.FileMetadata.Name, LinkedLoadoutItemsObservable = linkedLoadoutItemsObservable, }; - model.CreatedAtDate.Value = nexusModsLibraryFile.GetCreatedAt(); - model.Version.Value = nexusModsLibraryFile.FileMetadata.Version; - model.ItemSize.Value = nexusModsLibraryFile.FileMetadata.Size.ToString(); + model.Name.Value = nexusModsLibraryItem.FileMetadata.Name; + model.DownloadedDate.Value = nexusModsLibraryItem.GetCreatedAt(); + model.Version.Value = nexusModsLibraryItem.FileMetadata.Version; + model.ItemSize.Value = nexusModsLibraryItem.FileMetadata.Size; + return model; } - private LibraryItemModel ToLibraryItemModel(NexusModsModPageMetadata.ReadOnly modPageMetadata, LibraryFilter libraryFilter) + private ILibraryItemModel ToLibraryItemModel(NexusModsModPageMetadata.ReadOnly modPageMetadata, LibraryFilter libraryFilter) { // TODO: dispose var cache = new SourceCache(static datom => datom.E); @@ -93,7 +94,7 @@ private LibraryItemModel ToLibraryItemModel(NexusModsModPageMetadata.ReadOnly mo .Transform((_, e) => LibraryLinkedLoadoutItem.Load(_connection.Db, e)); var libraryFilesObservable = cache.Connect() - .Transform((_, e) => NexusModsLibraryItem.Load(_connection.Db, e).AsLibraryItem()); + .Transform((_, e) => NexusModsLibraryItem.Load(_connection.Db, e)); var numInstalledObservable = cache.Connect().TransformOnObservable((_, e) => _connection .ObserveDatoms(LibraryLinkedLoadoutItem.LibraryItemId, e) @@ -103,14 +104,17 @@ private LibraryItemModel ToLibraryItemModel(NexusModsModPageMetadata.ReadOnly mo .Prepend(false) ).QueryWhenChanged(static query => query.Items.Count(static b => b)); - return new NexusModsModPageLibraryItemModel(libraryFilesObservable) + var model = new NexusModsModPageLibraryItemModel(libraryFilesObservable) { - Name = modPageMetadata.Name, HasChildrenObservable = hasChildrenObservable, ChildrenObservable = childrenObservable, + LinkedLoadoutItemsObservable = linkedLoadoutItemsObservable, NumInstalledObservable = numInstalledObservable, }; + + model.Name.Value = modPageMetadata.Name; + return model; } public IObservable> ObserveNestedLoadoutItems(LoadoutFilter loadoutFilter)