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)