FModel/FModel/ViewModels/SearchViewModel.cs
Rob Trame 40d8b73ec5
fix: address remaining WPF→Avalonia gaps for phase 2 completion (#85)
* fix: address remaining WPF→Avalonia gaps for phase 2 completion

Resolves r6e/FModel-Linux#84

- Convert Colors.xaml & Icons.xaml to Avalonia xmlns
- Migrate CommitControl to Avalonia (replace DataTriggers with IsVisible)
- Migrate SearchTextBox to Avalonia (StyledProperty, native Watermark)
- Migrate TreeViewItemBehavior to Avalonia attached properties
- Replace ListCollectionView with DataGridCollectionView in 8 ViewModels
- Add Framework/CompositeCollection as WPF CompositeCollection replacement
- Remove AdonisUI.Controls refs from LoadCommand & AddEditDirectoryCommand
- Replace SystemParameters with Avalonia screen API in CUE4ParseViewModel
- Rewrite Resources.xaml for Avalonia Fluent theme (~2150→290 lines)
- Extend CommitMessageConverter with HasDescription parameter support

* fix: address review findings for PR #85

- C1: Fix ToolTip syntax (TextBlock.ToolTip → ToolTip.Tip) in tree view
- C2: Add Fill binding to folder icons via ConverterParameter=Brush
- C3: Convert AddEditDirectoryCommand.ShowDialog() to async with owner
- M1: Convert keyed Style → ControlTheme + Theme= in all consumers
- M2: Fix CommitControl badge overlap (Latest/Current mutual exclusivity)
  Add FallbackValue=False for null-safe Asset.IsLatest binding
- M3: Add TODO(P4-004) markers for OpenFileDialog/SaveFileDialog
- M4: Implement IDisposable on CompositeCollection; dispose on replace
- PR: Add UrlToBitmapConverter for HTTP avatar image loading
- PR: Fix ContextMenu DataContext in GameFilesTabControl ContentTemplate
  Restore 'Disable Alpha Channel' toggle (NoAlpha binding)
- PR: Replace ElementName=TabControlName with direct DataContext bindings
- PR: TreeViewItemBehavior uses GetObservable(IsSelectedProperty) with
  proper IDisposable subscription cleanup
- S1: Clarify UpdateViewModel grouping TODO (non-DataGrid limitation)
- S2: Cache compiled Regex in SearchViewModel.ItemFilter
- m3: Implement TabItemFillSpace with HorizontalAlignment=Stretch

Also updates FolderToGeometryConverter to support ConverterParameter
for explicit brush/geometry selection.

* fix: address review #2 findings on PR #85

- M1/m1/m2: UrlToBitmapConverter — sync download with ConcurrentDictionary
  URL cache, 4-space indent, doc comment noting async limitation
- M2: Remove duplicate IsVisible attribute on Latest badge Border
  (MultiBinding property element takes precedence; attribute was dead code)
- m3: Add TimeSpan.FromSeconds(1) timeout to user-input Regex construction
- PR: ZIndex → Panel.ZIndex in SearchTextBox.xaml (Avalonia attached prop)
- PR: ShadowEffect comment updated to reflect key removal (no consumers)
- PR: CustomSeparator Tag TextBlock hidden when empty via
  StringConverters.IsNotNullOrEmpty (eliminates unwanted 10px margin)
- PR: CustomVerticalSeparator BasedOn changed from default Separator to
  CustomSeparator (preserves tagged template inheritance from WPF)
- PR: AddEditDirectoryCommand — early return when owner is null instead
  of silently skipping dialog
- PR: CompositeCollection.Count — skip non-ICollection sources instead
  of O(n) enumeration fallback

* Fix Avalonia parity gaps in updates, styles, and dialogs

* fix: address parity review follow-ups and unresolved PR comments

* Fix remaining PR review thread regressions

* Address review findings: thread safety, Linux guards, indentation, and cleanup

- [C1] Fix LoadCoAuthors thread race by snapshotting Commits before Task.Run
- [M1] Remove IsAsync=True (WPF-only) from Avalonia bindings
- [M2] Add OperatingSystem.IsWindows() guard for Win32 OpenFileDialog
- [M3] Add null-safety for Application.Current in PlayPause converter
- [N1] Add BrushTransition to HighlightedCheckBox for animation parity
- [N2] Dispose TreeViewItem subscriptions on DetachedFromVisualTree
- [N3] Fix SelectionMode converter: only Multiple uses multi-select
- [S1] Simplify AddEditDirectoryCommand owner-null to early return
- [S3] Remove dead WPF trigger comments from UpdateView.xaml
- [S4] Add 10s timeout to UrlToBitmapConverter HttpClient
- PR: Reformat UrlToBitmapConverter to 4-space indentation
- PR: Catch RegexMatchTimeoutException in SearchViewModel filter

* Fix review round 2: ActualWidth→Bounds, IsAsync removal, FindAncestor xmlns, misc

Critical:
- C1: Replace ActualHeight/ActualWidth with Bounds.Height/Bounds.Width
  in UpdateView, MainWindow, SettingsView, DirectorySelector (Avalonia
  has no ActualWidth/ActualHeight properties)
- C2: Remove remaining IsAsync=True from SearchView.xaml and
  AudioPlayer.xaml (WPF-only, no-op in Avalonia)

Major:
- M1: Add xmlns:views for FModel.Views and replace dotted sub-namespace
  local:Views.SettingsView with views:SettingsView in all 23
  FindAncestor bindings (avoids ambiguous XAML type resolution)
- M2: CompositeCollection.Count now skips non-ICollection sources
  instead of enumerating them (O(1) for current callers)

Minor:
- N1: Add Application.Current null guard in FolderToGeometryConverter
  (matches PlaybackStateToPlayPauseConverter pattern)

Suggestions:
- S1: UrlToBitmapConverter.LoadAsync now cleans up _inFlight on all
  exit paths (defense-in-depth for exceptional failures)
- S2: Add comment in CommitMessageConverter documenting interaction
  with LoadCoAuthors co-author cleanup

* fix: merge resource dicts, regex cache, handler cleanup, null-init

- Merge Colors.xaml, Icons.xaml, Resources.xaml into App.xaml so
  StaticResource/DynamicResource lookups resolve at runtime
- Cache invalid regex state in SearchViewModel to avoid repeated
  recompilation on every filter invocation
- Detach DetachedFromVisualTree handler in TreeViewItemBehavior
  when IsBroughtIntoViewWhenSelected is set to false
- Fix LoadingModeToSelectionModeConverter doc to match behavior
- Initialize Commit/Author backing fields with null! to satisfy
  non-nullable contract before JSON deserialization
- Add Debug.Fail in CompositeCollection.Count for non-ICollection
  sources to surface unexpected usage in development

* fix: load missing resource dictionaries, remove dead style refs, fix using

- App.xaml: add ResourceInclude for FileContextMenu, FolderContextMenu,
  and TiledExplorer/Resources.xaml so StaticResource lookups resolve at
  runtime (C1 fix)
- TiledExplorer/Resources.xaml: remove ContextMenu Setters from both
  TiledExplorer and AssetsListBox styles; Avalonia does not support
  x:Shared="False", so the shared instance would break when two
  ListBoxes reference the same ContextMenu. ListBoxItemBehavior already
  handles assignment programmatically via TryFindResource on right-click.
- SettingsView.xaml: remove 11 references to undefined TextBoxDefaultStyle;
  HotkeyTextBox inherits FluentTheme TextBox styling by default (C2 fix)
- LoadingModeToSelectionModeConverter.cs: add missing 'using FModel' for
  ELoadingMode resolution (M1 fix)

* fix: convert Resources.xaml to Styles root, restore toggle button states

- Resources.xaml: change root from <ResourceDictionary> to <Styles> so
  StyleInclude in App.xaml correctly loads an IStyle-implementing element.
  All keyed resources (ControlThemes, converters) moved into
  <Styles.Resources>; selector-based styles (audio controls, FoldingMargin)
  are direct <Styles> children.
- AssetsExplorerToggleButtonStyle: restore IsChecked-dependent behavior
  using Avalonia selectors. :checked shows FolderIconAlt + accent background,
  :unchecked shows AssetIcon + default background. Tooltip shows hotkey.
- MainWindow.xaml: add Classes="AssetsExplorerToggle" to the toggle button
  so the selector-based checked/unchecked styles match.

* Fix Round 7 review findings: ControlTheme, ToolTip.Tip, dead code

- C1: Convert keyed Styles to ControlTheme in TiledExplorer/Resources.xaml,
  update Style= to Theme= in MainWindow.xaml, convert CustomRichTextBox
  to auto-applying Style Selector in Resources.xaml
- M1: Fix ToolTip= to ToolTip.Tip= across SearchView, SettingsView,
  AudioPlayer, and DirectorySelector (22 occurrences)
- m1: Remove duplicate _inFlight.TryRemove in UrlToBitmapConverter.cs
- m2: Remove dead Result property from CustomDir.xaml.cs
- S2: Remove unused InvertBooleanConverter from Resources.xaml

* Fix PR review findings: image column collapse, thread safety

- Add HasImageToColumnSpanConverter so AvalonEditor spans all columns
  when HasImage is false, preventing unused blank space from the image
  column width (Resources.xaml GameFilesTabControl ContentTemplate)
- Marshal LoadAssets and LoadCoAuthors collection/property mutations to
  Dispatcher.UIThread.InvokeAsync for explicit thread safety

* fix: reformat HasImageToColumnSpanConverter to 4-space indentation

* fix: map All/AllButNew/AllButModified/AllButPatched to multi-select

LoadingModeToSelectionModeConverter only mapped Multiple to
SelectionMode.Multiple. The original WPF DataTriggers also enabled
Extended selection for All, AllButNew, AllButModified, and
AllButPatched modes. Add those mappings to restore parity.
2026-03-16 21:35:16 -06:00

180 lines
5.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Collections;
using CUE4Parse.FileProvider.Objects;
using CUE4Parse.UE4.VirtualFileSystem;
using FModel.Framework;
namespace FModel.ViewModels;
public class SearchViewModel : ViewModel
{
public enum ESortSizeMode
{
None,
Ascending,
Descending
}
private string _filterText = string.Empty;
public string FilterText
{
get => _filterText;
set => SetProperty(ref _filterText, value);
}
private bool _hasRegexEnabled;
public bool HasRegexEnabled
{
get => _hasRegexEnabled;
set => SetProperty(ref _hasRegexEnabled, value);
}
private bool _hasMatchCaseEnabled;
public bool HasMatchCaseEnabled
{
get => _hasMatchCaseEnabled;
set => SetProperty(ref _hasMatchCaseEnabled, value);
}
private ESortSizeMode _currentSortSizeMode = ESortSizeMode.None;
public ESortSizeMode CurrentSortSizeMode
{
get => _currentSortSizeMode;
set => SetProperty(ref _currentSortSizeMode, value);
}
private int _resultsCount = 0;
public int ResultsCount
{
get => _resultsCount;
private set => SetProperty(ref _resultsCount, value);
}
private GameFile _refFile;
public GameFile RefFile
{
get => _refFile;
private set => SetProperty(ref _refFile, value);
}
public RangeObservableCollection<GameFile> SearchResults { get; }
public DataGridCollectionView SearchResultsView { get; }
public SearchViewModel()
{
SearchResults = [];
SearchResultsView = new DataGridCollectionView(SearchResults)
{
Filter = e => ItemFilter(e, FilterText.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)),
};
ResultsCount = SearchResultsView.Count;
}
public void RefreshFilter()
{
SearchResultsView.Refresh();
ResultsCount = SearchResultsView.Count;
}
public void ChangeCollection(IEnumerable<GameFile> files, GameFile refFile = null)
{
SearchResults.Clear();
SearchResults.AddRange(files);
RefFile = refFile;
ResultsCount = SearchResultsView.Count;
}
public async Task CycleSortSizeMode()
{
CurrentSortSizeMode = CurrentSortSizeMode switch
{
ESortSizeMode.None => ESortSizeMode.Descending,
ESortSizeMode.Descending => ESortSizeMode.Ascending,
_ => ESortSizeMode.None
};
var sorted = await Task.Run(() =>
{
var archiveDict = SearchResults
.OfType<VfsEntry>()
.Select(f => f.Vfs.Name)
.Distinct()
.Select((name, idx) => (name, idx))
.ToDictionary(x => x.name, x => x.idx);
var keyed = SearchResults.Select(f =>
{
int archiveKey = f is VfsEntry ve && archiveDict.TryGetValue(ve.Vfs.Name, out var key) ? key : -1;
return (File: f, f.Size, ArchiveKey: archiveKey);
});
return CurrentSortSizeMode switch
{
ESortSizeMode.Ascending => keyed
.OrderBy(x => x.Size).ThenBy(x => x.ArchiveKey)
.Select(x => x.File).ToList(),
ESortSizeMode.Descending => keyed
.OrderByDescending(x => x.Size).ThenBy(x => x.ArchiveKey)
.Select(x => x.File).ToList(),
_ => keyed
.OrderBy(x => x.ArchiveKey).ThenBy(x => x.File.Path, StringComparer.OrdinalIgnoreCase)
.Select(x => x.File).ToList()
};
});
SearchResults.Clear();
SearchResults.AddRange(sorted);
}
private Regex? _cachedFilterRegex;
private string? _cachedFilterText;
private bool _cachedMatchCase;
private bool _cachedRegexInvalid;
private bool ItemFilter(object item, IEnumerable<string> filters)
{
if (item is not GameFile entry)
return true;
if (!HasRegexEnabled)
return filters.All(x => entry.Path.Contains(x, HasMatchCaseEnabled ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase));
if (_cachedFilterText != FilterText || _cachedMatchCase != HasMatchCaseEnabled)
{
var o = RegexOptions.None;
if (!HasMatchCaseEnabled)
o |= RegexOptions.IgnoreCase;
try
{
_cachedFilterRegex = new Regex(FilterText, o, TimeSpan.FromSeconds(1));
_cachedRegexInvalid = false;
}
catch (ArgumentException)
{
_cachedFilterRegex = null;
_cachedRegexInvalid = true;
}
_cachedFilterText = FilterText;
_cachedMatchCase = HasMatchCaseEnabled;
}
if (_cachedRegexInvalid)
return false;
try
{
return _cachedFilterRegex?.Match(entry.Path).Success == true;
}
catch (RegexMatchTimeoutException)
{
return false;
}
}
}