Skip to content

Commit

Permalink
Merge pull request #2509 from erri120/feat/composite-2
Browse files Browse the repository at this point in the history
Rework TreeDataGrid for Loadouts
  • Loading branch information
erri120 authored Jan 22, 2025
2 parents 5ac0a27 + 4b4384e commit 10e80f4
Show file tree
Hide file tree
Showing 41 changed files with 713 additions and 894 deletions.
16 changes: 15 additions & 1 deletion src/NexusMods.App.UI/Controls/TreeDataGrid/AValueComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ namespace NexusMods.App.UI.Controls;
/// </summary>
[PublicAPI]
public abstract class AValueComponent<T> : ReactiveR3Object, IItemModelComponent
where T : notnull
{
/// <summary>
/// Gets the value property.
Expand Down Expand Up @@ -78,3 +77,18 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
}

public class ValueComponent<T> : AValueComponent<T>
{
public ValueComponent(
T initialValue,
IObservable<T> valueObservable,
bool subscribeWhenCreated = false) : base(initialValue, valueObservable, subscribeWhenCreated) { }

public ValueComponent(
T initialValue,
Observable<T> valueObservable,
bool subscribeWhenCreated = false) : base(initialValue, valueObservable, subscribeWhenCreated) { }

public ValueComponent(T value) : base(value) { }
}
178 changes: 131 additions & 47 deletions src/NexusMods.App.UI/Controls/TreeDataGrid/ColumnContentControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
using Avalonia.Controls.Presenters;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml.Templates;
using DynamicData.Kernel;
using JetBrains.Annotations;
using NexusMods.App.UI.Extensions;
using ObservableCollections;
using R3;

Expand Down Expand Up @@ -43,75 +46,56 @@ public DataTemplate DataTemplate
}

/// <summary>
/// Control for columns where row models are <see cref="CompositeItemModel{TKey}"/>.
/// Custom <see cref="ContentControl"/> to reactively build the control.
/// </summary>
public class ColumnContentControl<TKey> : ContentControl
where TKey : notnull
[PublicAPI]
public abstract class AReactiveContentControl<TContent> : ContentControl
where TContent : class
{
[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global", Justification = "Updated in XAML")]
public List<IComponentTemplate> AvailableTemplates { get; } = [];

public Control? Fallback { get; set; }

private readonly SerialDisposable _serialDisposable = new();

/// <summary>
/// Builds a control from the first template in <see cref="AvailableTemplates"/>
/// that matches with a component in the item model.
/// Builds a control for the given content.
/// </summary>
private Control? BuildContent(CompositeItemModel<TKey> itemModel)
{
foreach (var template in AvailableTemplates)
{
if (!itemModel.TryGet(template.ComponentKey, template.ComponentType, out var component)) continue;

// NOTE(erri120): DataTemplate.Build doesn't use the
// data you give it, need to manually set the DataContext.
// Otherwise. the new control will inherit the parent context,
// which is CompositeItemModel<TKey>.
var control = template.DataTemplate.Build(data: null);
if (control is null) return null;

control.DataContext = component;
return control;
}
protected abstract Control? BuildContentControl(TContent content, out Optional<string> contentPresenterClass);

return Fallback;
}
/// <summary>
/// Gets an observable stream to re-trigger content changes.
/// </summary>
protected abstract Observable<Unit> GetObservable(TContent content);

/// <summary>
/// Subscribes to component changes in the item model and rebuilds the content.
/// Subscribes to content changes.
/// </summary>
private IDisposable Subscribe(CompositeItemModel<TKey> itemModel)
protected virtual IDisposable Subscribe(TContent content)
{
return itemModel.Components
.ObserveChanged()
.ObserveOnUIThreadDispatcher()
.Subscribe((this, itemModel), static (_, state) =>
{
var (self, itemModel) = state;
if (self.Presenter is null) return;
return GetObservable(content).ObserveOnUIThreadDispatcher().Subscribe((this, content), static (_, state) =>
{
var (self, content) = state;
if (self.Presenter is null) return;

var content = self.BuildContent(itemModel);
self.Presenter.Content = content;
});
var control = self.BuildContentControl(content, out var optionalClass);
self.SetContentControl(control, optionalClass);
});
}

protected override Type StyleKeyOverride => typeof(ContentControl);

protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);

if (change.Property == ContentProperty)
{
if (change.NewValue is not CompositeItemModel<TKey> itemModel)
if (change.NewValue is not TContent content)
{
_serialDisposable.Disposable = null;
return;
}

// NOTE(erri120): we only care about Content changes when the
// Control is fully constructed and rendered on screen.
if (IsLoaded) _serialDisposable.Disposable = Subscribe(itemModel);
if (IsLoaded) _serialDisposable.Disposable = Subscribe(content);
}
}

