diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index 90f2ae63..36342c3b 100644 --- a/FModel/MainWindow.xaml.cs +++ b/FModel/MainWindow.xaml.cs @@ -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 SelectFolderAsync(IReadOnlyList 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 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(parent); + if (presenter == null) + { + parent.UpdateLayout(); + presenter = FindVisualChild(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(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(child) is { } descendant) + return descendant; + } + + return null; + } + private void OnAssetsTreeSelectedItemChanged(object sender, RoutedPropertyChangedEventArgs e) { if (sender is not TreeView { SelectedItem: TreeItem }) return; diff --git a/FModel/ViewModels/ApplicationViewModel.cs b/FModel/ViewModels/ApplicationViewModel.cs index 93f746c0..3120ddd4 100644 --- a/FModel/ViewModels/ApplicationViewModel.cs +++ b/FModel/ViewModels/ApplicationViewModel.cs @@ -60,6 +60,8 @@ public class ApplicationViewModel : ViewModel } } + public bool IsAssetPreviewLoadingSuspended { get; set; } + private int _selectedLeftTabIndex; public int SelectedLeftTabIndex { diff --git a/FModel/ViewModels/AssetsFolderViewModel.cs b/FModel/ViewModels/AssetsFolderViewModel.cs index b25bc5f0..544a2532 100644 --- a/FModel/ViewModels/AssetsFolderViewModel.cs +++ b/FModel/ViewModels/AssetsFolderViewModel.cs @@ -177,6 +177,8 @@ public sealed class TreeItem : ViewModel public class AssetsFolderViewModel { + private Dictionary _foldersByPath = new(StringComparer.Ordinal); + public RangeObservableCollection 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(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 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) diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index eedf7b1f..8c445f32 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -389,7 +389,7 @@ public class CUE4ParseViewModel : ViewModel { if (Provider == null) return; - AssetsFolder.Folders.Clear(); + AssetsFolder.Clear(); SearchVm.Clear(); Helper.CloseWindow("Search For Packages"); Provider.UnloadNonStreamedVfs(); diff --git a/FModel/ViewModels/Commands/GoToCommand.cs b/FModel/ViewModels/Commands/GoToCommand.cs index 226d5026..90dbd46e 100644 --- a/FModel/ViewModels/Commands/GoToCommand.cs +++ b/FModel/ViewModels/Commands/GoToCommand.cs @@ -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 { } - 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 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(); + for (var ancestor = folder; ancestor != null; ancestor = ancestor.Parent) + ancestors.Push(ancestor); + + var path = new List(ancestors.Count); + while (ancestors.TryPop(out var ancestor)) + path.Add(ancestor); + + return await MainWindow.YesWeCats.SelectFolderAsync(path) ? folder : null; } } diff --git a/FModel/ViewModels/Commands/LoadCommand.cs b/FModel/ViewModels/Commands/LoadCommand.cs index 7ee3780c..c71e4ce7 100644 --- a/FModel/ViewModels/Commands/LoadCommand.cs +++ b/FModel/ViewModels/Commands/LoadCommand.cs @@ -53,7 +53,7 @@ public class LoadCommand : ViewModelCommand #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; diff --git a/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs b/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs index 7c8187a0..94ee4881 100644 --- a/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs +++ b/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs @@ -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); } } diff --git a/FModel/Views/Resources/Controls/NavigableVirtualizingStackPanel.cs b/FModel/Views/Resources/Controls/NavigableVirtualizingStackPanel.cs new file mode 100644 index 00000000..eea2cbe0 --- /dev/null +++ b/FModel/Views/Resources/Controls/NavigableVirtualizingStackPanel.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace FModel.Views.Resources.Controls; + +/// +/// Exposes container realization for programmatic navigation in a virtualized tree. +/// +public sealed class NavigableVirtualizingStackPanel : VirtualizingStackPanel +{ + public void BringItemIntoView(int index) => BringIndexIntoView(index); +} diff --git a/FModel/Views/Resources/Resources.xaml b/FModel/Views/Resources/Resources.xaml index 670c7f83..876e6440 100644 --- a/FModel/Views/Resources/Resources.xaml +++ b/FModel/Views/Resources/Resources.xaml @@ -291,6 +291,19 @@ Value="Docked" /> + + + + + + + + + + @@ -572,6 +585,13 @@ BasedOn="{StaticResource TreeViewItemStyle}"> + + + + + + + () - .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() + .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)