Optimize asset tree construction

This commit is contained in:
Marlon
2026-07-16 13:59:51 +02:00
parent 3d07a51d6d
commit 287435d0b3
14 changed files with 220 additions and 160 deletions

View File

@@ -2,11 +2,14 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace FModel.Framework;
public sealed class RangeObservableCollection<T> : ObservableCollection<T>
{
private static readonly PropertyChangedEventArgs CountChanged = new(nameof(Count));
private static readonly PropertyChangedEventArgs IndexerChanged = new("Item[]");
private bool _suppressNotification;
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
@@ -20,15 +23,26 @@ public sealed class RangeObservableCollection<T> : ObservableCollection<T>
if (list == null)
throw new ArgumentNullException(nameof(list));
_suppressNotification = true;
var changed = false;
foreach (var item in list)
Add(item);
{
Items.Add(item);
changed = true;
}
_suppressNotification = false;
if (!changed || _suppressNotification)
return;
OnPropertyChanged(CountChanged);
OnPropertyChanged(IndexerChanged);
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
/// <summary>
/// Adds an item while constructing a collection that has not been published to a binding yet.
/// </summary>
public void AddWithoutNotification(T item) => Items.Add(item);
public void SetSuppressionState(bool state)
{
_suppressNotification = state;
@@ -38,4 +52,4 @@ public sealed class RangeObservableCollection<T> : ObservableCollection<T>
{
OnCollectionChanged(new NotifyCollectionChangedEventArgs(changedAction));
}
}
}

View File

@@ -316,7 +316,7 @@
<MultiBinding StringFormat="{}'{0}' has {1} folders and {2} packages">
<Binding Path="SelectedItem.Header" ElementName="AssetsFolderName" FallbackValue="None" />
<Binding Path="SelectedItem.FoldersView.Count" ElementName="AssetsFolderName" FallbackValue="0" />
<Binding Path="SelectedItem.AssetsList.Assets.Count" ElementName="AssetsFolderName" FallbackValue="0" />
<Binding Path="SelectedItem.AssetsList.Count" ElementName="AssetsFolderName" FallbackValue="0" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
@@ -371,7 +371,7 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding SelectedItem.AssetsList.Assets.Count, ElementName=AssetsFolderName, FallbackValue=0}" VerticalAlignment="Center" HorizontalAlignment="Left" />
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding SelectedItem.AssetsList.Count, ElementName=AssetsFolderName, FallbackValue=0}" VerticalAlignment="Center" HorizontalAlignment="Left" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="Packages Count" VerticalAlignment="Center" HorizontalAlignment="Right" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="{Binding SelectedItem.FoldersView.Count, ElementName=AssetsFolderName, FallbackValue=0}" VerticalAlignment="Center" HorizontalAlignment="Left" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="Folders Count" VerticalAlignment="Center" HorizontalAlignment="Right" />
@@ -386,7 +386,7 @@
</Grid>
</TabItem>
<TabItem Style="{StaticResource TabItemFillSpace}"
Header="{Binding SelectedItem.AssetsList.Assets.Count, FallbackValue=0, ElementName=AssetsFolderName}"
Header="{Binding SelectedItem.AssetsList.Count, FallbackValue=0, ElementName=AssetsFolderName}"
HeaderStringFormat="{}{0} Packages">
<DockPanel>

View File