Expand All @@ -120,27 +104,42 @@ protected override bool RegisterContentPresenter(ContentPresenter presenter)
var didRegister = base.RegisterContentPresenter(presenter);

// NOTE(erri120): Puts content into the presenter before the first render.
if (didRegister && Content is CompositeItemModel<TKey> itemModel)
if (didRegister && Content is TContent content)
{
var content = BuildContent(itemModel);
presenter.Content = content;
var control = BuildContentControl(content, out var optionalClass);
SetContentControl(control, optionalClass);
}

return didRegister;
}

protected void SetContentControl(Control? contentControl, Optional<string> newClass)
{
if (Presenter is null) throw new InvalidOperationException();

// NOTE(erri120): somewhat of a hack but this allows styles selector to work properly
// otherwise there would be no parent to select. We can't select ContentPresenter
// from styles because that's part of the template, and we can't use /template/
// because the parent is generic, and generics don't work in style selectors...
var border = new Border();
if (newClass.HasValue) border.Classes.Set(newClass.Value, true);
border.Child = contentControl;

Presenter.Content = border;
}

protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);

if (Content is not CompositeItemModel<TKey> itemModel)
if (Content is not TContent content)
{
_serialDisposable.Disposable = null;
return;
}

Debug.Assert(_serialDisposable.Disposable is null, "nothing should've subscribed yet");
_serialDisposable.Disposable = Subscribe(itemModel);
_serialDisposable.Disposable = Subscribe(content);
}

protected override void OnUnloaded(RoutedEventArgs e)
Expand All @@ -150,3 +149,88 @@ protected override void OnUnloaded(RoutedEventArgs e)
_serialDisposable.Disposable = null;
}
}

[PublicAPI]
public class ComponentControl<TKey> : AReactiveContentControl<CompositeItemModel<TKey>>
where TKey : notnull
{
public IComponentTemplate? ComponentTemplate { get; set; }

public Control? Fallback { get; set; }

protected override Control? BuildContentControl(CompositeItemModel<TKey> itemModel, out Optional<string> contentPresenterClass)
{
contentPresenterClass = Optional<string>.None;

if (ComponentTemplate is null) throw new InvalidOperationException();
if (!itemModel.TryGet(ComponentTemplate.ComponentKey, ComponentTemplate.ComponentType, out var component)) return Fallback;

// NOTE(erri120): DataTemplate.Build doesn't use the
// data you give it, need to manually set the DataContext.
// Otherwise. the new control will inherit the parent context,
// which is CompositeItemModel<TKey>.
var control = ComponentTemplate.DataTemplate.Build(data: null);
if (control is null) return Fallback;

control.DataContext = component;
contentPresenterClass = ComponentTemplate.ComponentKey.Value;
return control;
}

protected override Observable<Unit> GetObservable(CompositeItemModel<TKey> itemModel)
{
if (ComponentTemplate is null) throw new InvalidOperationException();
var key = ComponentTemplate.ComponentKey;

return itemModel.Components
.ObserveKeyChanges(key)
.Select(static _ => Unit.Default);
}
}

