diff --git a/src/ColumnFilterHandler.cs b/src/ColumnFilterHandler.cs index a6cfa0a..adbeb7e 100644 --- a/src/ColumnFilterHandler.cs +++ b/src/ColumnFilterHandler.cs @@ -1,109 +1,810 @@ using System; +using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml.Controls; +using Windows.System; using WinUI.TableView.Extensions; namespace WinUI.TableView; /// -/// Default implementation of the IColumnFilterHandler interface. +/// Fast filter and sorting implementation with unified single code path for Options Flyout and header clicks. /// public class ColumnFilterHandler : IColumnFilterHandler { private readonly TableView _tableView; - /// - /// Initializes a new instance of the ColumnFilterHandler class. - /// + // SMART CACHE: Uses intelligent cache keys that include filter state + private readonly ConcurrentDictionary> _smartUniqueValuesCache = new(); + private readonly ConcurrentDictionary> _activeFilters = new(); + private readonly ConcurrentDictionary> _propertyAccessors = new(); + + // UNIFIED SORTING ENGINE - SINGLE CODE PATH + private TableViewColumn? _currentSortColumn; + private SortDirection? _currentSortDirection; + private readonly FastSortingEngine _sortingEngine; + + private readonly FastFilterEngine _filterEngine; + private object? _lastItemsSource; + private IList? _originalItemsSource; + private IList? _currentFilteredSource; + + // CRITICAL: Prevent infinite loops between filtering and sorting + private volatile bool _isApplyingFilterOrSort; + + // SCROLL POSITION PRESERVATION: Store scroll position for restoration + private double _preservedHorizontalOffset = 0; + private ScrollViewer? _cachedScrollViewer = null; + public ColumnFilterHandler(TableView tableView) { _tableView = tableView; + _filterEngine = new FastFilterEngine(); + _sortingEngine = new FastSortingEngine(); + SelectedValues = new SelectedValuesWrapper(this); + + // Hook into ALL sorting events to unify the code path + HookIntoAllSortingEvents(); } - /// - public virtual IList GetFilterItems(TableViewColumn column, string? searchText = default) + /// + /// UNIFIED: Hook into all sorting events to create a single fast code path + /// + private void HookIntoAllSortingEvents() { - if (column is { TableView.ItemsSource: { } }) + // Hook into TableView sorting event (header clicks) + _tableView.Sorting += OnTableViewSorting; + + // Hook into ClearSorting event + _tableView.ClearSorting += OnTableViewClearSorting; + } + + /// + /// UNIFIED: Single entry point for all sorting operations + /// + private void OnTableViewSorting(object? sender, TableViewSortingEventArgs e) + { + // CRITICAL: Prevent infinite loops + if (_isApplyingFilterOrSort) + { + return; + } + + // Mark event as handled so TableView doesn't process it + e.Handled = true; + + // CRITICAL: Ensure we have original data before any sorting operation + EnsureOriginalDataSource(); + + // Handle the sorting with our unified fast engine + HandleUnifiedSort(e.Column); + } + + /// + /// UNIFIED: Single entry point for clearing sorts + /// + private void OnTableViewClearSorting(object? sender, TableViewClearSortingEventArgs e) + { + // CRITICAL: Prevent infinite loops + if (_isApplyingFilterOrSort) { - var collectionView = new CollectionView(column.TableView.ItemsSource); - collectionView.FilterDescriptions.AddRange( - column.TableView.FilterDescriptions.Where( - x => x is not ColumnFilterDescription columnFilter || columnFilter.Column != column)); + return; + } + + // Mark event as handled so TableView doesn't process it + e.Handled = true; - var filterValues = new SortedSet(); + // Clear our internal sort state + ClearAllSorting(); + } + + /// + /// PUBLIC API: UNIFIED fast sorting entry point + /// Used by Options Flyout - triggers the same TableView event as header clicks + /// + public void ApplyUnifiedSort(TableViewColumn column, SortDirection direction) + { + if (column?.TableView == null) return; + + // CRITICAL: Ensure we have original data + EnsureOriginalDataSource(); + + // Set the column sort direction first + column.SortDirection = direction; + + // UNIFIED PATH: Use the same event mechanism as header clicks + var eventArgs = new TableViewSortingEventArgs(column); + _tableView.OnSorting(eventArgs); + } + + /// + /// PUBLIC API: UNIFIED fast sort clearing + /// + public void ClearUnifiedSort(TableViewColumn column) + { + if (column?.TableView == null) return; + + // Clear the column sort direction + column.SortDirection = null; + + // UNIFIED PATH: Use the same event mechanism + var eventArgs = new TableViewClearSortingEventArgs(); + _tableView.OnClearSorting(eventArgs); + } + + /// + /// CRITICAL: Ensure we have the original data source captured + /// + private void EnsureOriginalDataSource() + { + if (_originalItemsSource == null && _tableView.ItemsSource != null) + { + _originalItemsSource = _tableView.ItemsSource as IList ?? _tableView.ItemsSource.Cast().ToList(); + _currentFilteredSource = _originalItemsSource; + _lastItemsSource = _tableView.ItemsSource; + + System.Diagnostics.Debug.WriteLine($"EnsureOriginalDataSource: Captured {_originalItemsSource.Count} items"); + } + } + + /// + /// UNIFIED: Handle all sorting with Excel-like single-column behavior + /// + private void HandleUnifiedSort(TableViewColumn column) + { + if (column == null) return; - foreach (var item in collectionView) + // EXCEL BEHAVIOR: Single column sorting only + // Clear all other columns first + foreach (var col in _tableView.Columns) + { + if (col != column && col != null) { - var value = column.GetCellContent(item); - filterValues.Add(IsBlank(value) ? null : value); + col.SortDirection = null; } + } + + // Determine next sort direction for this column + SortDirection? nextDirection; + + // logic for header clicks vs options flyout + var currentDirection = column.SortDirection; + + // Check if this is from Options Flyout (direction already set) or Header click (needs cycling) + // Options flyout calls ApplyUnifiedSort which sets the direction BEFORE calling this method + // Header clicks go through OnTableViewSorting and need Excel-like cycling + + if (currentDirection.HasValue) + { + // This could be either: + // 1. Options flyout (keep the direction that was just set) + // 2. Header click (cycle to next direction) + + // We need to distinguish: if direction was just set by ApplyUnifiedSort, keep it + // Otherwise, cycle to next direction for header clicks - return [.. filterValues.Select(value => + // For header clicks: cycle through directions using CURRENT state + var previousDirection = _currentSortColumn == column ? _currentSortDirection : null; + + if (previousDirection == currentDirection) + { + // This is a header click - cycle to next direction + nextDirection = GetNextSortDirection(currentDirection); + column.SortDirection = nextDirection; + } + else { - value ??= TableViewLocalizedStrings.BlankFilterValue; - var isSelected = !column.IsFiltered || !string.IsNullOrEmpty(searchText) || - (column.IsFiltered && SelectedValues[column].Contains(value)); + // Direction was just changed (by options flyout) - keep it + nextDirection = currentDirection; + } + } + else + { + // No direction set - this is first click (unsorted → ascending) + nextDirection = SortDirection.Ascending; + column.SortDirection = nextDirection; + } - return string.IsNullOrEmpty(searchText) - || value?.ToString()?.Contains(searchText, StringComparison.OrdinalIgnoreCase) == true - ? new TableViewFilterItem(isSelected, value) - : null; + // Update our internal state + _currentSortColumn = nextDirection.HasValue ? column : null; + _currentSortDirection = nextDirection; + + // Apply fast sorting + ApplyFastSorting(); + } - }).OfType()]; + /// + /// UNIFIED: Clear all sorting + /// + private void ClearAllSorting() + { + // Clear all column sort directions + foreach (var col in _tableView.Columns) + { + if (col != null) + { + col.SortDirection = null; + } } - return []; + // Clear our internal state + _currentSortColumn = null; + _currentSortDirection = null; + + // Re-apply filtering without sorting (restores original order within filtered data) + ApplyFilteringAndSorting(); } - private static bool IsBlank(object? value) + /// + /// Get next sort direction following Excel-like behavior + /// + private SortDirection? GetNextSortDirection(SortDirection? current) { - return value == null || - value == DBNull.Value || - (value is string str && string.IsNullOrWhiteSpace(str)) || - (value is Guid guid && guid == Guid.Empty); + return current switch + { + null => SortDirection.Ascending, // Unsorted → ↑ Ascending + SortDirection.Ascending => SortDirection.Descending, // ↑ → ↓ Descending + SortDirection.Descending => null, // ↓ → Unsorted + _ => SortDirection.Ascending + }; } - /// - public virtual void ApplyFilter(TableViewColumn column) + /// + /// Apply fast sorting with no scrolling. + /// + private void ApplyFastSorting() { - if (column is { TableView: { } }) + // CRITICAL: Prevent infinite loops + if (_isApplyingFilterOrSort) { - column.TableView.DeselectAll(); + return; + } - if (column.IsFiltered) + // CRITICAL: Ensure we have data to work with + if (_originalItemsSource == null) + { + EnsureOriginalDataSource(); + if (_originalItemsSource == null) { - column.TableView.RefreshFilter(); + return; } - else + } + + // SCROLL PRESERVATION: Capture current position before any data changes + PreserveHorizontalScrollPosition(); + + _isApplyingFilterOrSort = true; + + Task.Run(() => + { + try + { + // Step 1: Apply filters first (smaller dataset to sort) + var sourceForSorting = _activeFilters.Any() + ? _filterEngine.FilterFast(_originalItemsSource, _activeFilters, _propertyAccessors) + : _originalItemsSource; + + // Step 2: Apply sorting if we have a sort column + var finalItems = (_currentSortColumn != null && _currentSortDirection.HasValue) + ? _sortingEngine.SortFast(sourceForSorting, _currentSortColumn, _currentSortDirection.Value, _propertyAccessors) + : sourceForSorting; + + _tableView.DispatcherQueue?.TryEnqueue(() => + { + try + { + _tableView.DeselectAll(); + + SuppressScrollEventsDuringOperation(() => + { + BypassCollectionViewAndSetSource(finalItems); + _currentFilteredSource = finalItems; + UpdateTableViewSortDescriptions(); + }); + + // Final restoration + RestoreHorizontalScrollPosition(); + } + catch + { + // Fallback: restore to unsorted state + _currentSortColumn = null; + _currentSortDirection = null; + try + { + BypassCollectionViewAndSetSource(sourceForSorting); + // Still attempt to restore scroll position even in fallback + RestoreHorizontalScrollPosition(); + } + catch (Exception ex2) + { + System.Diagnostics.Debug.WriteLine($"Sort fallback error: {ex2.Message}"); + } + } + finally + { + _isApplyingFilterOrSort = false; + } + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Sort background error: {ex.Message}"); + _isApplyingFilterOrSort = false; + + // Reset sort state on error + _tableView.DispatcherQueue?.TryEnqueue(() => + { + _currentSortColumn = null; + _currentSortDirection = null; + // Ensure scroll position is reset on error to prevent stale state + _preservedHorizontalOffset = 0; + }); + } + }); + } + + /// + /// SCROLL PRESERVATION: Captures current horizontal scroll position before data operations + /// + private void PreserveHorizontalScrollPosition() + { + try + { + // Access the private _scrollViewer field using reflection + var scrollViewerField = typeof(TableView).GetField("_scrollViewer", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (scrollViewerField?.GetValue(_tableView) is ScrollViewer scrollViewer) + { + _preservedHorizontalOffset = scrollViewer.HorizontalOffset; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"PreserveScrollPosition error: {ex.Message}"); + _preservedHorizontalOffset = 0; // Safe fallback + } + } + + /// + /// SCROLL RESTORATION: Restores previously captured horizontal scroll position + /// Uses improved timing and multiple restoration attempts to eliminate visual jumping + /// + private void RestoreHorizontalScrollPosition() + { + if (_preservedHorizontalOffset <= 0) return; + + try + { + var scrollViewerField = typeof(TableView).GetField("_scrollViewer", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (scrollViewerField?.GetValue(_tableView) is ScrollViewer scrollViewer) + { + // IMMEDIATE RESTORATION: Set scroll position synchronously first to minimize jumping + // This prevents the initial jump to column 0 + try + { + scrollViewer.ChangeView(_preservedHorizontalOffset, null, null, true); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Immediate scroll restore error: {ex.Message}"); + } + + // DELAYED RESTORATION: Use multiple priority levels to ensure restoration + // This handles cases where the immediate restoration might be overridden + var capturedOffset = _preservedHorizontalOffset; + + // High priority restoration (fastest response) + _tableView.DispatcherQueue?.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.High, () => + { + try + { + if (Math.Abs(scrollViewer.HorizontalOffset - capturedOffset) > 1) + { + scrollViewer.ChangeView(capturedOffset, null, null, true); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"High priority scroll restore error: {ex.Message}"); + } + }); + + // Normal priority restoration (secondary safety net) + _tableView.DispatcherQueue?.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => + { + try + { + if (Math.Abs(scrollViewer.HorizontalOffset - capturedOffset) > 1) + { + scrollViewer.ChangeView(capturedOffset, null, null, true); + } + } + catch { /* Ignore errors in fallback */ } + }); + + // Low priority final restoration (final safety net) + _tableView.DispatcherQueue?.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + try + { + if (Math.Abs(scrollViewer.HorizontalOffset - capturedOffset) > 1) + { + scrollViewer.ChangeView(capturedOffset, null, null, true); + } + } + catch { /* Ignore errors in final fallback */ } + finally + { + // Reset preserved offset only after all restoration attempts + _preservedHorizontalOffset = 0; + } + }); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"RestoreScrollPosition dispatch error: {ex.Message}"); + _preservedHorizontalOffset = 0; // Reset on error + } + } + + /// + /// Apply both filtering and sorting in optimal order with scroll position preservation. + /// + private void ApplyFilteringAndSorting() + { + // CRITICAL: Prevent infinite loops + if (_isApplyingFilterOrSort) + { + return; + } + + // CRITICAL: Ensure we have data to work with + if (_originalItemsSource == null) + { + EnsureOriginalDataSource(); + if (_originalItemsSource == null) + { + return; + } + } + + // SCROLL PRESERVATION: Capture current position before any data changes + PreserveHorizontalScrollPosition(); + + _isApplyingFilterOrSort = true; + + Task.Run(() => + { + try + { + // Step 1: Apply filters first (smaller dataset to sort) + var filteredItems = _activeFilters.Any() + ? _filterEngine.FilterFast(_originalItemsSource, _activeFilters, _propertyAccessors) + : _originalItemsSource; + + // Step 2: Apply sorting to filtered data + var finalItems = (_currentSortColumn != null && _currentSortDirection.HasValue) + ? _sortingEngine.SortFast(filteredItems, _currentSortColumn, _currentSortDirection.Value, _propertyAccessors) + : filteredItems; + + _tableView.DispatcherQueue?.TryEnqueue(() => + { + try + { + _tableView.DeselectAll(); + BypassCollectionViewAndSetSource(finalItems); + _currentFilteredSource = finalItems; + + // SCROLL RESTORATION: Restore position after data has been updated + RestoreHorizontalScrollPosition(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Filter+Sort error: {ex.Message}"); + // Still attempt to restore scroll position even on error + RestoreHorizontalScrollPosition(); + } + finally + { + _isApplyingFilterOrSort = false; + } + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"ApplyFilteringAndSorting background error: {ex.Message}"); + _isApplyingFilterOrSort = false; + // Ensure scroll position is reset on error + _tableView.DispatcherQueue?.TryEnqueue(() => _preservedHorizontalOffset = 0); + } + }); + } + + /// + /// Set source and bypass all CollectionView processing (filters AND sorting) + /// + private void BypassCollectionViewAndSetSource(IList items) + { + try + { + var collectionViewField = typeof(TableView).GetField("_collectionView", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (collectionViewField?.GetValue(_tableView) is not CollectionView collectionView) { - var boundColumn = column as TableViewBoundColumn; + return; + } + + // CRITICAL: Preserve scroll position BEFORE any CollectionView operations + var preservedOffset = _preservedHorizontalOffset; + + // SCROLL SUPPRESSION: Temporarily disable scroll change notifications if possible + var scrollViewerField = typeof(TableView).GetField("_scrollViewer", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + ScrollViewer? scrollViewer = null; + bool wasScrollingEnabled = true; + + if (scrollViewerField?.GetValue(_tableView) is ScrollViewer sv) + { + scrollViewer = sv; + // Temporarily disable horizontal scrolling to prevent jumping + wasScrollingEnabled = scrollViewer.HorizontalScrollMode != ScrollMode.Disabled; + if (wasScrollingEnabled && preservedOffset > 0) + { + // Don't disable scrolling as it might cause layout issues + // Instead, we'll use the multi-level restoration approach + } + } + + using (collectionView.DeferRefresh()) + { + // Clear both filters AND sorts to prevent any CollectionView processing + collectionView.FilterDescriptions.Clear(); + collectionView.SortDescriptions.Clear(); + + // Set pre-processed source directly + collectionView.Source = items; + } + + // IMMEDIATE SCROLL RESTORATION: Restore scroll position as soon as possible + if (scrollViewer != null && preservedOffset > 0) + { + try + { + scrollViewer.ChangeView(preservedOffset, null, null, true); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"BypassCollectionView scroll restore error: {ex.Message}"); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"BypassCollectionViewAndSetSource error: {ex.Message}"); + } + } + + /// + /// Update TableView SortDescriptions to reflect our internal sorting state + /// Maintains UI consistency between internal state and TableView state + /// + private void UpdateTableViewSortDescriptions() + { + try + { + // Clear existing sorts + _tableView.SortDescriptions.Clear(); + + // Add our current sort if any + if (_currentSortColumn != null && _currentSortDirection.HasValue) + { + var propertyPath = (_currentSortColumn as TableViewBoundColumn)?.PropertyPath; + var sortDesc = new ColumnSortDescription(_currentSortColumn, propertyPath, _currentSortDirection.Value); + _tableView.SortDescriptions.Add(sortDesc); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"UpdateTableViewSortDescriptions error: {ex.Message}"); + } + } + + // INTERFACE IMPLEMENTATION: IColumnFilterHandler.GetFilterItems + public virtual IList GetFilterItems(TableViewColumn column, string? searchText = default) + { + if (column?.TableView?.ItemsSource is not { } itemsSource) + { + return new List(); + } + + // Store original source for fast filtering + if (_originalItemsSource == null) + { + _originalItemsSource = itemsSource as IList ?? itemsSource.Cast().ToList(); + _currentFilteredSource = _originalItemsSource; + } + + // Only rebuild cache when ItemsSource actually changes + if (_lastItemsSource != itemsSource) + { + _smartUniqueValuesCache.Clear(); + _lastItemsSource = itemsSource; + _originalItemsSource = itemsSource as IList ?? itemsSource.Cast().ToList(); + _currentFilteredSource = _originalItemsSource; + } + + // SMART CACHE: Get unique values using intelligent cache key + var uniqueValues = GetUniqueValuesWithSmartCache(column); + + var filteredValues = string.IsNullOrEmpty(searchText) + ? uniqueValues + : uniqueValues.Where(value => + { + // Handle blank values in search + if (value == null) + { + var blankText = TableViewLocalizedStrings.BlankFilterValue ?? "(Blank)"; + return blankText.Contains(searchText, StringComparison.OrdinalIgnoreCase); + } + return value.ToString()?.Contains(searchText, StringComparison.OrdinalIgnoreCase) == true; + }); + + return filteredValues + .Select(value => new TableViewFilterItem( + GetSelectionState(value, column), + // Use the same representation for blank values everywhere + value ?? TableViewLocalizedStrings.BlankFilterValue ?? "(Blank)")) + .OrderBy(item => + { + // Sort blank values to the end + if (item.Value?.ToString() == (TableViewLocalizedStrings.BlankFilterValue ?? "(Blank)")) + return "zzz_blank"; + return item.Value?.ToString() ?? ""; + }) + .ToList(); + } + + /// + /// SMART CACHE: Gets unique values using cache key that includes filter state + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private HashSet GetUniqueValuesWithSmartCache(TableViewColumn column) + { + var smartCacheKey = GenerateSmartCacheKey(column); + + if (_smartUniqueValuesCache.TryGetValue(smartCacheKey, out var cached)) + { + return cached; + } - column.IsFiltered = true; - column.TableView.FilterDescriptions.Add(new ColumnFilterDescription( - column, - boundColumn?.PropertyPath, - (o) => Filter(column, o))); + IList? sourceForUniqueValues = !HasAnyActiveFilters() + ? _originalItemsSource + : _currentFilteredSource; + + var values = new ConcurrentBag(); + var accessor = GetOrCreatePropertyAccessor(column); + var source = sourceForUniqueValues?.Cast().ToArray() ?? Array.Empty(); + + if (source.Length > 2000) + { + Parallel.ForEach(source, item => + { + try + { + var value = accessor(item); + // Always add the actual value, null for blank values + values.Add(IsBlank(value) ? null : value); + } + catch + { + // Skip items that cause errors + } + }); + } + else + { + foreach (var item in source) + { + try + { + var value = accessor(item); + // Always add the actual value, null for blank values + values.Add(IsBlank(value) ? null : value); + } + catch + { + // Skip items that cause errors + } } - column.TableView.RefreshFilter(); - column.TableView.EnsureAlternateRowColors(); } + + var result = new HashSet(values); + _smartUniqueValuesCache[smartCacheKey] = result; + + return result; + } + + private string GenerateSmartCacheKey(TableViewColumn column) + { + var columnId = column.GetHashCode().ToString(); + var dataCount = (_currentFilteredSource?.Count ?? 0).ToString(); + + var otherFiltersHash = string.Join("|", _activeFilters + .Where(kvp => kvp.Key != column) + .OrderBy(kvp => kvp.Key.GetHashCode()) + .Select(kvp => $"{kvp.Key.GetHashCode()}:{string.Join(",", kvp.Value.OrderBy(v => v.GetHashCode()))}")); + + return $"{columnId}:{dataCount}:{otherFiltersHash}"; + } + + public virtual void ApplyFilter(TableViewColumn column) + { + if (column?.TableView is null) return; + + if (SelectedValues.TryGetValue(column, out var selectedList)) + { + // Handle the conversion from UI values to filter values + var normalizedValues = selectedList.Select(value => + { + // Handle blank values + if (value == null || + (value is string str && str == (TableViewLocalizedStrings.BlankFilterValue ?? "(Blank)"))) + { + return ""; + } + return NormalizeValue(value); + }).ToHashSet(); + + _activeFilters[column] = normalizedValues; + } + + ApplyFilteringAndSorting(); + + if (!column.IsFiltered) + { + column.IsFiltered = true; + column.TableView.FilterDescriptions.Add(new ColumnFilterDescription( + column, + (column as TableViewBoundColumn)?.PropertyPath, + _ => true)); + } + + _tableView.EnsureAlternateRowColors(); } - /// public virtual void ClearFilter(TableViewColumn? column) { - if (column is { TableView: { } }) + if (column?.TableView is null) return; + + if (column is not null) { column.IsFiltered = false; - column.TableView.FilterDescriptions.RemoveWhere(x => x is ColumnFilterDescription columnFilter && columnFilter.Column == column); + column.TableView.FilterDescriptions.RemoveWhere(x => + x is ColumnFilterDescription cf && cf.Column == column); + SelectedValues.RemoveWhere(x => x.Key == column); - column.TableView.RefreshFilter(); + _activeFilters.TryRemove(column, out _); } else { SelectedValues.Clear(); _tableView.FilterDescriptions.Clear(); + _activeFilters.Clear(); + // Clear all column sort directions when clearing all filters foreach (var col in _tableView.Columns) { if (col is not null) @@ -111,17 +812,536 @@ public virtual void ClearFilter(TableViewColumn? column) col.IsFiltered = false; } } + + // When clearing all filters, also restore original order + _currentSortColumn = null; + _currentSortDirection = null; + } + + ApplyFilteringAndSorting(); + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private Func GetOrCreatePropertyAccessor(TableViewColumn column) + { + return _propertyAccessors.GetOrAdd(column, col => + { + if (col is TableViewBoundColumn boundColumn && !string.IsNullOrEmpty(boundColumn.PropertyPath)) + { + var propertyPath = boundColumn.PropertyPath; + if (propertyPath.Contains('.')) + { + var parts = propertyPath.Split('.'); + return item => + { + var current = item; + foreach (var part in parts) + { + if (current == null) return null; + try + { + var property = current.GetType().GetProperty(part); + current = property?.GetValue(current); + } + catch + { + return null; + } + } + return current; + }; + } + else + { + var propertyCache = new ConcurrentDictionary(); + return item => + { + if (item == null) return null; + + var type = item.GetType(); + var property = propertyCache.GetOrAdd(type, t => t.GetProperty(propertyPath)); + + try + { + return property?.GetValue(item); + } + catch + { + return null; + } + }; + } + } + + // SAFE FALLBACK: Never use GetCellContent - it's unreliable + return item => item?.ToString(); + }); + } + + private bool HasAnyActiveFilters() => _activeFilters.Any(kvp => kvp.Value.Count > 0); + + private bool GetSelectionState(object? value, TableViewColumn column) + { + if (!column.IsFiltered) return true; + + // Handle both null values and normalized values correctly + if (value == null) + { + // For null values, check if "" is in the active filters + return _activeFilters.TryGetValue(column, out var filters) && filters.Contains(""); + } + + // For non-null values, use the actual value + return _activeFilters.TryGetValue(column, out var nonNullFilters) && nonNullFilters.Contains(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsBlank(object? value) + { + return value == null || value == DBNull.Value || + (value is string str && string.IsNullOrWhiteSpace(str)) || + (value is Guid guid && guid == Guid.Empty); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static object NormalizeValue(object? value) + { + return IsBlank(value) ? "" : value!; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public virtual bool Filter(TableViewColumn column, object? item) => true; + + public void ClearCache() + { + _smartUniqueValuesCache.Clear(); + _activeFilters.Clear(); + _propertyAccessors.Clear(); + _currentSortColumn = null; + _currentSortDirection = null; + _lastItemsSource = null; + _originalItemsSource = null; + _currentFilteredSource = null; + } + + public IDictionary> SelectedValues { get; } + + /// + /// SelectedValuesWrapper to handle blank values correctly + /// + private class SelectedValuesWrapper : Dictionary> + { + private readonly ColumnFilterHandler _handler; + + public SelectedValuesWrapper(ColumnFilterHandler handler) + { + _handler = handler; + } + + public new IList this[TableViewColumn key] + { + get => base[key]; + set + { + base[key] = value; + // Handle blank values correctly in the setter + var normalizedValues = value.Select(val => + { + if (val == null || + (val is string str && str == (TableViewLocalizedStrings.BlankFilterValue ?? "(Blank)"))) + { + return ""; + } + return NormalizeValue(val); + }).ToHashSet(); + _handler._activeFilters[key] = normalizedValues; + } + } + + public new bool TryGetValue(TableViewColumn key, out IList value) + { + var result = base.TryGetValue(key, out value!); + if (result && !_handler._activeFilters.ContainsKey(key)) + { + // Handle blank values correctly + var normalizedValues = value.Select(val => + { + if (val == null || + (val is string str && str == (TableViewLocalizedStrings.BlankFilterValue ?? "(Blank)"))) + { + return ""; + } + return NormalizeValue(val); + }).ToHashSet(); + _handler._activeFilters[key] = normalizedValues; + } + return result; + } + + public new bool Remove(TableViewColumn key) + { + _handler._activeFilters.TryRemove(key, out _); + return base.Remove(key); + } + + public void RemoveWhere(Func>, bool> predicate) + { + var keysToRemove = this.Where(predicate).Select(kvp => kvp.Key).ToList(); + foreach (var key in keysToRemove) + { + _handler._activeFilters.TryRemove(key, out _); + base.Remove(key); + } + } + + public new void Clear() + { + _handler._activeFilters.Clear(); + base.Clear(); + } + + private static object NormalizeValue(object? value) + { + return ColumnFilterHandler.NormalizeValue(value); + } + } + + /// + /// Fast Single-column sorting engine optimized 500k+ of rows + /// + private class FastSortingEngine + { + // Pre-compiled property accessors cache for maximum performance + private static readonly ConcurrentDictionary> _compiledAccessors = new(); + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public IList SortFast( + IList sourceItems, + TableViewColumn sortColumn, + SortDirection direction, + ConcurrentDictionary> propertyAccessors) + { + if (sourceItems.Count == 0) + { + return sourceItems; + } + + var sourceArray = sourceItems.Cast().ToArray(); + var accessor = propertyAccessors.GetValueOrDefault(sortColumn) ?? (item => item?.ToString()); + + try + { + // Use parallel sorting for large datasets + if (sourceArray.Length > 10000) + { + return ParallelSortFast(sourceArray, accessor, direction); + } + else + { + // Single-threaded for smaller datasets + var comparer = new FastSingleColumnComparer(accessor, direction); + Array.Sort(sourceArray, comparer); + return sourceArray.ToList(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Sort error: {ex.Message}"); + return sourceItems; + } + } + + /// + /// Parallel merge sort for 500k+ of rows + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private IList ParallelSortFast(object[] sourceArray, Func accessor, SortDirection direction) + { + var processorCount = Environment.ProcessorCount; + var chunkSize = Math.Max(sourceArray.Length / processorCount, 1000); + var chunks = new List(); + + // Step 1: Divide into chunks for parallel processing + for (int i = 0; i < sourceArray.Length; i += chunkSize) + { + var size = Math.Min(chunkSize, sourceArray.Length - i); + var chunk = new object[size]; + Array.Copy(sourceArray, i, chunk, 0, size); + chunks.Add(chunk); + } + + // Step 2: Sort each chunk in parallel + var sortedChunks = new object[chunks.Count][]; + var comparer = new FastSingleColumnComparer(accessor, direction); + + Parallel.For(0, chunks.Count, i => + { + Array.Sort(chunks[i], comparer); + sortedChunks[i] = chunks[i]; + }); + + // Step 3: Merge all sorted chunks using k-way merge + return MergeChunksFast(sortedChunks, comparer); + } + + /// + /// FAST: K-way merge algorithm for combining sorted chunks + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private List MergeChunksFast(object[][] sortedChunks, IComparer comparer) + { + var result = new List(sortedChunks.Sum(c => c.Length)); + var heap = new PriorityQueue(comparer); + + // Initialize heap with first element from each chunk + for (int i = 0; i < sortedChunks.Length; i++) + { + if (sortedChunks[i].Length > 0) + { + var pointer = new ChunkPointer { ChunkIndex = i, ElementIndex = 0 }; + heap.Enqueue(pointer, sortedChunks[i][0]); + } + } + + // Extract minimum and add next element from same chunk + while (heap.Count > 0) + { + var minPointer = heap.Dequeue(); + var chunk = sortedChunks[minPointer.ChunkIndex]; + result.Add(chunk[minPointer.ElementIndex]); + + // Add next element from the same chunk if available + if (minPointer.ElementIndex + 1 < chunk.Length) + { + minPointer.ElementIndex++; + heap.Enqueue(minPointer, chunk[minPointer.ElementIndex]); + } + } + + return result; + } + + /// + /// Helper struct for k-way merge + /// + private struct ChunkPointer + { + public int ChunkIndex; + public int ElementIndex; + } + + /// + /// Single-column comparer with type-specific fast paths + /// + private class FastSingleColumnComparer : IComparer + { + private readonly Func _accessor; + private readonly SortDirection _direction; + private readonly int _directionMultiplier; + + public FastSingleColumnComparer(Func accessor, SortDirection direction) + { + _accessor = accessor; + _direction = direction; + _directionMultiplier = direction == SortDirection.Ascending ? 1 : -1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Compare(object? x, object? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x == null) return -_directionMultiplier; + if (y == null) return _directionMultiplier; + + try + { + var xValue = _accessor(x); + var yValue = _accessor(y); + + return CompareValuesFast(xValue, yValue) * _directionMultiplier; + } + catch + { + // fast fallback + return CompareToStringFast(x, y) * _directionMultiplier; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CompareValuesFast(object? x, object? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x == null) return -1; + if (y == null) return 1; + + // FAST: Type-specific comparisons with minimal boxing + var xType = x.GetType(); + var yType = y.GetType(); + + if (xType == yType) + { + // Same types - fast path + return Type.GetTypeCode(xType) switch + { + TypeCode.String => string.Compare((string)x, (string)y, StringComparison.OrdinalIgnoreCase), + TypeCode.Int32 => ((int)x).CompareTo((int)y), + TypeCode.Int64 => ((long)x).CompareTo((long)y), + TypeCode.Double => ((double)x).CompareTo((double)y), + TypeCode.Decimal => ((decimal)x).CompareTo((decimal)y), + TypeCode.DateTime => ((DateTime)x).CompareTo((DateTime)y), + TypeCode.Boolean => ((bool)x).CompareTo((bool)y), + TypeCode.Single => ((float)x).CompareTo((float)y), + TypeCode.Int16 => ((short)x).CompareTo((short)y), + TypeCode.Byte => ((byte)x).CompareTo((byte)y), + _ => (x as IComparable)?.CompareTo(y) ?? CompareToStringFast(x, y) + }; + } + + // Different types - try IComparable first + if (x is IComparable comparable) + { + try { return comparable.CompareTo(y); } + catch { /* Fall through */ } + } + + // Ultimate fallback + return CompareToStringFast(x, y); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CompareToStringFast(object x, object y) + { + return string.Compare(x?.ToString() ?? "", y?.ToString() ?? "", StringComparison.OrdinalIgnoreCase); + } } } - /// - public virtual bool Filter(TableViewColumn column, object? item) + /// + /// Fast filter engine optimized for 500k+ of rows + /// + private class FastFilterEngine { - var value = column.GetCellContent(item); - value = IsBlank(value) ? TableViewLocalizedStrings.BlankFilterValue : value!; - return SelectedValues[column].Contains(value); + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public IList FilterFast( + IList? originalSource, + ConcurrentDictionary> activeFilters, + ConcurrentDictionary> propertyAccessors) + { + if (originalSource == null || originalSource.Count == 0) + { + return new List(); + } + + if (activeFilters.Count == 0) + { + return originalSource; + } + + var sourceArray = originalSource.Cast().ToArray(); + var result = new ConcurrentBag(); + + Parallel.ForEach( + Partitioner.Create(sourceArray, true), + item => + { + bool passesAllFilters = true; + + foreach (var (column, allowedValues) in activeFilters) + { + if (!propertyAccessors.TryGetValue(column, out var accessor)) + { + continue; + } + + try + { + var value = accessor(item); + var normalizedValue = IsBlank(value) ? "" : value!; + + if (!allowedValues.Contains(normalizedValue)) + { + passesAllFilters = false; + break; + } + } + catch + { + // Skip items that cause errors + passesAllFilters = false; + break; + } + } + + if (passesAllFilters) + { + result.Add(item); + } + }); + + return result.ToList(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsBlank(object? value) + { + return value == null || value == DBNull.Value || + (value is string str && string.IsNullOrWhiteSpace(str)) || + (value is Guid guid && guid == Guid.Empty); + } } - /// - public IDictionary> SelectedValues { get; } = new Dictionary>(); + /// + /// Temporarily suppress scroll events during data operations + /// + private void SuppressScrollEventsDuringOperation(Action operation) + { + try + { + // Cache the scroll viewer reference for better performance + if (_cachedScrollViewer == null) + { + var scrollViewerField = typeof(TableView).GetField("_scrollViewer", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + _cachedScrollViewer = scrollViewerField?.GetValue(_tableView) as ScrollViewer; + } + + if (_cachedScrollViewer != null) + { + // Store scroll position before operation + var originalOffset = _cachedScrollViewer.HorizontalOffset; + + // Execute the operation + operation(); + + // Restore immediately after operation + _cachedScrollViewer.ChangeView(originalOffset, null, null, true); + + // Schedule additional restorations for improved reliability + _tableView.DispatcherQueue?.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.High, () => + { + try + { + if (_cachedScrollViewer != null && Math.Abs(_cachedScrollViewer.HorizontalOffset - originalOffset) > 1) + { + _cachedScrollViewer.ChangeView(originalOffset, null, null, true); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Scroll restoration error: {ex.Message}"); + } + }); + } + else + { + // Fallback: execute operation without scroll suppression + operation(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"SuppressScrollEventsDuringOperation error: {ex.Message}"); + operation(); // Execute operation anyway + } + } } diff --git a/src/IColumnFilterHandler.cs b/src/IColumnFilterHandler.cs index 1a321f7..1c86b49 100644 --- a/src/IColumnFilterHandler.cs +++ b/src/IColumnFilterHandler.cs @@ -38,4 +38,17 @@ public interface IColumnFilterHandler /// The item to check. /// True if the item passes the filter; otherwise, false. bool Filter(TableViewColumn column, object? item); + + /// + /// UNIFIED: Applies fast sorting to the specified column with the given direction. + /// + /// The column to sort. + /// The sort direction. + void ApplyUnifiedSort(TableViewColumn column, SortDirection direction); + + /// + /// UNIFIED: Clears fast sorting from the specified column. + /// + /// The column to clear sorting from. + void ClearUnifiedSort(TableViewColumn column); } diff --git a/src/TableViewColumnHeader.cs b/src/TableViewColumnHeader.cs index ec5d15e..2c20417 100644 --- a/src/TableViewColumnHeader.cs +++ b/src/TableViewColumnHeader.cs @@ -66,34 +66,21 @@ private void OnWidthChanged(DependencyObject sender, DependencyProperty dp) } /// - /// Sorts the column in the specified direction. + /// Sorts the column in the specified direction using fast sorting. /// private void DoSort(SD? direction, bool singleSorting = true) { - if (CanSort && Column is not null && _tableView is { CollectionView: CollectionView { } collectionView }) + if (CanSort && Column is not null && _tableView is not null) { - var defer = collectionView.DeferRefresh(); + // UNIFIED PATH: Use the fast sorting as header clicks + if (direction is not null) { - if (singleSorting) - { - _tableView.ClearAllSortingWithEvent(); - } - else - { - ClearSortingWithEvent(); - } - - if (direction is not null) - { - var boundColumn = Column as TableViewBoundColumn; - Column.SortDirection = direction; - _tableView.SortDescriptions.Add( - new ColumnSortDescription(Column!, boundColumn?.PropertyPath, direction.Value)); - - _tableView.EnsureAlternateRowColors(); - } + _tableView.FilterHandler.ApplyUnifiedSort(Column, direction.Value); + } + else + { + _tableView.FilterHandler.ClearUnifiedSort(Column); } - defer.Complete(); } }