@@ -338,7 +338,7 @@ public partial class MainWindow
}
var childFolder = folder;
while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0)
while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Count == 0)
{
childFolder.IsExpanded = true;
childFolder = childFolder.Folders[0];
@@ -360,14 +360,14 @@ public partial class MainWindow
if (e.Key != Key.Enter || sender is not TreeView treeView || treeView.SelectedItem is not TreeItem folder)
return;
if ((folder.IsExpanded || folder.Folders.Count == 0) && folder.AssetsList.Assets.Count > 0)
if ((folder.IsExpanded || folder.Folders.Count == 0) && folder.AssetsList.Count > 0)
{
_applicationView.SelectedLeftTabIndex++;
return;
}
var childFolder = folder;
while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0)
while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Count == 0)
{
childFolder.IsExpanded = true;
childFolder = childFolder.Folders[0];

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
@@ -16,14 +15,9 @@ using FModel.Services;
namespace FModel.ViewModels;
public class TreeItem : ViewModel
public sealed class TreeItem : ViewModel
{
private readonly string _header;
public string Header
{
get => _header;
private init => SetProperty(ref _header, value);
}
public string Header { get; }
private bool _isExpanded;
public bool IsExpanded
@@ -39,26 +33,9 @@ public class TreeItem : ViewModel
set => SetProperty(ref _isSelected, value);
}
private string _archive;
public string Archive
{
get => _archive;
private set => SetProperty(ref _archive, value);
}
private string _mountPoint;
public string MountPoint
{
get => _mountPoint;
private set => SetProperty(ref _mountPoint, value);
}
private FPackageFileVersion _version;
public FPackageFileVersion Version
{
get => _version;
private set => SetProperty(ref _version, value);
}
public string Archive { get; }
public string MountPoint { get; }
public FPackageFileVersion Version { get; }
private string _searchText = string.Empty;
public string SearchText
@@ -157,12 +134,12 @@ public class TreeItem : ViewModel
}
PathAtThisPoint = pathHere;
AssetsList.AssetsView.Filter = o => ItemFilter(o, SearchText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries));
AssetsList.SetFilter(o => ItemFilter(o, SearchText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)));
}
private void RefreshFilters()
{
AssetsList.AssetsView.Refresh();
AssetsList.RefreshView();
FilteredFoldersView?.Refresh();
}
@@ -195,7 +172,7 @@ public class TreeItem : ViewModel
RefreshFilters();
}
public override string ToString() => $"{Header} | {Folders.Count} Folders | {AssetsList.Assets.Count} Files";
public override string ToString() => $"{Header} | {Folders.Count} Folders | {AssetsList.Count} Files";
}
public class AssetsFolderViewModel
@@ -214,80 +191,98 @@ public class AssetsFolderViewModel
if (entries == null || entries.Count == 0)
return;
var treeItems = new List<TreeItem>();
var foldersByPath = new Dictionary<string, TreeItem>(StringComparer.Ordinal);
var folderLookup = foldersByPath.GetAlternateLookup<ReadOnlySpan<char>>();
TreeItem previousFolder = null;
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (var entry in entries)
{
var path = entry.Path.AsSpan();
if (path.StartsWith(localAppData.AsSpan(), StringComparison.OrdinalIgnoreCase))
{
path = path[localAppData.Length..];
while (!path.IsEmpty &&
(path[0] == Path.DirectorySeparatorChar || path[0] == Path.AltDirectorySeparatorChar))
path = path[1..];
}
var pathEnd = path.Length;
while (pathEnd > 0 && path[pathEnd - 1] == Path.AltDirectorySeparatorChar)
pathEnd--;
var lastSeparator = path[..pathEnd].LastIndexOf(Path.AltDirectorySeparatorChar);
if (lastSeparator < 0)
{
previousFolder = GetOrAddContentFolder(foldersByPath, entry, treeItems);
previousFolder.AssetsList.Add(entry);
continue;
}
var directories = path[..lastSeparator];
if (previousFolder != null && directories.SequenceEqual(previousFolder.PathAtThisPoint.AsSpan()))
{
previousFolder.AssetsList.Add(entry);
continue;
}
if (folderLookup.TryGetValue(directories, out var leafFolder))
{
leafFolder.AssetsList.Add(entry);
previousFolder = leafFolder;
continue;
}
TreeItem parentNode = null;
var segmentStart = 0;
while (segmentStart < directories.Length)
{
while (segmentStart < directories.Length && directories[segmentStart] == Path.AltDirectorySeparatorChar)
segmentStart++;
if (segmentStart == directories.Length)
break;
var segmentEnd = directories[segmentStart..].IndexOf(Path.AltDirectorySeparatorChar);
if (segmentEnd < 0)
segmentEnd = directories.Length;
else
segmentEnd += segmentStart;
var folderPath = directories[..segmentEnd];
if (!folderLookup.TryGetValue(folderPath, out var node))
{
var header = directories[segmentStart..segmentEnd].ToString();
var normalizedPath = parentNode == null
? header
: string.Concat(parentNode.PathAtThisPoint, "/", header);
if (!foldersByPath.TryGetValue(normalizedPath, out node))
{
node = new TreeItem(header, entry, normalizedPath) { Parent = parentNode };
foldersByPath.Add(normalizedPath, node);
if (parentNode == null)
treeItems.Add(node);
else
parentNode.Folders.AddWithoutNotification(node);
}
}
parentNode = node;
segmentStart = segmentEnd + 1;
}
if (parentNode == null)
parentNode = GetOrAddContentFolder(foldersByPath, entry, treeItems);
parentNode.AssetsList.Add(entry);
previousFolder = parentNode;
}
Application.Current.Dispatcher.Invoke(() =>
{
var treeItems = new RangeObservableCollection<TreeItem>();
treeItems.SetSuppressionState(true);
static TreeItem FindByHeaderOrNull(IReadOnlyList<TreeItem> list, string header)
{
for (var i = 0; i < list.Count; i++)
{
if (list[i].Header == header)
return list[i];
}
return null;
}
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData).Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (var entry in entries)
{
TreeItem lastNode = null;
TreeItem parentItem = null;
var path = entry.Path;
if (path.StartsWith(localAppData, StringComparison.OrdinalIgnoreCase))
path = path[localAppData.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var folders = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
var builder = new StringBuilder(64);
var parentNode = treeItems;
if (folders.Length <= 1)
{
var rootNode = FindByHeaderOrNull(treeItems, "Content");
if (rootNode == null)
{
rootNode = new TreeItem("Content", entry, "Content")
{
Parent = null
};
rootNode.Folders.SetSuppressionState(true);
rootNode.AssetsList.Assets.SetSuppressionState(true);
treeItems.Add(rootNode);
}
rootNode.AssetsList.Add(entry);
continue;
}
for (var i = 0; i < folders.Length - 1; i++)
{
var folder = folders[i];
builder.Append(folder).Append('/');
lastNode = FindByHeaderOrNull(parentNode, folder);
if (lastNode == null)
{
var nodePath = builder.ToString();
lastNode = new TreeItem(folder, entry, nodePath[..^1])
{
Parent = parentItem
};
lastNode.Folders.SetSuppressionState(true);
lastNode.AssetsList.Assets.SetSuppressionState(true);
parentNode.Add(lastNode);
}
parentItem = lastNode;
parentNode = lastNode.Folders;
}
lastNode?.AssetsList.Add(entry);
}
Folders.AddRange(treeItems);
if (treeItems.Count > 0)
@@ -299,26 +294,19 @@ public class AssetsFolderViewModel
}
ApplicationService.ApplicationView.CUE4Parse.SearchVm.ChangeCollection(entries);
foreach (var folder in Folders)
InvokeOnCollectionChanged(folder);
static void InvokeOnCollectionChanged(TreeItem item)
{
item.Folders.SetSuppressionState(false);
item.AssetsList.Assets.SetSuppressionState(false);
if (item.Folders.Count != 0)
{
item.Folders.InvokeOnCollectionChanged();
foreach (var folderItem in item.Folders)
InvokeOnCollectionChanged(folderItem);
}
if (item.AssetsList.Assets.Count != 0)
item.AssetsList.Assets.InvokeOnCollectionChanged();
}
});
}
private static TreeItem GetOrAddContentFolder(Dictionary<string, TreeItem> foldersByPath, GameFile entry,
List<TreeItem> roots)
{
const string content = "Content";
if (foldersByPath.TryGetValue(content, out var node))
return node;
node = new TreeItem(content, entry, content);
foldersByPath.Add(content, node);
roots.Add(node);
return node;
}
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows.Data;
using CUE4Parse.FileProvider.Objects;
@@ -7,20 +9,58 @@ namespace FModel.ViewModels;
public class AssetsListViewModel
{
public RangeObservableCollection<GameFileViewModel> Assets { get; } = [];
private List<GameFile> _pendingAssets;
private RangeObservableCollection<GameFileViewModel> _assets;
public RangeObservableCollection<GameFileViewModel> Assets
{
get
{
if (_assets != null)
return _assets;
_assets = [];
if (_pendingAssets == null)
return _assets;
foreach (var asset in _pendingAssets)
_assets.AddWithoutNotification(new GameFileViewModel(asset));
_pendingAssets = null;
return _assets;
}
}
public int Count => _assets?.Count ?? _pendingAssets?.Count ?? 0;
private ICollectionView _assetsView;
private Predicate<object> _filter;
public ICollectionView AssetsView
{
get
{
_assetsView ??= new ListCollectionView(Assets)
{
SortDescriptions = { new SortDescription("Asset.Path", ListSortDirection.Ascending) }
SortDescriptions = { new SortDescription("Asset.Path", ListSortDirection.Ascending) },
Filter = _filter
};
return _assetsView;
}
}
public void Add(GameFile gameFile) => Assets.Add(new GameFileViewModel(gameFile));
public void Add(GameFile gameFile)
{
if (_assets == null)
(_pendingAssets ??= []).Add(gameFile);
else
_assets.Add(new GameFileViewModel(gameFile));
}
public void SetFilter(Predicate<object> filter)
{
_filter = filter;
if (_assetsView != null)
_assetsView.Filter = filter;
}
public void RefreshView() => _assetsView?.Refresh();
}

