Optimize asset folder navigation

This commit is contained in:
Marlon 2026-07-16 15:27:08 +02:00
parent 287435d0b3
commit 0e92b201f5
No known key found for this signature in database
GPG Key ID: 5C65CA190C38792F
10 changed files with 215 additions and 52 deletions

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
@ -7,6 +8,8 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using FModel.Services;
using FModel.Settings;
using FModel.ViewModels;
@ -254,6 +257,9 @@ public partial class MainWindow
private void OnPreviewTexturesToggled(object sender, RoutedEventArgs e) => ItemContainerGenerator_StatusChanged(AssetsExplorer.ItemContainerGenerator, EventArgs.Empty);
private void ItemContainerGenerator_StatusChanged(object sender, EventArgs e)
{
if (_applicationView.IsAssetPreviewLoadingSuspended)
return;
if (sender is not ItemContainerGenerator { Status: GeneratorStatus.ContainersGenerated } generator)
return;
@ -277,6 +283,94 @@ public partial class MainWindow
}
}
public void RefreshVisibleAssetPreviews()
=> ItemContainerGenerator_StatusChanged(AssetsExplorer.ItemContainerGenerator, EventArgs.Empty);
public async Task<bool> SelectFolderAsync(IReadOnlyList<TreeItem> path)
{
if (path.Count == 0)
return false;
// Set the complete model path first. A virtualized container will pick up the
// expansion state whenever WPF realizes it, independently of UI timing.
for (var i = 0; i < path.Count - 1; i++)
path[i].IsExpanded = true;
ItemsControl parent = AssetsFolderName;
TreeViewItem container = null;
for (var i = 0; i < path.Count; i++)
{
container = await GetTreeViewItemAsync(parent, path[i]);
if (container == null)
return false;
// Only ancestors must be expanded. Expanding the target itself can realize a
// large child subtree even though Go To never needs to display those children.
if (i < path.Count - 1)
{
container.IsExpanded = true;
}
parent = container;
}
container.IsSelected = false;
container.IsSelected = true;
await Dispatcher.InvokeAsync(static () => { }, DispatcherPriority.Background);
return ReferenceEquals(AssetsFolderName.SelectedItem, path[^1]);
}
private async Task<TreeViewItem> GetTreeViewItemAsync(ItemsControl parent, TreeItem item)
{
// FoldersView is bound with IsAsync=True. For large sibling collections its sorted
// view can take substantially longer than a fixed number of dispatcher turns.
var timeoutAt = DateTime.UtcNow + TimeSpan.FromSeconds(15);
while (DateTime.UtcNow < timeoutAt)
{
parent.ApplyTemplate();
var presenter = parent.Template.FindName("ItemsHost", parent) as ItemsPresenter ??
FindVisualChild<ItemsPresenter>(parent);
if (presenter == null)
{
parent.UpdateLayout();
presenter = FindVisualChild<ItemsPresenter>(parent);
}
presenter?.ApplyTemplate();
var index = parent.Items.IndexOf(item);
if (index >= 0 && presenter != null && VisualTreeHelper.GetChildrenCount(presenter) > 0 &&
VisualTreeHelper.GetChild(presenter, 0) is NavigableVirtualizingStackPanel panel)
{
_ = panel.Children; // Ensure that the item generator is connected.
panel.BringItemIntoView(index);
parent.UpdateLayout();
if (parent.ItemContainerGenerator.ContainerFromIndex(index) is TreeViewItem container)
return container;
}
await Task.Delay(1);
}
return null;
}
private static T FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T match)
return match;
if (FindVisualChild<T>(child) is { } descendant)
return descendant;
}
return null;
}
private void OnAssetsTreeSelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (sender is not TreeView { SelectedItem: TreeItem }) return;

View File

