diff --git a/CUE4Parse b/CUE4Parse index 396ede2a..25d982ec 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 396ede2a1d89752142c3b73dba7c908467ccf3a3 +Subproject commit 25d982ecf40620038525ebd7adbd07ed644df2fe diff --git a/FModel/Framework/RangeObservableCollection.cs b/FModel/Framework/RangeObservableCollection.cs index cf0b5261..cc34eacc 100644 --- a/FModel/Framework/RangeObservableCollection.cs +++ b/FModel/Framework/RangeObservableCollection.cs @@ -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 : ObservableCollection { + 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 : ObservableCollection 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)); } + /// + /// Adds an item while constructing a collection that has not been published to a binding yet. + /// + public void AddWithoutNotification(T item) => Items.Add(item); + public void SetSuppressionState(bool state) { _suppressNotification = state; @@ -38,4 +52,4 @@ public sealed class RangeObservableCollection : ObservableCollection { OnCollectionChanged(new NotifyCollectionChangedEventArgs(changedAction)); } -} \ No newline at end of file +} diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index 46d2ddb5..41ea26e3 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -316,7 +316,7 @@ - + @@ -371,7 +371,7 @@ - + @@ -386,7 +386,7 @@ diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index fbed15d4..3024cc91 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; @@ -106,6 +109,14 @@ public partial class MainWindow ApplicationViewModel.InitZlib() ); + var auxiliaryInitialization = Task.WhenAll( + _applicationView.CUE4Parse.InitMappings(), + ApplicationViewModel.InitDetex(), + ApplicationViewModel.InitVgmStream(), + ApplicationViewModel.InitImGuiSettings(newOrUpdated), + UserSettings.Default.DecompileLua ? ApplicationViewModel.InitUnluac() : Task.CompletedTask + ); + await _applicationView.CUE4Parse.Initialize(); await _applicationView.AesManager.InitAes(); await _applicationView.UpdateProvider(true); @@ -116,16 +127,12 @@ public partial class MainWindow await Task.WhenAll( _applicationView.CUE4Parse.VerifyConsoleVariables(), _applicationView.CUE4Parse.VerifyOnDemandArchives(), - _applicationView.CUE4Parse.InitMappings(), - ApplicationViewModel.InitDetex(), - ApplicationViewModel.InitVgmStream(), - ApplicationViewModel.InitImGuiSettings(newOrUpdated), + auxiliaryInitialization, Task.Run(() => { if (UserSettings.Default.DiscordRpc == EDiscordRpc.Always) _discordHandler.Initialize(_applicationView.GameDisplayName); - }), - UserSettings.Default.DecompileLua ? ApplicationViewModel.InitUnluac() : Task.CompletedTask + }) ).ConfigureAwait(false); #if DEBUG @@ -254,6 +261,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 +287,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; @@ -338,7 +436,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 +458,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]; diff --git a/FModel/ViewModels/ApplicationViewModel.cs b/FModel/ViewModels/ApplicationViewModel.cs index 93f746c0..700c09b2 100644 --- a/FModel/ViewModels/ApplicationViewModel.cs +++ b/FModel/ViewModels/ApplicationViewModel.cs @@ -6,6 +6,7 @@ using System.IO.Compression; using System.Linq; using System.Threading.Tasks; using System.Windows; +using System.Windows.Threading; using CUE4Parse_Conversion.Textures.BC; using CUE4Parse.Compression; using CUE4Parse.Encryption.Aes; @@ -27,6 +28,10 @@ namespace FModel.ViewModels; public class ApplicationViewModel : ViewModel { + private readonly object _providerStatusLock = new(); + private (string Label, string Prefix)? _pendingProviderStatus; + private bool _providerStatusScheduled; + private EBuildKind _build; public EBuildKind Build { @@ -60,6 +65,8 @@ public class ApplicationViewModel : ViewModel } } + public bool IsAssetPreviewLoadingSuspended { get; set; } + private int _selectedLeftTabIndex; public int SelectedLeftTabIndex { @@ -113,13 +120,13 @@ public class ApplicationViewModel : ViewModel CUE4Parse.Provider.VfsRegistered += (sender, count) => { if (sender is not IAesVfsReader reader) return; - Status.UpdateStatusLabel($"{count} Archives ({reader.Name})", "Registered"); + QueueProviderStatus($"{count} Archives ({reader.Name})", "Registered"); CUE4Parse.GameDirectory.Add(reader); }; CUE4Parse.Provider.VfsMounted += (sender, count) => { if (sender is not IAesVfsReader reader) return; - Status.UpdateStatusLabel($"{count:N0} Packages ({reader.Name})", "Mounted"); + QueueProviderStatus($"{count:N0} Packages ({reader.Name})", "Mounted"); CUE4Parse.GameDirectory.Verify(reader); }; CUE4Parse.Provider.VfsUnmounted += (sender, _) => @@ -135,6 +142,34 @@ public class ApplicationViewModel : ViewModel Status.SetStatus(EStatusKind.Ready); } + private void QueueProviderStatus(string label, string prefix) + { + lock (_providerStatusLock) + { + _pendingProviderStatus = (label, prefix); + if (_providerStatusScheduled) + return; + + _providerStatusScheduled = true; + } + + _ = Application.Current.Dispatcher.BeginInvoke(PublishProviderStatus, DispatcherPriority.Background); + } + + private void PublishProviderStatus() + { + (string Label, string Prefix)? update; + lock (_providerStatusLock) + { + update = _pendingProviderStatus; + _pendingProviderStatus = null; + _providerStatusScheduled = false; + } + + if (update is { } status) + Status.UpdateStatusLabel(status.Label, status.Prefix); + } + public DirectorySettings AvoidEmptyGameDirectory(bool bAlreadyLaunched) { var gameDirectory = UserSettings.Default.GameDirectory; diff --git a/FModel/ViewModels/AssetsFolderViewModel.cs b/FModel/ViewModels/AssetsFolderViewModel.cs index 70f6eeaf..544a2532 100644 --- a/FModel/ViewModels/AssetsFolderViewModel.cs +++ b/FModel/ViewModels/AssetsFolderViewModel.cs @@ -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,11 +172,13 @@ 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 { + private Dictionary _foldersByPath = new(StringComparer.Ordinal); + public RangeObservableCollection Folders { get; } public ICollectionView FoldersView { get; } @@ -209,85 +188,136 @@ 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) return; + var treeItems = new List(); + var foldersByPath = new Dictionary(StringComparer.Ordinal); + var folderLookup = foldersByPath.GetAlternateLookup>(); + 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(); - treeItems.SetSuppressionState(true); - - static TreeItem FindByHeaderOrNull(IReadOnlyList 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); - } - + _foldersByPath = foldersByPath; Folders.AddRange(treeItems); if (treeItems.Count > 0) @@ -299,26 +329,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 foldersByPath, GameFile entry, + List 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; + } } diff --git a/FModel/ViewModels/AssetsListViewModel.cs b/FModel/ViewModels/AssetsListViewModel.cs index 1666644c..def8b1cb 100644 --- a/FModel/ViewModels/AssetsListViewModel.cs +++ b/FModel/ViewModels/AssetsListViewModel.cs @@ -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 Assets { get; } = []; + private List _pendingAssets; + private RangeObservableCollection _assets; + public RangeObservableCollection 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 _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 filter) + { + _filter = filter; + if (_assetsView != null) + _assetsView.Filter = filter; + } + + public void RefreshView() => _assetsView?.Refresh(); } diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index 364033c0..162345ec 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -334,6 +334,7 @@ public class CUE4ParseViewModel : ViewModel Provider.Initialize(); GameDirectory.AddLooseFiles(Provider.LooseFileCount); + GameDirectory.FlushPendingChanges(); _wwiseProviderLazy = new Lazy(() => new WwiseProvider(Provider, UserSettings.Default.GameDirectory)); _fmodProviderLazy = new Lazy(() => new FModProvider(Provider, UserSettings.Default.GameDirectory)); _criWareProviderLazy = new Lazy(() => new CriWareProvider(Provider, UserSettings.Default.GameDirectory)); @@ -379,6 +380,7 @@ public class CUE4ParseViewModel : ViewModel { Provider.SubmitKeys(aesKeys); Provider.PostMount(); + GameDirectory.FlushPendingChanges(); var aesMax = Provider.RequiredKeys.Count + Provider.Keys.Count; var archiveMax = Provider.UnloadedVfs.Count + Provider.MountedVfs.Count; @@ -389,8 +391,8 @@ public class CUE4ParseViewModel : ViewModel { if (Provider == null) return; - AssetsFolder.Folders.Clear(); - SearchVm.SearchResults.Clear(); + AssetsFolder.Clear(); + SearchVm.Clear(); Helper.CloseWindow("Search For Packages"); Provider.UnloadNonStreamedVfs(); GC.Collect(); 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 c29a695e..c71e4ce7 100644 --- a/FModel/ViewModels/Commands/LoadCommand.cs +++ b/FModel/ViewModels/Commands/LoadCommand.cs @@ -53,8 +53,8 @@ public class LoadCommand : ViewModelCommand #if DEBUG var loadingTime = Stopwatch.StartNew(); #endif - _applicationView.CUE4Parse.AssetsFolder.Folders.Clear(); - _applicationView.CUE4Parse.SearchVm.SearchResults.Clear(); + _applicationView.CUE4Parse.AssetsFolder.Clear(); + _applicationView.CUE4Parse.SearchVm.Clear(); _applicationView.SelectedLeftTabIndex = 1; // folders tab _applicationView.IsAssetsExplorerVisible = true; Helper.CloseWindow("Search For Packages"); // close search window if opened diff --git a/FModel/ViewModels/GameDirectoryViewModel.cs b/FModel/ViewModels/GameDirectoryViewModel.cs index 1d358e6c..4147abe2 100644 --- a/FModel/ViewModels/GameDirectoryViewModel.cs +++ b/FModel/ViewModels/GameDirectoryViewModel.cs @@ -1,10 +1,12 @@ using FModel.Framework; -using System.Collections.ObjectModel; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.ComponentModel; -using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Windows; using System.Windows.Data; +using System.Windows.Threading; using CUE4Parse.Compression; using CUE4Parse.UE4.IO; using CUE4Parse.UE4.Objects.Core.Misc; @@ -123,11 +125,21 @@ public class FileItem : ViewModel public partial class GameDirectoryViewModel : ViewModel { - public readonly ObservableCollection DirectoryFiles; + private readonly record struct FileItemUpdate(FileItem File, bool IsEnabled, string MountPoint, + int FileCount, bool HasMountInfo); + + public readonly RangeObservableCollection DirectoryFiles; public ICollectionView DirectoryFilesView { get; } private readonly Regex _hiddenArchives = ArchivesRegex(); + private readonly ConcurrentDictionary _filesByReader = + new(ReferenceEqualityComparer.Instance); + private readonly ConcurrentQueue _pendingAdditions = new(); + private readonly ConcurrentQueue _pendingUpdates = new(); + private FileItem _looseFilesContainer; + private int _pendingLooseFileCount; + private int _publishScheduled; public GameDirectoryViewModel() { @@ -147,7 +159,11 @@ public partial class GameDirectoryViewModel : ViewModel if (!_hiddenArchives.IsMatch(reader.Name)) return; var fileItem = new FileItem(reader); - Application.Current.Dispatcher.Invoke(() => DirectoryFiles.Add(fileItem)); + if (!_filesByReader.TryAdd(reader, fileItem)) + return; + + _pendingAdditions.Enqueue(fileItem); + SchedulePublish(); } public void AddLooseFiles(int fileCount) @@ -155,33 +171,78 @@ public partial class GameDirectoryViewModel : ViewModel if (fileCount < 1) return; - Application.Current.Dispatcher.Invoke(() => - { - var looseFilesContainer = DirectoryFiles.FirstOrDefault(x => x.IsLooseFilesContainer); - if (looseFilesContainer is not null) - { - looseFilesContainer.FileCount += fileCount; - } - else - { - DirectoryFiles.Add(new FileItem("Loose Files", fileCount, 0, true)); - } - }); + Interlocked.Add(ref _pendingLooseFileCount, fileCount); + SchedulePublish(); } public void Verify(IAesVfsReader reader) { - if (DirectoryFiles.FirstOrDefault(x => x.Name == reader.Name) is not { } file) return; + if (!_filesByReader.TryGetValue(reader, out var file)) return; - file.IsEnabled = true; - file.MountPoint = reader.MountPoint; - file.FileCount = reader.FileCount; + _pendingUpdates.Enqueue(new FileItemUpdate(file, true, reader.MountPoint, reader.FileCount, true)); + SchedulePublish(); } public void Disable(IAesVfsReader reader) { - if (DirectoryFiles.FirstOrDefault(x => x.Name == reader.Name) is not { } file) return; - file.IsEnabled = false; + if (!_filesByReader.TryGetValue(reader, out var file)) return; + + _pendingUpdates.Enqueue(new FileItemUpdate(file, false, string.Empty, 0, false)); + SchedulePublish(); + } + + public void FlushPendingChanges() + { + if (Application.Current.Dispatcher.CheckAccess()) + PublishPendingChanges(); + else + Application.Current.Dispatcher.Invoke(PublishPendingChanges); + } + + private void SchedulePublish() + { + if (Interlocked.CompareExchange(ref _publishScheduled, 1, 0) != 0) + return; + + _ = Application.Current.Dispatcher.BeginInvoke(PublishPendingChanges, DispatcherPriority.Background); + } + + private void PublishPendingChanges() + { + var additions = new List(); + while (_pendingAdditions.TryDequeue(out var file)) + additions.Add(file); + + var looseFileCount = Interlocked.Exchange(ref _pendingLooseFileCount, 0); + if (looseFileCount > 0) + { + if (_looseFilesContainer is null) + { + _looseFilesContainer = new FileItem("Loose Files", looseFileCount, 0, true); + additions.Add(_looseFilesContainer); + } + else + { + _looseFilesContainer.FileCount += looseFileCount; + } + } + + if (additions.Count > 0) + DirectoryFiles.AddRange(additions); + + while (_pendingUpdates.TryDequeue(out var update)) + { + update.File.IsEnabled = update.IsEnabled; + if (!update.HasMountInfo) + continue; + + update.File.MountPoint = update.MountPoint; + update.File.FileCount = update.FileCount; + } + + Interlocked.Exchange(ref _publishScheduled, 0); + if (!_pendingAdditions.IsEmpty || !_pendingUpdates.IsEmpty || Volatile.Read(ref _pendingLooseFileCount) > 0) + SchedulePublish(); } [GeneratedRegex(@"^(?!global|pakchunk.+(optional|ondemand)\-).+(pak|utoc)$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant)] diff --git a/FModel/ViewModels/GameFileViewModel.cs b/FModel/ViewModels/GameFileViewModel.cs index ea33dac9..1ec2b4b1 100644 --- a/FModel/ViewModels/GameFileViewModel.cs +++ b/FModel/ViewModels/GameFileViewModel.cs @@ -67,6 +67,7 @@ namespace FModel.ViewModels; public class GameFileViewModel(GameFile asset) : ViewModel { private const int MaxPreviewSize = 128; + private static readonly SemaphoreSlim ResolverConcurrency = new(4, 4); private ApplicationViewModel _applicationView => ApplicationService.ApplicationView; private EGame? GameVersion => _applicationView.CUE4Parse?.Provider.Versions.Game; @@ -133,24 +134,24 @@ public class GameFileViewModel(GameFile asset) : ViewModel => ApplicationService.ThreadWorkerView.Begin(cancellationToken => _applicationView.CUE4Parse.ExtractSelected(cancellationToken, [Asset])); - public Task ResolveAsync(EResolveCompute resolve) + public async Task ResolveAsync(EResolveCompute resolve) { try { - return ResolveInternalAsync(resolve); + await ResolveInternalAsync(resolve).ConfigureAwait(false); } catch (Exception e) { Log.Error(e, "Failed to resolve asset {AssetName} ({Resolver})", Asset.Path, resolve.ToStringBitfield()); Resolved = EResolveCompute.All; - return Task.CompletedTask; } } private Task ResolveInternalAsync(EResolveCompute resolve) { - if (!_applicationView.IsAssetsExplorerVisible || !UserSettings.Default.PreviewTexturesAssetExplorer) + if (!_applicationView.IsAssetsExplorerVisible || _applicationView.IsAssetPreviewLoadingSuspended || + !UserSettings.Default.PreviewTexturesAssetExplorer) { resolve &= ~EResolveCompute.Preview; } @@ -184,7 +185,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel return Task.CompletedTask; } - return Task.Run(() => + return RunResolverAsync(() => { // TODO: cache and reuse packages var pkg = _applicationView.CUE4Parse?.Provider.LoadPackage(Asset); @@ -405,7 +406,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel if (!resolve.HasFlag(EResolveCompute.Preview)) break; - return Task.Run(() => + return RunResolverAsync(() => { var data = _applicationView.CUE4Parse.Provider.SaveAsset(Asset); using var stream = new MemoryStream(data); @@ -464,6 +465,19 @@ public class GameFileViewModel(GameFile asset) : ViewModel return Task.CompletedTask; } + private static async Task RunResolverAsync(Action action) + { + await ResolverConcurrency.WaitAsync().ConfigureAwait(false); + try + { + await Task.Run(action).ConfigureAwait(false); + } + finally + { + ResolverConcurrency.Release(); + } + } + private void SetPreviewImage(SKData data) { using var ms = new MemoryStream(data.ToArray()); @@ -492,7 +506,7 @@ public class GameFileViewModel(GameFile asset) : ViewModel Task.Delay(100, token).ContinueWith(t => { if (t.IsCanceled) return; - ResolveAsync(EResolveCompute.All); + _ = ResolveAsync(EResolveCompute.All); }, TaskScheduler.FromCurrentSynchronizationContext()); } } diff --git a/FModel/ViewModels/SearchViewModel.cs b/FModel/ViewModels/SearchViewModel.cs index 17b904f2..714629c6 100644 --- a/FModel/ViewModels/SearchViewModel.cs +++ b/FModel/ViewModels/SearchViewModel.cs @@ -61,33 +61,58 @@ public class SearchViewModel : ViewModel private set => SetProperty(ref _refFile, value); } - public RangeObservableCollection SearchResults { get; } - public ListCollectionView SearchResultsView { get; } + private List _searchResults = []; + public List SearchResults + { + get => _searchResults; + private set => SetProperty(ref _searchResults, value); + } + private ListCollectionView _searchResultsView; + private string[] _filters = []; + private Regex _filterRegex; + private bool _isRegexValid = true; + + public ListCollectionView SearchResultsView + { + get + { + if (_searchResultsView != null) + return _searchResultsView; + + PrepareFilter(); + _searchResultsView = new ListCollectionView(SearchResults) + { + Filter = ItemFilter, + }; + 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() { + PrepareFilter(); SearchResultsView.Refresh(); ResultsCount = SearchResultsView.Count; } public void ChangeCollection(IEnumerable files, GameFile refFile = null) { - SearchResults.Clear(); - SearchResults.AddRange(files); + var results = files as List ?? 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,20 +151,41 @@ public class SearchViewModel : ViewModel }; }); - SearchResults.Clear(); - SearchResults.AddRange(sorted); + ChangeCollection(sorted, RefFile); } - private bool ItemFilter(object item, IEnumerable filters) + private void PrepareFilter() + { + _filters = FilterText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + _filterRegex = null; + _isRegexValid = true; + + if (!HasRegexEnabled) + return; + + var options = RegexOptions.Compiled; + if (!HasMatchCaseEnabled) + options |= RegexOptions.IgnoreCase; + + try + { + _filterRegex = new Regex(FilterText, options); + } + catch (ArgumentException) + { + _isRegexValid = false; + } + } + + private bool ItemFilter(object item) { if (item is not GameFile entry) return true; if (!HasRegexEnabled) - return filters.All(x => entry.Path.Contains(x, HasMatchCaseEnabled ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)); + return _filters.All(x => entry.Path.Contains(x, + HasMatchCaseEnabled ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)); - var o = RegexOptions.None; - if (!HasMatchCaseEnabled) o |= RegexOptions.IgnoreCase; - return new Regex(FilterText, o).Match(entry.Path).Success; + return _isRegexValid && _filterRegex.IsMatch(entry.Path); } } 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/Controls/TiledExplorer/FolderButton2.xaml b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml index 0eda947a..a21a3107 100644 --- a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml +++ b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml @@ -100,7 +100,7 @@ Foreground="{DynamicResource {x:Static adonisUi:Brushes.ForegroundBrush}}" Opacity="0.8" /> - + diff --git a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs index 907a9a71..172c1dfb 100644 --- a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs +++ b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs @@ -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]; diff --git a/FModel/Views/Resources/Resources.xaml b/FModel/Views/Resources/Resources.xaml index 201841ef..7cb4b638 100644 --- a/FModel/Views/Resources/Resources.xaml +++ b/FModel/Views/Resources/Resources.xaml @@ -105,6 +105,12 @@ Value="{Binding CUE4Parse.GameDirectory.DirectoryFilesView, IsAsync=True}" /> + + + + + + + + + + + + + @@ -308,7 +327,7 @@ Background="#705542" Margin="-10.5 0 4.5 0" VerticalAlignment="Bottom"> - - + @@ -340,7 +359,7 @@ Property="Source" Value="/FModel;component/Resources/empty_folder.png" /> - + + + + + + + @@ -38,14 +39,15 @@ - + @@ -402,14 +404,15 @@ - + diff --git a/FModel/Views/SearchView.xaml.cs b/FModel/Views/SearchView.xaml.cs index e1499a39..a14f553c 100644 --- a/FModel/Views/SearchView.xaml.cs +++ b/FModel/Views/SearchView.xaml.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Input; +using System.Windows.Threading; using CUE4Parse.FileProvider.Objects; using FModel.Services; using FModel.ViewModels; @@ -19,15 +20,22 @@ public enum ESearchViewTab public partial class SearchView { + private static readonly TimeSpan AutoSearchDelay = TimeSpan.FromMilliseconds(500); + private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView; private ApplicationViewModel _applicationView => ApplicationService.ApplicationView; private SearchViewModel _searchViewModel => _applicationView.CUE4Parse.SearchVm; private SearchViewModel _refViewModel => _applicationView.CUE4Parse.RefVm; private ESearchViewTab _currentTab = ESearchViewTab.SearchView; + private readonly DispatcherTimer _autoSearchTimer; + private SearchViewModel _pendingAutoSearch; public SearchView() { + _autoSearchTimer = new DispatcherTimer { Interval = AutoSearchDelay }; + _autoSearchTimer.Tick += OnAutoSearchTimerTick; + DataContext = new { mainApplication = _applicationView, @@ -95,9 +103,30 @@ public partial class SearchView if (viewModel == null) return; viewModel.FilterText = string.Empty; + CancelAutoSearch(); viewModel.RefreshFilter(); } + private void OnSearchTextChanged(object sender, TextChangedEventArgs e) + { + _pendingAutoSearch = ReferenceEquals(sender, RefSearchTextBox) ? _refViewModel : _searchViewModel; + _autoSearchTimer.Stop(); + _autoSearchTimer.Start(); + } + + private void OnAutoSearchTimerTick(object sender, EventArgs e) + { + var viewModel = _pendingAutoSearch; + CancelAutoSearch(); + viewModel?.RefreshFilter(); + } + + private void CancelAutoSearch() + { + _autoSearchTimer.Stop(); + _pendingAutoSearch = null; + } + private SearchViewModel CurrentViewModel => _currentTab switch { ESearchViewTab.SearchView => _applicationView.CUE4Parse.SearchVm, @@ -141,30 +170,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.Assets.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() - .FirstOrDefault(x => x.Asset == entry); + WindowState = WindowState.Minimized; + MainWindow.YesWeCats.AssetsListName.ClearValue(ItemsControl.ItemsSourceProperty); + 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) @@ -182,9 +216,17 @@ public partial class SearchView { if (e.Key != Key.Enter) return; + + CancelAutoSearch(); CurrentViewModel?.RefreshFilter(); } + private void OnWindowClosed(object sender, EventArgs e) + { + CancelAutoSearch(); + _autoSearchTimer.Tick -= OnAutoSearchTimerTick; + } + private void OnStateChanged(object sender, EventArgs e) { switch (WindowState)