View File

@@ -390,7 +390,7 @@ public class CUE4ParseViewModel : ViewModel
if (Provider == null) return;
AssetsFolder.Folders.Clear();
SearchVm.SearchResults.Clear();
SearchVm.Clear();
Helper.CloseWindow<AdonisWindow>("Search For Packages");
Provider.UnloadNonStreamedVfs();
GC.Collect();

View File

@@ -54,7 +54,7 @@ public class LoadCommand : ViewModelCommand<LoadingModesViewModel>
var loadingTime = Stopwatch.StartNew();
#endif
_applicationView.CUE4Parse.AssetsFolder.Folders.Clear();
_applicationView.CUE4Parse.SearchVm.SearchResults.Clear();
_applicationView.CUE4Parse.SearchVm.Clear();
_applicationView.SelectedLeftTabIndex = 1; // folders tab
_applicationView.IsAssetsExplorerVisible = true;
Helper.CloseWindow<AdonisWindow>("Search For Packages"); // close search window if opened

View File

@@ -61,17 +61,32 @@ public class SearchViewModel : ViewModel
private set => SetProperty(ref _refFile, value);
}
public RangeObservableCollection<GameFile> SearchResults { get; }
public ListCollectionView SearchResultsView { get; }
private List<GameFile> _searchResults = [];
public List<GameFile> SearchResults
{
get => _searchResults;
private set => SetProperty(ref _searchResults, value);
}
private ListCollectionView _searchResultsView;
public ListCollectionView SearchResultsView
{
get
{
if (_searchResultsView != null)
return _searchResultsView;
_searchResultsView = new ListCollectionView(SearchResults)
{
Filter = e => ItemFilter(e, FilterText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)),
};
ResultsCount = _searchResultsView.Count;
return _searchResultsView;
}
}
public SearchViewModel()
{
SearchResults = [];
SearchResultsView = new ListCollectionView(SearchResults)
{
Filter = e => ItemFilter(e, FilterText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)),
};
ResultsCount = SearchResultsView.Count;
ResultsCount = 0;
}
public void RefreshFilter()
@@ -82,12 +97,16 @@ public class SearchViewModel : ViewModel
public void ChangeCollection(IEnumerable<GameFile> files, GameFile refFile = null)
{
SearchResults.Clear();
SearchResults.AddRange(files);
var results = files as List<GameFile> ?? files.ToList();
_searchResultsView = null;
SearchResults = results;
RaisePropertyChanged(nameof(SearchResultsView));
RefFile = refFile;
ResultsCount = SearchResultsView.Count;
ResultsCount = results.Count;
}
public void Clear() => ChangeCollection([]);
public async Task CycleSortSizeMode()
{
CurrentSortSizeMode = CurrentSortSizeMode switch
@@ -126,8 +145,7 @@ public class SearchViewModel : ViewModel
};
});
SearchResults.Clear();
SearchResults.AddRange(sorted);
ChangeCollection(sorted, RefFile);
}
private bool ItemFilter(object item, IEnumerable<string> filters)