@ -60,6 +60,8 @@ public class ApplicationViewModel : ViewModel
}
}
public bool IsAssetPreviewLoadingSuspended { get; set; }
private int _selectedLeftTabIndex;
public int SelectedLeftTabIndex
{

View File

@ -177,6 +177,8 @@ public sealed class TreeItem : ViewModel
public class AssetsFolderViewModel
{
private Dictionary<string, TreeItem> _foldersByPath = new(StringComparer.Ordinal);
public RangeObservableCollection<TreeItem> Folders { get; }
public ICollectionView FoldersView { get; }
@ -186,6 +188,38 @@ public class AssetsFolderViewModel
FoldersView = new ListCollectionView(Folders) { SortDescriptions = { new SortDescription("Header", ListSortDirection.Ascending) } };
}
public void Clear()
{
Folders.Clear();
_foldersByPath = new Dictionary<string, TreeItem>(StringComparer.Ordinal);
}
public bool TryGetFolder(string directory, out TreeItem folder)
{
folder = null;
if (string.IsNullOrEmpty(directory))
return false;
directory = directory.TrimEnd(Path.AltDirectorySeparatorChar);
if (_foldersByPath.TryGetValue(directory, out folder))
return true;
// Preserve the previous behavior where only the root segment was case-insensitive.
var separator = directory.IndexOf(Path.AltDirectorySeparatorChar);
var rootName = separator < 0 ? directory : directory[..separator];
var root = Folders.FirstOrDefault(x => x.Header.Equals(rootName, StringComparison.OrdinalIgnoreCase));
if (root == null)
return false;
if (separator < 0)
{
folder = root;
return true;
}
return _foldersByPath.TryGetValue(string.Concat(root.Header, directory[separator..]), out folder);
}
public void BulkPopulate(IReadOnlyCollection<GameFile> entries)
{
if (entries == null || entries.Count == 0)
@ -283,6 +317,7 @@ public class AssetsFolderViewModel
Application.Current.Dispatcher.Invoke(() =>
{
_foldersByPath = foldersByPath;
Folders.AddRange(treeItems);
if (treeItems.Count > 0)

View File

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

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FModel.Framework;
using FModel.Services;
@ -12,46 +13,41 @@ public class GoToCommand : ViewModelCommand<CustomDirectoriesViewModel>
{
}
public override void Execute(CustomDirectoriesViewModel contextViewModel, object parameter)
public override async void Execute(CustomDirectoriesViewModel contextViewModel, object parameter)
{
if (parameter is not string s || string.IsNullOrEmpty(s)) return;
JumpTo(s);
await JumpToAsync(s);
}
public TreeItem JumpTo(string directory)
public async Task<TreeItem> JumpToAsync(string directory)
{
_applicationView.SelectedLeftTabIndex = 1; // folders tab
var root = _applicationView.CUE4Parse.AssetsFolder.Folders;
if (root is not { Count: > 0 }) return null;
if (!_applicationView.CUE4Parse.AssetsFolder.TryGetFolder(directory, out var folder))
return null;
var i = 0;
var done = false;
var folders = directory.Split('/');
while (!done)
// An ancestor of the selected folder is already realized. Selecting it directly
// avoids running the virtualized path walker again (notably for breadcrumbs).
if (MainWindow.YesWeCats.AssetsFolderName.SelectedItem is TreeItem selectedFolder)
{
foreach (var folder in root)
for (var ancestor = selectedFolder; ancestor != null; ancestor = ancestor.Parent)
{
if (!folder.Header.Equals(folders[i], i == 0 ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))
if (!ReferenceEquals(ancestor, folder))
continue;
folder.IsExpanded = true; // folder found = expand
// is this the last folder aka the one we want to jump in
if (i >= folders.Length - 1)
{
folder.IsSelected = true; // select it
return folder;
}
root = folder.Folders; // grab his subfolders
break;
folder.IsSelected = true;
return folder;
}
i++;
done = i == folders.Length || root.Count == 0;
}
return null;
var ancestors = new Stack<TreeItem>();
for (var ancestor = folder; ancestor != null; ancestor = ancestor.Parent)
ancestors.Push(ancestor);
var path = new List<TreeItem>(ancestors.Count);
while (ancestors.TryPop(out var ancestor))
path.Add(ancestor);
return await MainWindow.YesWeCats.SelectFolderAsync(path) ? folder : null;
}
}

View File

@ -53,7 +53,7 @@ public class LoadCommand : ViewModelCommand<LoadingModesViewModel>
#if DEBUG
var loadingTime = Stopwatch.StartNew();
#endif
_applicationView.CUE4Parse.AssetsFolder.Folders.Clear();
_applicationView.CUE4Parse.AssetsFolder.Clear();
_applicationView.CUE4Parse.SearchVm.Clear();
_applicationView.SelectedLeftTabIndex = 1; // folders tab
_applicationView.IsAssetsExplorerVisible = true;

View File

@ -89,13 +89,13 @@ public partial class Breadcrumb
}
}
private void OnMouseClick(object sender, MouseButtonEventArgs e)
private async void OnMouseClick(object sender, MouseButtonEventArgs e)
{
if (sender is not Border { DataContext: string pathAtThisPoint, Tag: int index }) return;
var directory = string.Join('/', pathAtThisPoint.Split('/').Take(index));
if (pathAtThisPoint.Equals(directory)) return;
ApplicationService.ApplicationView.CustomDirectories.GoToCommand.JumpTo(directory);
await ApplicationService.ApplicationView.CustomDirectories.GoToCommand.JumpToAsync(directory);
}
}