/// <summary>
/// Control for columns where row models are <see cref="CompositeItemModel{TKey}"/>.
/// </summary>
[PublicAPI]
public class MultiComponentControl<TKey> : AReactiveContentControl<CompositeItemModel<TKey>>
where TKey : notnull
{
[SuppressMessage("ReSharper", "CollectionNeverUpdated.Global", Justification = "Updated in XAML")]
public List<IComponentTemplate> AvailableTemplates { get; } = [];

public Control? Fallback { get; set; }

/// <summary>
/// Builds a control from the first template in <see cref="AvailableTemplates"/>
/// that matches with a component in the item model.
/// </summary>
protected override Control? BuildContentControl(CompositeItemModel<TKey> itemModel, out Optional<string> contentPresenterClass)
{
contentPresenterClass = Optional<string>.None;

foreach (var template in AvailableTemplates)
{
if (!itemModel.TryGet(template.ComponentKey, template.ComponentType, out var component)) continue;

// NOTE(erri120): DataTemplate.Build doesn't use the
// data you give it, need to manually set the DataContext.
// Otherwise. the new control will inherit the parent context,
// which is CompositeItemModel<TKey>.
var control = template.DataTemplate.Build(data: null);
if (control is null) continue;

control.DataContext = component;
contentPresenterClass = template.ComponentKey.Value;
return control;
}

return Fallback;
}

protected override Observable<Unit> GetObservable(CompositeItemModel<TKey> itemModel)
{
return itemModel.Components
.ObserveChanged()
.Select(static _ => Unit.Default);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Controls.Models.TreeDataGrid;
using DynamicData.Kernel;
using JetBrains.Annotations;
Expand All @@ -18,7 +19,9 @@ static virtual int Compare<TKey>(Optional<CompositeItemModel<TKey>> a, Optional<

static abstract int Compare<TKey>(CompositeItemModel<TKey> a, CompositeItemModel<TKey> b) where TKey : notnull;

static virtual IColumn<CompositeItemModel<TKey>> CreateColumn<TKey>(Optional<ListSortDirection> sortDirection = default)
static virtual IColumn<CompositeItemModel<TKey>> CreateColumn<TKey>(
Optional<ListSortDirection> sortDirection = default,
Optional<GridLength> width = default)
where TKey : notnull
{
return new CustomTemplateColumn<CompositeItemModel<TKey>>(
Expand All @@ -30,7 +33,8 @@ static virtual IColumn<CompositeItemModel<TKey>> CreateColumn<TKey>(Optional<Lis
CanUserResizeColumn = true,
CompareAscending = static (a, b) => TSelf.Compare(Optional<CompositeItemModel<TKey>>.Create(a), b),
CompareDescending = static (a, b) => TSelf.Compare(Optional<CompositeItemModel<TKey>>.Create(b), a),
}
},
width: width.ValueOrDefault()
)
{
Id = TSelf.GetColumnId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Controls.Models.TreeDataGrid;
using DynamicData.Kernel;
using JetBrains.Annotations;
Expand Down Expand Up @@ -38,10 +39,12 @@ static virtual int Compare(TSelf? a, TSelf? b)

public static partial class ColumnCreator
{
public static IColumn<CompositeItemModel<TKey>> Create<TKey, TColumn>(Optional<ListSortDirection> sortDirection = default)
public static IColumn<CompositeItemModel<TKey>> Create<TKey, TColumn>(
Optional<ListSortDirection> sortDirection = default,
Optional<GridLength> width = default)
where TKey : notnull
where TColumn : class, ICompositeColumnDefinition<TColumn>
{
return TColumn.CreateColumn<TKey>(sortDirection);
return TColumn.CreateColumn<TKey>(sortDirection, width);
}
}
50 changes: 50 additions & 0 deletions src/NexusMods.App.UI/Controls/TreeDataGrid/ImageComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Avalonia.Media.Imaging;
using BitFaster.Caching;
using JetBrains.Annotations;
using NexusMods.Abstractions.Resources;
using R3;

namespace NexusMods.App.UI.Controls;

/// <summary>
/// Component for <see cref="Bitmap"/>.
/// </summary>
[PublicAPI]
public sealed class ImageComponent : AValueComponent<Bitmap>, IItemModelComponent<ImageComponent>, IComparable<ImageComponent>
{
/// <inheritdoc/>
public int CompareTo(ImageComponent? other) => other is null ? 1 : 0;

/// <inheritdoc/>
public ImageComponent(
Bitmap initialValue,
IObservable<Bitmap> valueObservable,
bool subscribeWhenCreated = false) : base(initialValue, valueObservable, subscribeWhenCreated) { }

/// <inheritdoc/>
public ImageComponent(
Bitmap initialValue,
Observable<Bitmap> valueObservable,
bool subscribeWhenCreated = false) : base(initialValue, valueObservable, subscribeWhenCreated) { }

/// <inheritdoc/>
public ImageComponent(Bitmap value) : base(value) { }

public static ImageComponent FromPipeline<TId>(
IResourceLoader<TId, Bitmap> pipeline,
TId id,
Bitmap initialValue)
where TId : notnull
{
var observable = Observable
.Return(id)
.ObserveOnThreadPool()
.SelectAwait(async (_, cancellationToken) => await pipeline.LoadResourceAsync(id, cancellationToken), configureAwait: false)
.Select(static resource => resource.Data);

return new ImageComponent(
initialValue: initialValue,
valueObservable: observable
);
}
}
7 changes: 4 additions & 3 deletions src/NexusMods.App.UI/Controls/TreeDataGrid/SharedColumns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public static int Compare<TKey>(CompositeItemModel<TKey> a, CompositeItemModel<T
}

public const string ColumnTemplateResourceKey = Prefix + "Name";
public static readonly ComponentKey StringComponentKey = ComponentKey.From(Prefix + nameof(Name) + nameof(StringComponent));
public static readonly ComponentKey StringComponentKey = ComponentKey.From(ColumnTemplateResourceKey + "_" + nameof(StringComponent));
public static readonly ComponentKey ImageComponentKey = ComponentKey.From(ColumnTemplateResourceKey + "_" + nameof(ImageComponent));

public static string GetColumnHeader() => "Name";
public static string GetColumnTemplateResourceKey() => ColumnTemplateResourceKey;
Expand All @@ -34,7 +35,7 @@ public static int Compare<TKey>(CompositeItemModel<TKey> a, CompositeItemModel<T
}

public const string ColumnTemplateResourceKey = Prefix + "InstalledDate";
public static readonly ComponentKey ComponentKey = ComponentKey.From(Prefix + nameof(InstalledDate) + nameof(DateComponent));
public static readonly ComponentKey ComponentKey = ComponentKey.From(ColumnTemplateResourceKey + "_" + nameof(DateComponent));

public static string GetColumnHeader() => "Installed";
public static string GetColumnTemplateResourceKey() => ColumnTemplateResourceKey;
Expand All @@ -50,7 +51,7 @@ public static int Compare<TKey>(CompositeItemModel<TKey> a, CompositeItemModel<T
}

public const string ColumnTemplateResourceKey = Prefix + "DownloadedDate";
public static readonly ComponentKey ComponentKey = ComponentKey.From(Prefix + nameof(DownloadedDate) + nameof(DateComponent));
public static readonly ComponentKey ComponentKey = ComponentKey.From(ColumnTemplateResourceKey + "_" + nameof(DateComponent));

public static string GetColumnHeader() => "Downloaded";
public static string GetColumnTemplateResourceKey() => ColumnTemplateResourceKey;
Expand Down
Loading

0 comments on commit 10e80f4

Please sign in to comment.