View File

@@ -100,7 +100,7 @@
Foreground="{DynamicResource {x:Static adonisUi:Brushes.ForegroundBrush}}"
Opacity="0.8" />
<TextBlock Grid.Row="3"
Text="{Binding AssetsList.Assets.Count, StringFormat=Assets: {0}}"
Text="{Binding AssetsList.Count, StringFormat=Assets: {0}}"
FontSize="11"
HorizontalAlignment="Center"
Foreground="{DynamicResource {x:Static adonisUi:Brushes.ForegroundBrush}}"

View File

@@ -76,7 +76,7 @@
<ColumnDefinition.Width>
<MultiBinding Converter="{x:Static converters:RatioToGridLengthConverter.Instance}">
<Binding Path="Folders.Count" />
<Binding Path="AssetsList.Assets.Count" />
<Binding Path="AssetsList.Count" />
</MultiBinding>
</ColumnDefinition.Width>
</ColumnDefinition>

View File

@@ -39,7 +39,7 @@ public partial class ResourcesDictionary
// Auto expand single child folders
var childFolder = folder;
while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0)
while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Count == 0)
{
childFolder.IsExpanded = true;
childFolder = childFolder.Folders[0];

View File

@@ -308,7 +308,7 @@
Background="#705542"
Margin="-10.5 0 4.5 0"
VerticalAlignment="Bottom">
<TextBlock Text="{Binding AssetsList.Assets.Count}"
<TextBlock Text="{Binding AssetsList.Count}"
Margin="2.5 0 2.5 0"
Padding="1 0 0 0"
FontSize="7"
@@ -325,7 +325,7 @@
<MultiBinding StringFormat="{}{0} has {1} folders and {2} packages">
<Binding Path="Header" />
<Binding Path="FoldersView.Count" />
<Binding Path="AssetsList.Assets.Count" />
<Binding Path="AssetsList.Count" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
@@ -340,7 +340,7 @@
Property="Source"
Value="/FModel;component/Resources/empty_folder.png" />
</DataTrigger>
<DataTrigger Binding="{Binding AssetsList.Assets.Count}"
<DataTrigger Binding="{Binding AssetsList.Count}"
Value="0">
<Setter TargetName="TreeBadge"
Property="Visibility"

View File

@@ -150,7 +150,7 @@ public partial class SearchView
MainWindow.YesWeCats.Activate();
do
{ await Task.Delay(100); } while (MainWindow.YesWeCats.AssetsListName.Items.Count < folder.AssetsList.Assets.Count);
{ await Task.Delay(100); } while (MainWindow.YesWeCats.AssetsListName.Items.Count < folder.AssetsList.Count);
while (!folder.IsSelected || MainWindow.YesWeCats.AssetsFolderName.SelectedItem != folder)
await Task.Delay(50); // stops assets tab from opening too early