View File

@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace FModel.Views.Resources.Controls;
/// <summary>
/// Exposes container realization for programmatic navigation in a virtualized tree.
/// </summary>
public sealed class NavigableVirtualizingStackPanel : VirtualizingStackPanel
{
public void BringItemIntoView(int index) => BringIndexIntoView(index);
}

View File

@ -291,6 +291,19 @@
Value="Docked" />
<Setter Property="HorizontalContentAlignment"
Value="Stretch" />
<Setter Property="VirtualizingPanel.IsVirtualizing"
Value="True" />
<Setter Property="VirtualizingPanel.VirtualizationMode"
Value="Recycling" />
<Setter Property="ScrollViewer.CanContentScroll"
Value="True" />
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<controls:NavigableVirtualizingStackPanel />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemTemplate">
<Setter.Value>
<HierarchicalDataTemplate ItemsSource="{Binding FoldersView, IsAsync=True}">
@ -572,6 +585,13 @@
BasedOn="{StaticResource TreeViewItemStyle}">
<Setter Property="HorizontalContentAlignment"
Value="Stretch" />
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<controls:NavigableVirtualizingStackPanel />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="IsSelected"
Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="IsExpanded"

View File

@ -141,30 +141,35 @@ public partial class SearchView
private async Task NavigateToAssetAndSelect(GameFile entry)
{
WindowState = WindowState.Minimized;
MainWindow.YesWeCats.AssetsListName.ItemsSource = null;
var folder = _applicationView.CustomDirectories.GoToCommand.JumpTo(entry.Directory);
if (folder == null)
return;
MainWindow.YesWeCats.Activate();
do
{ 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
ApplicationService.ApplicationView.SelectedLeftTabIndex = 2; // assets tab
do
_applicationView.IsAssetPreviewLoadingSuspended = true;
try
{
await Task.Delay(100);
var vm = MainWindow.YesWeCats.AssetsListName.Items
.OfType<GameFileViewModel>()
.FirstOrDefault(x => x.Asset == entry);
WindowState = WindowState.Minimized;
MainWindow.YesWeCats.AssetsListName.ItemsSource = null;
var folder = await _applicationView.CustomDirectories.GoToCommand.JumpToAsync(entry.Directory);
if (folder == null)
return;
MainWindow.YesWeCats.Activate();
while (MainWindow.YesWeCats.AssetsListName.Items.Count < folder.AssetsList.Count)
await Task.Delay(10);
ApplicationService.ApplicationView.SelectedLeftTabIndex = 2; // assets tab
GameFileViewModel vm;
while ((vm = MainWindow.YesWeCats.AssetsListName.Items
.OfType<GameFileViewModel>()
.FirstOrDefault(x => x.Asset == entry)) == null)
await Task.Delay(10);
MainWindow.YesWeCats.AssetsListName.SelectedItem = vm;
MainWindow.YesWeCats.AssetsListName.ScrollIntoView(vm);
} while (MainWindow.YesWeCats.AssetsListName.SelectedItem == null);
}
finally
{
_applicationView.IsAssetPreviewLoadingSuspended = false;
MainWindow.YesWeCats.RefreshVisibleAssetPreviews();
}
}
private async void OnAssetExtract(object sender, RoutedEventArgs e)