diff --git a/src/ColumnFilterHandler.cs b/src/ColumnFilterHandler.cs index 9c6e94cd..ed34f7e9 100644 --- a/src/ColumnFilterHandler.cs +++ b/src/ColumnFilterHandler.cs @@ -41,7 +41,9 @@ public virtual IList GetFilterItems(TableViewColumn column, })); } - collectionView.Source = (column.TableView.ItemsSource as IEnumerable) ?? Enumerable.Empty(); + collectionView.Source = (_tableView.IsHierarchicalEnabled + ? (IEnumerable)_tableView.GetAllHierarchyItemsFlat().ToList() + : column.TableView.ItemsSource as IEnumerable) ?? Enumerable.Empty(); var items = _tableView.ShowFilterItemsCount ? GetFilterItemsWithCount(column, searchText, collectionView) : diff --git a/src/EventArgs/TableViewRowExpansionChangedEventArgs.cs b/src/EventArgs/TableViewRowExpansionChangedEventArgs.cs new file mode 100644 index 00000000..12f37095 --- /dev/null +++ b/src/EventArgs/TableViewRowExpansionChangedEventArgs.cs @@ -0,0 +1,34 @@ +using System; + +namespace WinUI.TableView; + +/// +/// Provides data for the and events. +/// +public sealed class TableViewRowExpansionChangedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + public TableViewRowExpansionChangedEventArgs(object item, int index, bool isExpanded) + { + Item = item; + Index = index; + IsExpanded = isExpanded; + } + + /// + /// Gets the item whose expansion state changed. + /// + public object Item { get; } + + /// + /// Gets the index of the item in the display list. + /// + public int Index { get; } + + /// + /// Gets a value indicating whether the item is now expanded. + /// + public bool IsExpanded { get; } +} diff --git a/src/ItemsSource/CollectionView.Properties.cs b/src/ItemsSource/CollectionView.Properties.cs index 6c022501..31536d30 100644 --- a/src/ItemsSource/CollectionView.Properties.cs +++ b/src/ItemsSource/CollectionView.Properties.cs @@ -8,6 +8,21 @@ namespace WinUI.TableView; partial class CollectionView { + /// + /// Gets or sets a value indicating whether sort descriptions should be ignored when + /// rebuilding the view. Set by when hierarchical mode is active + /// so that per-level sorting is applied during flattening instead of globally. + /// + internal bool BypassSort { get; set; } + + /// + /// Gets or sets a value indicating whether filter descriptions should be ignored when + /// rebuilding the view. Set by when hierarchical mode is active + /// so that subtree-aware filtering is applied during hierarchy flattening instead of + /// the flat per-item filter used in normal mode. + /// + internal bool BypassFilter { get; set; } + /// /// Gets or sets the source collection. /// diff --git a/src/ItemsSource/CollectionView.cs b/src/ItemsSource/CollectionView.cs index 18e42342..34c162b2 100644 --- a/src/ItemsSource/CollectionView.cs +++ b/src/ItemsSource/CollectionView.cs @@ -255,7 +255,7 @@ private void OnItemPropertyChanged(object? item, PropertyChangedEventArgs e) return; } - if (FilterDescriptions.Any(fd => string.IsNullOrEmpty(fd.PropertyName) || fd.PropertyName == e.PropertyName)) + if (!BypassFilter && FilterDescriptions.Any(fd => string.IsNullOrEmpty(fd.PropertyName) || fd.PropertyName == e.PropertyName)) { var filterResult = FilterDescriptions.All(x => x.Predicate(item)); var viewIndex = _view.IndexOf(item); @@ -318,7 +318,7 @@ private void HandleSourceChanged() if (Source is not null) { - if (FilterDescriptions.Count > 0) + if (!BypassFilter && FilterDescriptions.Count > 0) { foreach (var item in Source) { @@ -331,7 +331,7 @@ private void HandleSourceChanged() _view.AddRange(_source.OfType()); } - if (SortDescriptions.Count > 0) + if (SortDescriptions.Count > 0 && !BypassSort) _view.Sort(this); } @@ -344,6 +344,8 @@ private void HandleSourceChanged() /// private void HandleFilterChanged() { + if (BypassFilter) return; + if (FilterDescriptions.Count > 0) { for (var index = 0; index < _view.Count; index++) @@ -384,16 +386,18 @@ private void HandleFilterChanged() /// private void HandleSortChanged() { - if (SortDescriptions.Count > 0) + if (!BypassSort) { - _view.Sort(this); - } - else - { - HandleSourceChanged(); - } + if (SortDescriptions.Count > 0) + _view.Sort(this); + else + HandleSourceChanged(); - OnVectorChanged(new VectorChangedEventArgs(CollectionChange.Reset)); + OnVectorChanged(new VectorChangedEventArgs(CollectionChange.Reset)); + } + // When BypassSort is true the external caller (TableView) handles the full + // rebuild via RebuildHierarchyView(). Firing VectorChanged here would trigger + // a stale RebuildDisplayedItems() before the hierarchy is re-flattened. } /// @@ -401,7 +405,7 @@ private void HandleSortChanged() /// private bool HandleItemAdded(int newStartingIndex, object? newItem, int? viewIndex = null) { - if (!FilterDescriptions.All(x => x.Predicate(newItem))) + if (!BypassFilter && !FilterDescriptions.All(x => x.Predicate(newItem))) { return false; } diff --git a/src/Strings/en-US/WinUI.TableView.resw b/src/Strings/en-US/WinUI.TableView.resw index 9c722cf9..34b23fe2 100644 --- a/src/Strings/en-US/WinUI.TableView.resw +++ b/src/Strings/en-US/WinUI.TableView.resw @@ -180,4 +180,10 @@ Copy + + Expand row + + + Collapse row + \ No newline at end of file diff --git a/src/TableView.Events.cs b/src/TableView.Events.cs index aa1dc8ff..91131487 100644 --- a/src/TableView.Events.cs +++ b/src/TableView.Events.cs @@ -271,4 +271,30 @@ protected internal virtual void OnColumnReordered(TableViewColumnReorderedEventA { ColumnReordered?.Invoke(this, args); } -} \ No newline at end of file + + /// + /// Occurs when a row is expanded in hierarchical mode. + /// + public event EventHandler? RowExpanded; + + /// + /// Occurs when a row is collapsed in hierarchical mode. + /// + public event EventHandler? RowCollapsed; + + /// + /// Called when a row is expanded. + /// + protected internal virtual void OnRowExpanded(TableViewRowExpansionChangedEventArgs args) + { + RowExpanded?.Invoke(this, args); + } + + /// + /// Called when a row is collapsed. + /// + protected internal virtual void OnRowCollapsed(TableViewRowExpansionChangedEventArgs args) + { + RowCollapsed?.Invoke(this, args); + } +} diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index d90e7d26..b28297c1 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Media; using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -67,6 +68,46 @@ public partial class TableView /// public static readonly DependencyProperty AutoGenerateColumnsProperty = DependencyProperty.Register(nameof(AutoGenerateColumns), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnAutoGenerateColumnsChanged)); + /// + /// Identifies the IsHierarchicalEnabled dependency property. + /// + public static readonly DependencyProperty IsHierarchicalEnabledProperty = DependencyProperty.Register(nameof(IsHierarchicalEnabled), typeof(bool), typeof(TableView), new PropertyMetadata(false, OnIsHierarchicalEnabledChanged)); + + /// + /// Identifies the HierarchyItemsSourcePath dependency property. + /// + public static readonly DependencyProperty HierarchyItemsSourcePathProperty = DependencyProperty.Register(nameof(HierarchyItemsSourcePath), typeof(string), typeof(TableView), new PropertyMetadata(default(string), OnHierarchyItemsSourcePathChanged)); + + /// + /// Identifies the HierarchyIndent dependency property. + /// + public static readonly DependencyProperty HierarchyIndentProperty = DependencyProperty.Register(nameof(HierarchyIndent), typeof(double), typeof(TableView), new PropertyMetadata(16d, OnHierarchyIndentChanged)); + + /// + /// Identifies the ChildrenPath dependency property (alias for ). + /// + public static readonly DependencyProperty ChildrenPathProperty = HierarchyItemsSourcePathProperty; + + /// + /// Identifies the IndentSize dependency property (alias for ). + /// + public static readonly DependencyProperty IndentSizeProperty = HierarchyIndentProperty; + + /// + /// Identifies the HasChildrenPath dependency property. + /// + public static readonly DependencyProperty HasChildrenPathProperty = DependencyProperty.Register(nameof(HasChildrenPath), typeof(string), typeof(TableView), new PropertyMetadata(default(string), OnHierarchyBindingChanged)); + + /// + /// Identifies the IsExpandedPath dependency property. + /// + public static readonly DependencyProperty IsExpandedPathProperty = DependencyProperty.Register(nameof(IsExpandedPath), typeof(string), typeof(TableView), new PropertyMetadata(default(string), OnHierarchyBindingChanged)); + + /// + /// Identifies the ChildrenSelector dependency property. + /// + public static readonly DependencyProperty ChildrenSelectorProperty = DependencyProperty.Register(nameof(ChildrenSelector), typeof(Func), typeof(TableView), new PropertyMetadata(default(Func), OnHierarchyBindingChanged)); + /// /// Identifies the IsReadOnly dependency property. /// @@ -476,6 +517,78 @@ public bool AutoGenerateColumns set => SetValue(AutoGenerateColumnsProperty, value); } + /// + /// Gets or sets a value indicating whether hierarchical (tree-view) mode is enabled. + /// + public bool IsHierarchicalEnabled + { + get => (bool)GetValue(IsHierarchicalEnabledProperty); + set => SetValue(IsHierarchicalEnabledProperty, value); + } + + /// + /// Gets or sets the property path used to retrieve child items for each row. + /// + public string? HierarchyItemsSourcePath + { + get => (string?)GetValue(HierarchyItemsSourcePathProperty); + set => SetValue(HierarchyItemsSourcePathProperty, value); + } + + /// + /// Gets or sets the indentation per hierarchy level in pixels. + /// + public double HierarchyIndent + { + get => (double)GetValue(HierarchyIndentProperty); + set => SetValue(HierarchyIndentProperty, value); + } + + /// + /// Gets or sets the property path used to retrieve child items (alias for ). + /// + public string? ChildrenPath + { + get => (string?)GetValue(ChildrenPathProperty); + set => SetValue(ChildrenPathProperty, value); + } + + /// + /// Gets or sets the indentation per hierarchy level in pixels (alias for ). + /// + public double IndentSize + { + get => (double)GetValue(IndentSizeProperty); + set => SetValue(IndentSizeProperty, value); + } + + /// + /// Gets or sets the property path used to determine whether an item has children. + /// + public string? HasChildrenPath + { + get => (string?)GetValue(HasChildrenPathProperty); + set => SetValue(HasChildrenPathProperty, value); + } + + /// + /// Gets or sets the property path used to get/set the expanded state of an item. + /// + public string? IsExpandedPath + { + get => (string?)GetValue(IsExpandedPathProperty); + set => SetValue(IsExpandedPathProperty, value); + } + + /// + /// Gets or sets a function used to retrieve child items for each row. + /// + public Func? ChildrenSelector + { + get => (Func?)GetValue(ChildrenSelectorProperty); + set => SetValue(ChildrenSelectorProperty, value); + } + /// /// Gets or sets a value indicating whether the TableView is read-only. This will override what is set on individual column. /// @@ -845,6 +958,53 @@ private static void OnAutoGenerateColumnsChanged(DependencyObject d, DependencyP } } + /// + /// Handles changes to the IsHierarchicalEnabled property. + /// + private static void OnIsHierarchicalEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tv) + { + tv._collectionView.BypassSort = tv.IsHierarchicalEnabled; + tv._collectionView.BypassFilter = tv.IsHierarchicalEnabled; + tv._collapsedHierarchyItems.Clear(); + tv.RebuildHierarchyView(); + } + } + + /// + /// Handles changes to the HierarchyItemsSourcePath property. + /// + private static void OnHierarchyItemsSourcePathChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tv && tv.IsHierarchicalEnabled) + { + tv.RebuildHierarchyView(); + } + } + + /// + /// Handles changes to the HierarchyIndent property. + /// + private static void OnHierarchyIndentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tv) + { + tv.UpdateAllRowsHierarchyPresentation(); + } + } + + /// + /// Handles changes to hierarchy binding properties (HasChildrenPath, IsExpandedPath, ChildrenSelector). + /// + private static void OnHierarchyBindingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tv && tv.IsHierarchicalEnabled) + { + tv.RebuildHierarchyView(); + } + } + /// /// Handles changes to the CornerButtonMode property. /// @@ -976,12 +1136,25 @@ private static async void OnCurrentCellSlotChanged(DependencyObject d, Dependenc { if (d is not TableView tableView) return; - tableView.OnCurrentCellChanged(e); + // Track the data item for the new slot so that RebuildHierarchyView can + // update the slot's row index after items shift (item identity, not position). + if (!tableView._suppressCurrentCellChanged) + { + var newSlot = e.NewValue as TableViewCellSlot?; + if (newSlot.HasValue && newSlot.Value.Row >= 0 && newSlot.Value.Row < tableView.Items.Count) + tableView._currentCellItem = tableView.Items[newSlot.Value.Row]; + else + tableView._currentCellItem = null; + } - var oldSlot = e.OldValue as TableViewCellSlot?; - var newSlot = e.NewValue as TableViewCellSlot?; + if (!tableView._suppressCurrentCellChanged) + { + tableView.OnCurrentCellChanged(e); - await tableView.OnCurrentCellChanged(oldSlot, newSlot); + var oldSlot = e.OldValue as TableViewCellSlot?; + var newSlot = e.NewValue as TableViewCellSlot?; + await tableView.OnCurrentCellChanged(oldSlot, newSlot); + } } /// @@ -1109,7 +1282,10 @@ private static void OnAreRowDetailsFrozen(DependencyObject d, DependencyProperty /// private void OnBaseItemsSourceChanged(DependencyObject sender, DependencyProperty dp) { - throw new InvalidOperationException("Setting this property directly is not allowed. Use TableView.ItemsSource instead."); + if (!_isUpdatingBaseItemsSource) + { + throw new InvalidOperationException("Setting this property directly is not allowed. Use TableView.ItemsSource instead."); + } } /// diff --git a/src/TableView.cs b/src/TableView.cs index e21a6d3e..276ef7d0 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -6,6 +6,8 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.IO; @@ -15,6 +17,7 @@ using System.Threading.Tasks; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; +using Windows.Foundation.Collections; using Windows.Storage; using Windows.Storage.Pickers; using Windows.System; @@ -34,9 +37,23 @@ public partial class TableView : ListView private ScrollViewer? _scrollViewer; private RowDefinition? _headerRowDefinition; private bool _shouldThrowSelectionModeChangedException; + private bool _isUpdatingBaseItemsSource; private bool _ensureColumns = true; private readonly List _rows = []; private readonly CollectionView _collectionView = []; + private readonly ObservableCollection _displayItems = []; + private const int HierarchyMaxDepth = 1000; + + private readonly Dictionary _hierarchyLevelsByItem = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _collapsedHierarchyItems = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _hierarchyHasChildrenCache = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary<(Type Type, string Path), Func?> _propertyPathAccessorCache = []; + private bool _isDisplayedItemsRebuildQueued; + private bool _isHierarchyViewRebuildQueued; + private bool _suppressIsExpandedPathRebuild; + private bool _suppressCurrentCellChanged; + private object? _currentCellItem; + private readonly HashSet _observedHierarchyCollections = new(ReferenceEqualityComparer.Instance); /// /// Initializes a new instance of the TableView class. @@ -48,7 +65,9 @@ public TableView() Columns = new TableViewColumnsCollection(this); FilterHandler = new ColumnFilterHandler(this); - base.ItemsSource = _collectionView; + _isUpdatingBaseItemsSource = true; + base.ItemsSource = _displayItems; + _isUpdatingBaseItemsSource = false; base.SelectionMode = SelectionMode; SetValue(ConditionalCellStylesProperty, new TableViewConditionalCellStylesCollection()); @@ -59,6 +78,9 @@ public TableView() Unloaded += OnUnloaded; SelectionChanged += TableView_SelectionChanged; _collectionView.ItemPropertyChanged += OnItemPropertyChanged; + _collectionView.VectorChanged += OnCollectionViewVectorChanged; + ((INotifyCollectionChanged)_collectionView.SortDescriptions).CollectionChanged += OnSortDescriptionsChanged; + ((INotifyCollectionChanged)_collectionView.FilterDescriptions).CollectionChanged += OnFilterDescriptionsChanged; } /// @@ -96,6 +118,26 @@ private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) var row = ContainerFromItem(sender) as TableViewRow; row?.EnsureCellsStyle(default, sender); + + if (!IsHierarchicalEnabled || _suppressIsExpandedPathRebuild || sender is null) + { + return; + } + + var propertyLeaf = e.PropertyName; + + var isExpandedChanged = !string.IsNullOrWhiteSpace(IsExpandedPath) && + string.Equals(propertyLeaf, GetPropertyPathLeaf(IsExpandedPath), StringComparison.OrdinalIgnoreCase) && + ResolvePropertyPathValue(sender, IsExpandedPath!) is bool; + + var hasChildrenChanged = !string.IsNullOrWhiteSpace(HasChildrenPath) && + string.Equals(propertyLeaf, GetPropertyPathLeaf(HasChildrenPath), StringComparison.OrdinalIgnoreCase) && + ResolvePropertyPathValue(sender, HasChildrenPath!) is bool; + + if (isExpandedChanged || hasChildrenChanged) + { + QueueHierarchyViewRebuild(); + } } /// @@ -103,6 +145,11 @@ protected override void PrepareContainerForItemOverride(DependencyObject element { base.PrepareContainerForItemOverride(element, item); + if (element is TableViewRow row && !_rows.Contains(row)) + { + _rows.Add(row); + } + DispatcherQueue.TryEnqueue(() => { if (element is TableViewRow row) @@ -111,9 +158,13 @@ protected override void PrepareContainerForItemOverride(DependencyObject element row.ApplyCellsSelectionState(); row.RowPresenter?.ApplyDetailsPaneState(item); - if (CurrentCellSlot.HasValue) + // Always sweep all cells — even when no cell is current — so that any + // stale StateCurrent left on a recycled container is cleared to StateRegular. + row.ApplyCurrentCellState(); + + if (IsHierarchicalEnabled) { - row.ApplyCurrentCellState(CurrentCellSlot.Value); + row.RowPresenter?.UpdateHierarchyPresentation(); } } }); @@ -128,10 +179,20 @@ protected override DependencyObject GetContainerForItemOverride() row.SetBinding(FontFamilyProperty, new Binding { Path = new("TableView.FontFamily"), RelativeSource = new() { Mode = RelativeSourceMode.Self } }); row.SetBinding(FontSizeProperty, new Binding { Path = new("TableView.FontSize"), RelativeSource = new() { Mode = RelativeSourceMode.Self } }); - _rows.Add(row); return row; } + /// + protected override void ClearContainerForItemOverride(DependencyObject element, object item) + { + base.ClearContainerForItemOverride(element, item); + + if (element is TableViewRow row) + { + _rows.Remove(row); + } + } + /// protected override async void OnKeyDown(KeyRoutedEventArgs e) { @@ -154,6 +215,12 @@ private async Task HandleNavigations(KeyRoutedEventArgs e, bool shiftKey, bool c { var currentCell = CurrentCellSlot.HasValue ? GetCellFromSlot(CurrentCellSlot.Value) : default; + if (!IsEditing && e.Key is VirtualKey.Left or VirtualKey.Right && TryHandleHierarchyArrowNavigation(e.Key)) + { + e.Handled = true; + return; + } + if (e.Key is VirtualKey.F2 && currentCell is { IsReadOnly: false } && !IsEditing) { e.Handled = await currentCell.BeginCellEditing(e); @@ -327,6 +394,14 @@ private void OnScrollViewerLoaded(object sender, RoutedEventArgs e) private void OnLoaded(object sender, RoutedEventArgs e) { EnsureAutoColumns(); + + // Re-establish hierarchy subscriptions cleared by OnUnloaded. + // Necessary when NavigationCacheMode.Enabled reuses the same control instance + // across navigation — ItemsSource doesn't change, so no ItemsSourceChanged fires. + if (IsHierarchicalEnabled && HasHierarchyBinding()) + { + UpdateHierarchySourceSubscription(ItemsSource as IEnumerable); + } } /// @@ -338,6 +413,8 @@ private void OnUnloaded(object sender, RoutedEventArgs e) { currentCell.EndEditing(TableViewEditAction.Commit); } + + UpdateHierarchySourceSubscription(null); } /// @@ -599,6 +676,11 @@ private void GenerateColumns() { foreach (var propertyInfo in dataType.GetProperties()) { + if (ShouldSkipAutoGeneratedHierarchyProperty(propertyInfo.Name)) + { + continue; + } + var displayAttribute = propertyInfo.GetCustomAttributes().OfType().FirstOrDefault(); var autoGenerateField = displayAttribute?.GetAutoGenerateField(); if (autoGenerateField == false) @@ -677,16 +759,8 @@ private static TableViewBoundColumn GetTableViewColumnFromType(string? propertyN private void ItemsSourceChanged(DependencyPropertyChangedEventArgs e) { DetailsPaneStates.Clear(); - - using var defer = _collectionView.DeferRefresh(); - _collectionView.Source = null!; - - if (e.NewValue is IEnumerable source) - { - EnsureAutoColumns(); - - _collectionView.Source = source; - } + _collapsedHierarchyItems.Clear(); + RefreshProcessedItemsSource(e.NewValue as IEnumerable); } /// @@ -792,14 +866,29 @@ private async Task GetStorageFile() public void RefreshView() { DeselectAll(); - _collectionView.Refresh(); + + if (IsHierarchicalEnabled) + { + RebuildHierarchyView(); + return; + } + + RefreshProcessedItemsSource(ItemsSource as IEnumerable); } /// /// Refreshes the sorting applied to the items in the TableView. + /// When is , sorting is applied + /// per-level within the hierarchy rather than globally, so the view is rebuilt. /// public void RefreshSorting() { + if (IsHierarchicalEnabled) + { + RebuildHierarchyView(); + return; + } + DeselectAll(); _collectionView.RefreshSorting(); } @@ -847,9 +936,18 @@ public void ClearAllFilters() /// /// Refreshes all applied filters. + /// When is , the hierarchy + /// is rebuilt with subtree-aware filtering applied. /// public void RefreshFilter() { + if (IsHierarchicalEnabled) + { + DeselectAll(); + RebuildHierarchyView(); + return; + } + DeselectAll(); _collectionView.RefreshFilter(); } @@ -1533,4 +1631,840 @@ internal void UpdateHorizontalScrollBarMargin() var offset = CellsHorizontalOffset + Columns.VisibleColumns.Where(c => c.IsFrozen).Sum(c => c.ActualWidth); AttachedPropertiesHelper.SetFrozenColumnScrollBarSpace(_scrollViewer, offset); } + + // ───────────────────────────────────────────────────────── Hierarchy ── + + private void OnCollectionViewVectorChanged(IObservableVector sender, IVectorChangedEventArgs args) + { + // In hierarchy mode, RebuildHierarchyView manages _displayItems directly via + // a synchronous RebuildDisplayedItems() call. Letting the CollectionView's + // deferred VectorChanged fire an additional async Clear+Add cycle causes + // containers to be briefly deassigned, making IndexFromContainer return stale + // values inside UpdateAllRowsHierarchyPresentation and leaving stale + // StateCurrent visual states on cells that WinUI skips re-preparing. + if (IsHierarchicalEnabled) + { + return; + } + + QueueDisplayedItemsRebuild(); + } + + private void QueueDisplayedItemsRebuild() + { + if (_isDisplayedItemsRebuildQueued) + { + return; + } + + _isDisplayedItemsRebuildQueued = true; + + if (DispatcherQueue is null) + { + _isDisplayedItemsRebuildQueued = false; + RebuildDisplayedItems(); + return; + } + + if (!DispatcherQueue.TryEnqueue(() => + { + _isDisplayedItemsRebuildQueued = false; + RebuildDisplayedItems(); + })) + { + // Enqueue failed (DispatcherQueue shut down); run synchronously to avoid + // the debounce flag staying stuck true and blocking all future rebuilds. + _isDisplayedItemsRebuildQueued = false; + RebuildDisplayedItems(); + } + } + + private void RefreshProcessedItemsSource(IEnumerable? source) + { + UpdateHierarchySourceSubscription(source); + + using var defer = _collectionView.DeferRefresh(); + + _hierarchyLevelsByItem.Clear(); + _hierarchyHasChildrenCache.Clear(); + _displayItems.Clear(); + _collectionView.Source = null!; + + if (source is not null) + { + EnsureAutoColumns(); + _collectionView.Source = BuildProcessedSource(source); + } + + RebuildDisplayedItems(); + } + + private void UpdateHierarchySourceSubscription(IEnumerable? newSource) + { + foreach (var collection in _observedHierarchyCollections) + { + collection.CollectionChanged -= OnHierarchyCollectionChanged; + } + + _observedHierarchyCollections.Clear(); + + if (IsHierarchicalEnabled && HasHierarchyBinding() && newSource is not null) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + SubscribeToHierarchyCollections(newSource, visited); + } + } + + /// + /// Recursively subscribes to every INotifyCollectionChanged collection in the hierarchy tree, + /// so that changes at any nesting level (not just the root) trigger a rebuild. + /// + private void SubscribeToHierarchyCollections(IEnumerable source, HashSet visited) + { + if (source is INotifyCollectionChanged incc && _observedHierarchyCollections.Add(incc)) + { + incc.CollectionChanged += OnHierarchyCollectionChanged; + } + + foreach (var item in source.OfType()) + { + if (!visited.Add(item)) + { + continue; + } + + var children = GetChildren(item); + if (children is IEnumerable childrenEnum && children is not string) + { + SubscribeToHierarchyCollections(childrenEnum, visited); + } + } + } + + private void OnHierarchyCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // For Reset (e.g. Clear()), OldItems is null by convention; re-scan subscriptions + // and purge all collapse state since we don't know which items were removed. + if (e.Action == NotifyCollectionChangedAction.Reset) + { + _collapsedHierarchyItems.Clear(); + UpdateHierarchySourceSubscription(ItemsSource as IEnumerable); + QueueHierarchyViewRebuild(); + return; + } + + // Clean up collapse state for removed items and all their descendants so + // they don't linger in _collapsedHierarchyItems and keep objects alive past + // their logical lifetime (e.g. a terminated process in a Task Manager view). + if (e.OldItems is not null) + { + foreach (var item in e.OldItems.OfType()) + { + CleanCollapsedStateRecursive(item); + } + } + + // Subscribe to child collections of newly added items so that mutations + // anywhere in the subtree trigger a rebuild, not just at the root level. + if (e.NewItems is not null) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var item in e.NewItems.OfType()) + { + var children = GetChildren(item); + if (children is IEnumerable childEnum && children is not string) + { + SubscribeToHierarchyCollections(childEnum, visited); + } + } + } + + QueueHierarchyViewRebuild(); + } + + private void CleanCollapsedStateRecursive(object item, HashSet? visited = null) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + + if (!visited.Add(item)) + { + return; // circular reference – stop + } + + _collapsedHierarchyItems.Remove(item); + + var children = GetChildren(item); + if (children is IEnumerable childrenEnum && children is not string) + { + foreach (var child in childrenEnum.OfType()) + { + CleanCollapsedStateRecursive(child, visited); + } + } + } + + private void OnSortDescriptionsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (IsHierarchicalEnabled) + { + // Sort changes are user-initiated and infrequent; rebuild synchronously so that + // tests and code that read Items immediately after adding a SortDescription see + // the updated order without needing an explicit RefreshView() call. + RebuildHierarchyView(); + } + } + + private void OnFilterDescriptionsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (IsHierarchicalEnabled) + { + // Filter changes are user-initiated and infrequent; rebuild synchronously so + // that items are immediately hidden/shown and tests see a consistent state. + RebuildHierarchyView(); + } + } + + private void RebuildDisplayedItems() + { + _displayItems.Clear(); + + foreach (var item in _collectionView.OfType()) + { + _displayItems.Add(item); + } + } + + private IEnumerable BuildProcessedSource(IEnumerable source) + { + _hierarchyLevelsByItem.Clear(); + + if (IsHierarchicalEnabled && HasHierarchyBinding()) + { + _collectionView.BypassSort = true; + _collectionView.BypassFilter = true; + var flattened = new List(); + var filterVisited = new HashSet(ReferenceEqualityComparer.Instance); + FlattenHierarchy(source, flattened, 0, new HashSet(ReferenceEqualityComparer.Instance), filterVisited); + return flattened; + } + + _collectionView.BypassSort = false; + _collectionView.BypassFilter = false; + foreach (var item in source.OfType()) + { + _hierarchyLevelsByItem[item] = 0; + } + + return source; + } + + private void FlattenHierarchy(IEnumerable source, ICollection target, int level, HashSet path, HashSet filterVisited) + { + if (level >= HierarchyMaxDepth) + { + System.Diagnostics.Debug.WriteLine($"[WinUI.TableView] Hierarchy depth limit ({HierarchyMaxDepth}) reached; stopping traversal."); + return; + } + + IEnumerable items = source.OfType(); + + if (_collectionView.SortDescriptions.Count > 0) + { + items = items.OrderBy(x => x, _collectionView); + } + + var hasFilter = _collectionView.FilterDescriptions.Count > 0; + + foreach (var item in items) + { + // Clear the visited set before each call so that nodes explored by a + // previous SubtreeMatchesFilter invocation don't falsely trigger the + // circular-reference guard when the same node is evaluated again for + // a different branch of the tree. + filterVisited.Clear(); + + // Skip items whose entire subtree has no match for the active filter. + if (hasFilter && !SubtreeMatchesFilter(item, filterVisited)) + { + continue; + } + + target.Add(item); + _hierarchyLevelsByItem[item] = level; + + if (!path.Add(item)) + { + continue; // circular reference – skip recursion + } + + try + { + // When a filter is active, force-expand items that are ancestors of a matching + // descendant (but don't directly match themselves) so matching items stay visible. + var itemPassesFilter = !hasFilter || _collectionView.FilterDescriptions.All(fd => fd.Predicate(item)); + var shouldRecurse = IsItemExpanded(item) || (hasFilter && !itemPassesFilter); + + if (!shouldRecurse) + { + continue; + } + + var children = GetChildren(item); + + if (children is IEnumerable childrenEnumerable && children is not string) + { + FlattenHierarchy(childrenEnumerable, target, level + 1, path, filterVisited); + } + } + finally + { + path.Remove(item); + } + } + } + + /// + /// Returns if itself passes all active + /// filter predicates, or if any descendant in its subtree does. + /// Used by to decide whether an item should appear in + /// the hierarchy view when a filter is active. + /// + private bool SubtreeMatchesFilter(object item, HashSet? visited = null) + { + if (_collectionView.FilterDescriptions.All(fd => fd.Predicate(item))) + { + return true; + } + + var children = GetChildren(item); + + if (children is not null && children is not string) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + + if (!visited.Add(item)) + { + return false; // circular reference — stop + } + + foreach (var child in children.OfType()) + { + if (SubtreeMatchesFilter(child, visited)) + { + return true; + } + } + } + + return false; + } + + /// + /// Returns every item in the hierarchy tree, ignoring collapse and filter state. + /// Used by to enumerate all values for the filter dropdown. + /// + internal IEnumerable GetAllHierarchyItemsFlat() + { + if (ItemsSource is not IEnumerable source) + { + return []; + } + + var visited = new HashSet(ReferenceEqualityComparer.Instance); + return GetAllItemsFlatRecursive(source, visited); + } + + private IEnumerable GetAllItemsFlatRecursive(IEnumerable source, HashSet visited) + { + foreach (var item in source.OfType()) + { + if (!visited.Add(item)) + { + continue; // circular reference – skip recursion + } + + yield return item; + + var children = GetChildren(item); + + if (children is IEnumerable childEnum && children is not string) + { + foreach (var descendant in GetAllItemsFlatRecursive(childEnum, visited)) + { + yield return descendant; + } + } + } + } + + private bool HasHierarchyBinding() => ChildrenSelector is not null || !string.IsNullOrWhiteSpace(ChildrenPath); + + private IEnumerable? GetChildren(object item) + { + if (ChildrenSelector is not null) + { + return ChildrenSelector(item); + } + + if (!string.IsNullOrWhiteSpace(ChildrenPath)) + { + return ResolvePropertyPathValue(item, ChildrenPath!) as IEnumerable; + } + + return null; + } + + private object? ResolvePropertyPathValue(object item, string propertyPath) + { + var key = (item.GetType(), propertyPath); + + if (!_propertyPathAccessorCache.TryGetValue(key, out var accessor)) + { + accessor = item.GetFuncCompiledPropertyPath(propertyPath); + _propertyPathAccessorCache[key] = accessor; + } + + return accessor?.Invoke(item); + } + + private bool TrySetSimplePropertyPathValue(object item, string propertyPath, object? value) + { + try + { + object current = item; + var parts = propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries); + + for (var index = 0; index < parts.Length - 1; index++) + { + var propertyInfo = current.GetType().GetProperty(parts[index], BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (propertyInfo?.GetValue(current) is not { } next) + { + return false; + } + + current = next; + } + + var targetProperty = current.GetType().GetProperty(parts[^1], BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (targetProperty is null || !targetProperty.CanWrite) + { + return false; + } + + var convertedValue = value; + if (value is not null && targetProperty.PropertyType != value.GetType()) + { + var targetType = Nullable.GetUnderlyingType(targetProperty.PropertyType) ?? targetProperty.PropertyType; + convertedValue = Convert.ChangeType(value, targetType); + } + + targetProperty.SetValue(current, convertedValue); + return true; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[WinUI.TableView] Failed to write '{propertyPath}' on {item.GetType().Name}: {ex.Message}. Note: only dot-separated property paths are supported for write-back; indexers are not supported."); + return false; + } + } + + /// + /// Returns whether the item has child items. + /// + public bool HasChildItems(object? item) + { + if (!IsHierarchicalEnabled || item is null) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(HasChildrenPath) && + ResolvePropertyPathValue(item, HasChildrenPath!) is bool hasChildren) + { + return hasChildren; + } + + if (_hierarchyHasChildrenCache.TryGetValue(item, out var cached)) + { + return cached; + } + + var children = GetChildren(item); + if (children is null || children is string) + { + return _hierarchyHasChildrenCache[item] = false; + } + + if (children is ICollection collection) + { + return _hierarchyHasChildrenCache[item] = collection.Count > 0; + } + + var enumerator = children.GetEnumerator(); + bool hasAny; + try + { + hasAny = enumerator.MoveNext(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + + return _hierarchyHasChildrenCache[item] = hasAny; + } + + /// + /// Returns whether the item is currently expanded. + /// + public bool IsItemExpanded(object? item) + { + if (!IsHierarchicalEnabled || item is null || !HasChildItems(item)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(IsExpandedPath) && + ResolvePropertyPathValue(item, IsExpandedPath!) is bool isExpanded) + { + return isExpanded; + } + + return !_collapsedHierarchyItems.Contains(item); + } + + /// + /// Toggles the expansion state of the item. + /// + public void ToggleItemExpansion(object? item) + { + if (item is null || !HasChildItems(item)) + { + return; + } + + SetItemExpanded(item, !IsItemExpanded(item)); + } + + /// + /// Sets the expansion state of the item and rebuilds the visible list. + /// + public void SetItemExpanded(object item, bool isExpanded) + { + if (!HasChildItems(item)) + { + return; + } + + // No-op: avoid redundant events and rebuild when the state already matches. + if (IsItemExpanded(item) == isExpanded) + { + return; + } + + if (!string.IsNullOrWhiteSpace(IsExpandedPath)) + { + // Suppress the property-change rebuild triggered by setting IsExpandedPath — the + // explicit RebuildHierarchyView() call below handles the rebuild synchronously. + _suppressIsExpandedPathRebuild = true; + try + { + _ = TrySetSimplePropertyPathValue(item, IsExpandedPath!, isExpanded); + } + finally + { + _suppressIsExpandedPathRebuild = false; + } + } + + if (isExpanded) + { + _collapsedHierarchyItems.Remove(item); + OnRowExpanded(new TableViewRowExpansionChangedEventArgs(item, IndexFromItem(item), true)); + } + else + { + _collapsedHierarchyItems.Add(item); + OnRowCollapsed(new TableViewRowExpansionChangedEventArgs(item, IndexFromItem(item), false)); + } + + RebuildHierarchyView(); + } + + /// + /// Expands the specified item so its children are visible. + /// + public void ExpandItem(object item) => SetItemExpanded(item, true); + + /// + /// Collapses the specified item, hiding its children. + /// + public void CollapseItem(object item) => SetItemExpanded(item, false); + + /// + /// Expands all items in the hierarchy in a single rebuild pass. + /// + public void ExpandAll() + { + if (!IsHierarchicalEnabled) + { + return; + } + + _suppressIsExpandedPathRebuild = true; + try + { + foreach (var item in GetAllHierarchyItemsFlat()) + { + if (!HasChildItems(item)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(IsExpandedPath)) + { + _ = TrySetSimplePropertyPathValue(item, IsExpandedPath!, true); + } + + _collapsedHierarchyItems.Remove(item); + } + } + finally + { + _suppressIsExpandedPathRebuild = false; + } + + RebuildHierarchyView(); + } + + /// + /// Collapses all items in the hierarchy in a single rebuild pass. + /// + public void CollapseAll() + { + if (!IsHierarchicalEnabled) + { + return; + } + + _suppressIsExpandedPathRebuild = true; + try + { + foreach (var item in GetAllHierarchyItemsFlat()) + { + if (!HasChildItems(item)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(IsExpandedPath)) + { + _ = TrySetSimplePropertyPathValue(item, IsExpandedPath!, false); + } + + _collapsedHierarchyItems.Add(item); + } + } + finally + { + _suppressIsExpandedPathRebuild = false; + } + + RebuildHierarchyView(); + } + + private void QueueHierarchyViewRebuild() + { + if (_isHierarchyViewRebuildQueued) + { + return; + } + + _isHierarchyViewRebuildQueued = true; + + if (DispatcherQueue is null) + { + _isHierarchyViewRebuildQueued = false; + RebuildHierarchyView(); + return; + } + + if (!DispatcherQueue.TryEnqueue(() => + { + _isHierarchyViewRebuildQueued = false; + RebuildHierarchyView(); + })) + { + // Enqueue failed (DispatcherQueue shut down); run synchronously. + _isHierarchyViewRebuildQueued = false; + RebuildHierarchyView(); + } + } + + private void RebuildHierarchyView() + { + _hierarchyHasChildrenCache.Clear(); + var currentColumn = CurrentCellSlot?.Column ?? -1; + + RefreshProcessedItemsSource(ItemsSource as IEnumerable); + + // After rebuild row indices shift — silently update CurrentCellSlot to track + // the same data item at its new position so that OnCurrentCellChanged can + // correctly reset the right container later. + if (_currentCellItem is not null && currentColumn >= 0) + { + _suppressCurrentCellChanged = true; + try + { + var newRow = _displayItems.IndexOf(_currentCellItem); + if (newRow >= 0) + { + CurrentCellSlot = new TableViewCellSlot(newRow, currentColumn); + } + else + { + // Item was collapsed/hidden — clear the current cell. + CurrentCellSlot = null; + _currentCellItem = null; + } + } + finally + { + _suppressCurrentCellChanged = false; + } + } + + // Update recycled rows whose OnApplyTemplate already fired before the rebuild. + // Fall back to synchronous execution if the DispatcherQueue is unavailable, + // rather than silently skipping the visual update. + if (DispatcherQueue is null || !DispatcherQueue.TryEnqueue(UpdateAllRowsHierarchyPresentation)) + { + UpdateAllRowsHierarchyPresentation(); + } + } + + /// + /// Updates hierarchy presentation and cell states for all visible rows. + /// + internal void UpdateAllRowsHierarchyPresentation() + { + foreach (var row in _rows) + { + row.RowPresenter?.UpdateHierarchyPresentation(); + + // Re-apply current-cell visual state for all rows — including when + // CurrentCellSlot is null (item collapsed/hidden) so that any stale + // StateCurrent on previously focused cells is cleared to StateRegular. + row.ApplyCurrentCellState(); + } + } + + /// + /// Gets the nesting level of an item in the hierarchy. + /// + public int GetHierarchyLevel(object? item) + { + if (item is not null && _hierarchyLevelsByItem.TryGetValue(item, out var level)) + { + return level; + } + + return 0; + } + + private int IndexFromItem(object item) => _displayItems.IndexOf(item); + + private bool TryHandleHierarchyArrowNavigation(VirtualKey key) + { + if (!IsHierarchicalEnabled || Items.Count == 0) + { + return false; + } + + var row = (LastSelectionUnit is TableViewSelectionUnit.Row ? CurrentRowIndex : CurrentCellSlot?.Row) ?? -1; + var column = CurrentCellSlot?.Column ?? 0; + + if (row < 0) + { + row = SelectedIndex; + } + + if (row < 0 || row >= Items.Count) + { + return false; + } + + var item = Items[row]; + var level = GetHierarchyLevel(item); + var hasChildren = HasChildItems(item); + var isExpanded = IsItemExpanded(item); + + if (key is VirtualKey.Right) + { + if (hasChildren && !isExpanded) + { + SetItemExpanded(item, true); + return true; + } + + if (hasChildren && isExpanded) + { + var firstChildRow = row + 1; + if (firstChildRow < Items.Count && GetHierarchyLevel(Items[firstChildRow]) == level + 1) + { + MakeSelection(new TableViewCellSlot(firstChildRow, Math.Max(0, column)), false); + return true; + } + } + + return false; + } + + // Left key + if (hasChildren && isExpanded) + { + SetItemExpanded(item, false); + return true; + } + + if (level <= 0) + { + return false; + } + + for (var index = row - 1; index >= 0; index--) + { + if (GetHierarchyLevel(Items[index]) == level - 1) + { + MakeSelection(new TableViewCellSlot(index, Math.Max(0, column)), false); + return true; + } + } + + return false; + } + + private bool ShouldSkipAutoGeneratedHierarchyProperty(string propertyName) + { + if (!IsHierarchicalEnabled) + { + return false; + } + + var comparableName = GetPropertyPathLeaf(propertyName); + return string.Equals(comparableName, GetPropertyPathLeaf(ChildrenPath), StringComparison.OrdinalIgnoreCase) + || string.Equals(comparableName, GetPropertyPathLeaf(HasChildrenPath), StringComparison.OrdinalIgnoreCase) + || string.Equals(comparableName, GetPropertyPathLeaf(IsExpandedPath), StringComparison.OrdinalIgnoreCase); + } + + private static string? GetPropertyPathLeaf(string? propertyPath) + { + if (string.IsNullOrWhiteSpace(propertyPath)) + { + return null; + } + + var parts = propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 0 ? parts[^1] : propertyPath; + } } diff --git a/src/TableViewLocalizedStrings.cs b/src/TableViewLocalizedStrings.cs index 3403060c..a12c9cf9 100644 --- a/src/TableViewLocalizedStrings.cs +++ b/src/TableViewLocalizedStrings.cs @@ -39,6 +39,8 @@ static TableViewLocalizedStrings() SortAscending = GetValue(nameof(SortAscending)); SortDescending = GetValue(nameof(SortDescending)); TimePickerPlaceholder = GetValue(nameof(TimePickerPlaceholder)); + ExpandRow = GetValue(nameof(ExpandRow)); + CollapseRow = GetValue(nameof(CollapseRow)); } private static string GetValue(string name) @@ -85,4 +87,6 @@ private static string GetValue(string name) public static string SortAscending { get; set; } public static string SortDescending { get; set; } public static string TimePickerPlaceholder { get; set; } + public static string ExpandRow { get; set; } + public static string CollapseRow { get; set; } } diff --git a/src/TableViewRow.cs b/src/TableViewRow.cs index d403a149..a5a6a598 100644 --- a/src/TableViewRow.cs +++ b/src/TableViewRow.cs @@ -35,6 +35,7 @@ public partial class TableViewRow : ListViewItem private TableView? _tableView; private ListViewItemPresenter? _itemPresenter; + private TableViewRowPresenter? _rowPresenter; private Border? _selectionBackground; private bool _ensureCells = true; private Brush? _cellPresenterBackground; @@ -123,8 +124,8 @@ protected override void OnApplyTemplate() _cellPresenterBackground = Background; _cellPresenterForeground = Foreground; _itemPresenter = GetTemplateChild("Root") as ListViewItemPresenter; + _rowPresenter = GetTemplateChild("RowPresenter") as TableViewRowPresenter; #if !WINDOWS - RowPresenter = GetTemplateChild("RowPresenter") as TableViewRowPresenter; _selectionBackground = GetTemplateChild("SelectionBackground") as Border; #endif } @@ -211,6 +212,15 @@ protected override Size ArrangeOverride(Size finalSize) return finalSize; } + /// + /// Resets the row for a group header item by clearing cells and marking for later recreation if reused. + /// + internal void PrepareForGroupHeader() + { + _ensureCells = true; + RowPresenter?.ClearCells(); + } + /// /// Ensures cells are created for the row. /// @@ -465,13 +475,13 @@ internal void EnsureCellsStyle(TableViewColumn? column = null, object? dataItem } /// - /// Applies the current cell state to the specified slot. + /// Applies the current cell state to all cells in the row. /// - internal void ApplyCurrentCellState(TableViewCellSlot slot) + internal void ApplyCurrentCellState() { - if (slot.Column >= 0 && slot.Column < Cells.Count) + // Iterate all cells so any stale StateCurrent on recycled containers is cleared. + foreach (var cell in Cells) { - var cell = Cells[slot.Column]; cell.ApplyCurrentCellState(); } } @@ -519,12 +529,6 @@ internal void EnsureLayout() selectionIndicator = fontIcon?.Parent as Border; } - if (TableView is ListView { SelectionMode: ListViewSelectionMode.Multiple }) - { - var fontIcon = this.FindDescendant(x => x.Glyph == Check_Mark); - selectionIndicator = fontIcon?.Parent as Border; - } - _selectionBackground ??= _itemPresenter?.FindDescendants() .OfType() @@ -640,10 +644,12 @@ internal set } /// - public TableViewRowPresenter? RowPresenter -#if WINDOWS - => ContentTemplateRoot as TableViewRowPresenter; -#else - { get; private set; } -#endif + public TableViewRowPresenter? RowPresenter => _rowPresenter; + + /// + /// Called by from its OnApplyTemplate to wire up + /// the back-pointer. On Windows the ControlTemplate root is ListViewItemPresenter so + /// GetTemplateChild("RowPresenter") always returns null; the presenter must self-register. + /// + internal void SetRowPresenter(TableViewRowPresenter rowPresenter) => _rowPresenter = rowPresenter; } diff --git a/src/TableViewRowHeader.cs b/src/TableViewRowHeader.cs index a0bb53aa..f4ada283 100644 --- a/src/TableViewRowHeader.cs +++ b/src/TableViewRowHeader.cs @@ -1,4 +1,4 @@ -using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System; using Windows.Foundation; @@ -38,7 +38,7 @@ protected override Size MeasureOverride(Size availableSize) if (TableView is not null && TableViewRow is not null && _contentPresenter is not null) { var element = ContentTemplateRoot as FrameworkElement; - #region TEMP_FIX_FOR_ISSUE https://github.com/microsoft/microsoft-ui-xaml/issues/9860 + #region TEMP_FIX_FOR_ISSUE https://github.com/microsoft/microsoft-ui-xaml/issues/9860 if (element is not null) { element.MaxWidth = double.PositiveInfinity; @@ -122,12 +122,12 @@ private double GetHorizontalGridlineHeight() } /// - /// Gets or sets the TableViewRow associated with the presenter. + /// Gets or sets the TableView associated with the header. /// public TableView? TableView { get; internal set; } /// - /// Gets or sets the TableView associated with the presenter. + /// Gets or sets the TableViewRow associated with the header. /// public TableViewRow? TableViewRow { get; internal set; } } diff --git a/src/TableViewRowPresenter.cs b/src/TableViewRowPresenter.cs index 00f382eb..14ac7518 100644 --- a/src/TableViewRowPresenter.cs +++ b/src/TableViewRowPresenter.cs @@ -1,5 +1,6 @@ using Microsoft.UI; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Data; @@ -32,6 +33,15 @@ public partial class TableViewRowPresenter : Control private Panel? _detailsPanel; private ContentPresenter? _detailsPresenter; private ToggleButton? _detailsToggleButton; + private ToggleButton? _hierarchyToggleButton; + private bool _isUpdatingHierarchyToggle; + + private static readonly DependencyProperty OriginalCellLeftPaddingProperty = + DependencyProperty.RegisterAttached( + "OriginalCellLeftPadding", + typeof(double), + typeof(TableViewRowPresenter), + new PropertyMetadata(double.NaN)); private ListViewItemPresenter? _itemPresenter; /// @@ -56,17 +66,31 @@ protected override void OnApplyTemplate() _detailsPanel = GetTemplateChild("DetailsPanel") as Panel; _detailsPresenter = GetTemplateChild("DetailsPresenter") as ContentPresenter; _detailsToggleButton = GetTemplateChild("DetailsToggleButton") as ToggleButton; + _hierarchyToggleButton = GetTemplateChild("HierarchyToggleButton") as ToggleButton; _itemPresenter = this.FindAscendant(); TableViewRow = this.FindAscendant(); TableView = TableViewRow?.TableView; + // On Windows, GetTemplateChild("RowPresenter") on TableViewRow always returns null because + // TableViewRowPresenter is rendered via ItemTemplate inside ListViewItemPresenter, not as a + // named part of TableViewRow's ControlTemplate. Self-register so the back-pointer is valid. + TableViewRow?.SetRowPresenter(this); + if (_rowHeader is not null) { _rowHeader.TableView = TableView; _rowHeader.TableViewRow = TableViewRow; } + if (_hierarchyToggleButton is not null) + { + _hierarchyToggleButton.Checked -= OnHierarchyToggleButtonChanged; + _hierarchyToggleButton.Unchecked -= OnHierarchyToggleButtonChanged; + _hierarchyToggleButton.Checked += OnHierarchyToggleButtonChanged; + _hierarchyToggleButton.Unchecked += OnHierarchyToggleButtonChanged; + } + if (_detailsToggleButton is not null) { _detailsToggleButton.Tapped += OnDetailsToggleButtonTapped; @@ -79,7 +103,11 @@ protected override void OnApplyTemplate() => TableViewRow?.EnsureLayout()); } - TableViewRow?.EnsureCells(); + // Defer EnsureCells so it runs after both OnApplyTemplate methods have fired. + // TableViewRow.OnApplyTemplate fires first and sets _rowPresenter, but + // _scrollableCellsPanel in this presenter is only set during THIS method. + // A deferred call ensures both are available when cells are created and inserted. + DispatcherQueue?.TryEnqueue(() => TableViewRow?.EnsureCells()); EnsureGridLines(); SetRowHeaderBindings(); SetRowHeaderVisibility(); @@ -87,6 +115,12 @@ protected override void OnApplyTemplate() SetRowHeaderWidth(); SetRowDetailsVisibility(); SetRowDetailsTemplate(); + UpdateHierarchyPresentation(); + + // Deferred retry: handles the case where Content or hierarchy level was not + // available yet when OnApplyTemplate fired (timing gap between item assignment + // and visual-tree connection). + DispatcherQueue?.TryEnqueue(UpdateHierarchyPresentation); } /// @@ -477,4 +511,101 @@ public void ClearCells() /// Gets a value indicating whether the row details panel is currently visible. /// internal bool IsDetailsPanelVisible => _detailsPanel?.Visibility is Visibility.Visible; + + /// + /// Updates the hierarchy indent and expander button state for this row. + /// + internal void UpdateHierarchyPresentation() + { + if (TableView is null) + { + return; + } + + if (!TableView.IsHierarchicalEnabled) + { + // Reset everything when hierarchy mode is turned off. + if (_hierarchyToggleButton is not null) + { + _hierarchyToggleButton.Visibility = Visibility.Collapsed; + } + SetFirstCellHierarchyMargin(0); + return; + } + + if (_hierarchyToggleButton is null) + { + return; + } + + var item = TableViewRow?.Content; + var level = TableView.GetHierarchyLevel(item); + var hasChildren = TableView.HasChildItems(item); + var isExpanded = TableView.IsItemExpanded(item); + var indent = level * TableView.HierarchyIndent; + + // Position the toggle button at the indent level, overlaying the start of the first cell. + // Keep it in the visual tree (Visible) but transparent for leaf nodes so that all rows + // at the same level reserve identical space, keeping the cell content aligned. + _isUpdatingHierarchyToggle = true; + _hierarchyToggleButton.Visibility = Visibility.Visible; + _hierarchyToggleButton.Opacity = hasChildren ? 1.0 : 0.0; + _hierarchyToggleButton.IsHitTestVisible = hasChildren; + _hierarchyToggleButton.Margin = new Thickness(indent, 0, 0, 0); + // Pin width to HierarchyIndent so it exactly fills the space reserved in the cell margin. + _hierarchyToggleButton.Width = TableView.HierarchyIndent; + _hierarchyToggleButton.IsChecked = isExpanded; + + // Force the visual state even when IsChecked didn't change (e.g. recycled row + // already had IsChecked=false) so the glyph always reflects the correct state. + VisualStateManager.GoToState(_hierarchyToggleButton, isExpanded ? "Checked" : "Unchecked", useTransitions: false); + _isUpdatingHierarchyToggle = false; + + AutomationProperties.SetName( + _hierarchyToggleButton, + isExpanded ? TableViewLocalizedStrings.CollapseRow : TableViewLocalizedStrings.ExpandRow); + + // Offset the first data cell so its content doesn't overlap the toggle button. + // Reserve one HierarchyIndent-wide slot for the toggle regardless of level so + // that leaf and parent rows at the same level start their text at the same x position. + SetFirstCellHierarchyMargin(indent + TableView.HierarchyIndent); + } + + private void OnHierarchyToggleButtonChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingHierarchyToggle || TableViewRow?.Content is null || TableView is null || _hierarchyToggleButton is null) + { + return; + } + + TableView.SetItemExpanded(TableViewRow.Content, _hierarchyToggleButton.IsChecked is true); + } + + private void SetFirstCellHierarchyMargin(double leftMargin) + { + FrameworkElement? firstCell = null; + + if (_frozenCellsPanel?.Children.Count > 0) + firstCell = _frozenCellsPanel.Children[0] as FrameworkElement; + else if (_scrollableCellsPanel?.Children.Count > 0) + firstCell = _scrollableCellsPanel.Children[0] as FrameworkElement; + + // Use Padding (not Margin) so only the cell's *content* is indented. + // Margin would shift all subsequent cells right relative to their column headers. + if (firstCell is Control cell) + { + // Capture the original left padding the first time we touch this cell so that + // subsequent calls (e.g. on recycle with a different indent level) always add + // the hierarchy margin on top of the template-defined padding rather than + // accumulating it on top of a previously modified value. + var originalLeft = (double)cell.GetValue(OriginalCellLeftPaddingProperty); + if (double.IsNaN(originalLeft)) + { + originalLeft = cell.Padding.Left; + cell.SetValue(OriginalCellLeftPaddingProperty, originalLeft); + } + + cell.Padding = new Thickness(originalLeft + leftMargin, cell.Padding.Top, cell.Padding.Right, cell.Padding.Bottom); + } + } } diff --git a/src/Themes/Resources.xaml b/src/Themes/Resources.xaml index 31d47b4a..b9675779 100644 --- a/src/Themes/Resources.xaml +++ b/src/Themes/Resources.xaml @@ -457,4 +457,64 @@ + + + diff --git a/src/Themes/TableViewRowHeader.xaml b/src/Themes/TableViewRowHeader.xaml index 948f581e..25ecf023 100644 --- a/src/Themes/TableViewRowHeader.xaml +++ b/src/Themes/TableViewRowHeader.xaml @@ -23,11 +23,6 @@ BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"> - - - - - - - - + Orientation="Horizontal" /> + + + public const string StateEmpty = "Empty"; + /// + /// Expanded state group + /// + public const string GroupExpanded = "ExpandedStates"; + // GroupFocus /// diff --git a/tests/TableViewHierarchyTests.cs b/tests/TableViewHierarchyTests.cs new file mode 100644 index 00000000..aca6a0fd --- /dev/null +++ b/tests/TableViewHierarchyTests.cs @@ -0,0 +1,1528 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using WinUI.TableView; + +namespace WinUI.TableView.Tests; + +[TestClass] +public class TableViewHierarchyTests +{ + // ────────────────────────────────────── Defaults ────────────────────── + + [UITestMethod] + public void IsHierarchicalEnabled_DefaultsFalse() + { + var tv = new TableView(); + Assert.IsFalse(tv.IsHierarchicalEnabled); + } + + [UITestMethod] + public void HierarchyIndent_DefaultIs16() + { + var tv = new TableView(); + Assert.AreEqual(16d, tv.HierarchyIndent); + } + + [UITestMethod] + public void ChildrenPath_And_HierarchyItemsSourcePath_ShareSameDP() + { + var tv = new TableView(); + tv.HierarchyItemsSourcePath = "Children"; + Assert.AreEqual("Children", tv.ChildrenPath); + tv.ChildrenPath = "Kids"; + Assert.AreEqual("Kids", tv.HierarchyItemsSourcePath); + } + + [UITestMethod] + public void IndentSize_And_HierarchyIndent_ShareSameDP() + { + var tv = new TableView(); + tv.HierarchyIndent = 24d; + Assert.AreEqual(24d, tv.IndentSize); + tv.IndentSize = 8d; + Assert.AreEqual(8d, tv.HierarchyIndent); + } + + // ────────────────────────────────── HasChildItems ───────────────────── + + [UITestMethod] + public void HasChildItems_ReturnsFalse_WhenHierarchyDisabled() + { + var tv = new TableView { ChildrenPath = "Children" }; + var item = new TreeNode("A", new TreeNode("B")); + Assert.IsFalse(tv.HasChildItems(item)); + } + + [UITestMethod] + public void HasChildItems_ReturnsFalse_ForNull() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + Assert.IsFalse(tv.HasChildItems(null)); + } + + [UITestMethod] + public void HasChildItems_ReturnsTrue_WhenChildrenExist() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("Parent", new TreeNode("Child")); + Assert.IsTrue(tv.HasChildItems(item)); + } + + [UITestMethod] + public void HasChildItems_ReturnsFalse_WhenNoChildren() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("Leaf"); + Assert.IsFalse(tv.HasChildItems(item)); + } + + [UITestMethod] + public void HasChildItems_UsesHasChildrenPath_WhenSet() + { + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + HasChildrenPath = "HasChildren" + }; + var withFlag = new TreeNodeWithFlag("A", hasChildren: true); + var withoutFlag = new TreeNodeWithFlag("B", hasChildren: false); + Assert.IsTrue(tv.HasChildItems(withFlag)); + Assert.IsFalse(tv.HasChildItems(withoutFlag)); + } + + [UITestMethod] + public void HasChildItems_WorksWithChildrenSelector() + { + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenSelector = item => item is TreeNode n ? n.Children : null + }; + Assert.IsTrue(tv.HasChildItems(new TreeNode("P", new TreeNode("C")))); + Assert.IsFalse(tv.HasChildItems(new TreeNode("Leaf"))); + } + + // ────────────────────────────────── IsItemExpanded ──────────────────── + + [UITestMethod] + public void IsItemExpanded_ReturnsFalse_WhenHierarchyDisabled() + { + var tv = new TableView { ChildrenPath = "Children" }; + var item = new TreeNode("A", new TreeNode("B")); + Assert.IsFalse(tv.IsItemExpanded(item)); + } + + [UITestMethod] + public void IsItemExpanded_ReturnsFalse_ForNull() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + Assert.IsFalse(tv.IsItemExpanded(null)); + } + + [UITestMethod] + public void IsItemExpanded_ReturnsFalse_ForLeafNode() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + Assert.IsFalse(tv.IsItemExpanded(new TreeNode("Leaf"))); + } + + [UITestMethod] + public void IsItemExpanded_ReturnsTrue_ByDefault_ForParentNode() + { + // Items not in _collapsedHierarchyItems are considered expanded + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("P", new TreeNode("C")); + Assert.IsTrue(tv.IsItemExpanded(item)); + } + + [UITestMethod] + public void IsItemExpanded_ReturnsFalse_AfterCollapsing() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("P", new TreeNode("C")); + tv.CollapseItem(item); + Assert.IsFalse(tv.IsItemExpanded(item)); + } + + [UITestMethod] + public void IsItemExpanded_UsesIsExpandedPath_WhenSet() + { + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + IsExpandedPath = "IsExpanded" + }; + var expanded = new ExpandableNode("P", isExpanded: true, new TreeNode("C")); + var collapsed = new ExpandableNode("Q", isExpanded: false, new TreeNode("C")); + Assert.IsTrue(tv.IsItemExpanded(expanded)); + Assert.IsFalse(tv.IsItemExpanded(collapsed)); + } + + // ─────────────────────────────── SetItemExpanded ────────────────────── + + [UITestMethod] + public void SetItemExpanded_True_FiresRowExpandedEvent() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("P", new TreeNode("C")); + + // Collapse first so we can expand + tv.CollapseItem(item); + + TableViewRowExpansionChangedEventArgs? args = null; + tv.RowExpanded += (_, e) => args = e; + tv.SetItemExpanded(item, true); + + Assert.IsNotNull(args); + Assert.AreEqual(item, args.Item); + Assert.IsTrue(args.IsExpanded); + } + + [UITestMethod] + public void SetItemExpanded_False_FiresRowCollapsedEvent() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("P", new TreeNode("C")); + + TableViewRowExpansionChangedEventArgs? args = null; + tv.RowCollapsed += (_, e) => args = e; + tv.SetItemExpanded(item, false); + + Assert.IsNotNull(args); + Assert.AreEqual(item, args.Item); + Assert.IsFalse(args.IsExpanded); + } + + [UITestMethod] + public void SetItemExpanded_DoesNothing_ForLeafNode() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("Leaf"); + + bool eventFired = false; + tv.RowCollapsed += (_, _) => eventFired = true; + tv.RowExpanded += (_, _) => eventFired = true; + + tv.SetItemExpanded(item, false); + tv.SetItemExpanded(item, true); + + Assert.IsFalse(eventFired); + } + + [UITestMethod] + public void SetItemExpanded_WritesIsExpandedPath_WhenSet() + { + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + IsExpandedPath = "IsExpanded" + }; + var item = new ExpandableNode("P", isExpanded: true, new TreeNode("C")); + tv.SetItemExpanded(item, false); + Assert.IsFalse(item.IsExpanded); + } + + // ──────────────────────────── ToggleItemExpansion ───────────────────── + + [UITestMethod] + public void ToggleItemExpansion_ExpandsCollapsedNode() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("P", new TreeNode("C")); + tv.CollapseItem(item); + + tv.ToggleItemExpansion(item); + + Assert.IsTrue(tv.IsItemExpanded(item)); + } + + [UITestMethod] + public void ToggleItemExpansion_CollapsesExpandedNode() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + var item = new TreeNode("P", new TreeNode("C")); + + tv.ToggleItemExpansion(item); + + Assert.IsFalse(tv.IsItemExpanded(item)); + } + + [UITestMethod] + public void ToggleItemExpansion_DoesNothing_ForNull() + { + var tv = new TableView { IsHierarchicalEnabled = true, ChildrenPath = "Children" }; + tv.ToggleItemExpansion(null); // should not throw + } + + // ──────────────────────── ExpandItem / CollapseItem ─────────────────── + + [UITestMethod] + public void ExpandItem_ExpandsCollapsedNode() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.SetItemExpanded(root, false); + Assert.IsFalse(tv.Items.Contains(child)); + + tv.ExpandItem(root); + + Assert.IsTrue(tv.IsItemExpanded(root)); + Assert.IsTrue(tv.Items.Contains(child)); + } + + [UITestMethod] + public void CollapseItem_CollapsesExpandedNode() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + Assert.IsTrue(tv.Items.Contains(child)); + + tv.CollapseItem(root); + + Assert.IsFalse(tv.IsItemExpanded(root)); + Assert.IsFalse(tv.Items.Contains(child)); + } + + // ──────────────────────────── ExpandAll / CollapseAll ───────────────── + + [UITestMethod] + public void ExpandAll_ExpandsAllCollapsedNodes() + { + var grandchild = new TreeNode("Grandchild"); + var child = new TreeNode("Child", grandchild); + var root = new TreeNode("Root", child); + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.SetItemExpanded(root, false); + tv.SetItemExpanded(child, false); + Assert.AreEqual(1, tv.Items.Count); + + tv.ExpandAll(); + + Assert.AreEqual(3, tv.Items.Count); + Assert.IsTrue(tv.Items.Contains(child)); + Assert.IsTrue(tv.Items.Contains(grandchild)); + } + + [UITestMethod] + public void CollapseAll_CollapsesAllExpandedNodes() + { + var grandchild = new TreeNode("Grandchild"); + var child = new TreeNode("Child", grandchild); + var root = new TreeNode("Root", child); + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + Assert.AreEqual(3, tv.Items.Count); + + tv.CollapseAll(); + + Assert.AreEqual(1, tv.Items.Count); + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsFalse(tv.Items.Contains(child)); + } + + [UITestMethod] + public void ExpandAll_DoesNothing_WhenHierarchyDisabled() + { + var tv = new TableView { IsHierarchicalEnabled = false }; + tv.ExpandAll(); // should not throw + } + + [UITestMethod] + public void CollapseAll_DoesNothing_WhenHierarchyDisabled() + { + var tv = new TableView { IsHierarchicalEnabled = false }; + tv.CollapseAll(); // should not throw + } + + // ────────────────────────────── GetHierarchyLevel ───────────────────── + + [UITestMethod] + public void GetHierarchyLevel_ReturnsZero_ForUnknownItem() + { + var tv = new TableView(); + Assert.AreEqual(0, tv.GetHierarchyLevel(new TreeNode("X"))); + } + + [UITestMethod] + public void GetHierarchyLevel_ReturnsZero_ForNull() + { + var tv = new TableView(); + Assert.AreEqual(0, tv.GetHierarchyLevel(null)); + } + + [UITestMethod] + public void GetHierarchyLevel_Returns_Correct_Levels_After_BuildProcessedSource() + { + var child1 = new TreeNode("C1"); + var child2 = new TreeNode("C2"); + var grandchild = new TreeNode("GC"); + child1 = new TreeNode("C1", grandchild); + var root = new TreeNode("Root", child1, child2); + var items = new ObservableCollection { root }; + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = items + }; + + Assert.AreEqual(0, tv.GetHierarchyLevel(root)); + Assert.AreEqual(1, tv.GetHierarchyLevel(child1)); + Assert.AreEqual(1, tv.GetHierarchyLevel(child2)); + Assert.AreEqual(2, tv.GetHierarchyLevel(grandchild)); + } + + // ─────────────────── Visible items reflect expand/collapse state ────── + + [UITestMethod] + public void CollapsedItemsChildren_AreNotInDisplayedItems_AfterSourceSet() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + var items = new ObservableCollection { root }; + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = items + }; + + // Root is expanded by default — child should be in Items + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsTrue(tv.Items.Contains(child)); + + // Collapse root + tv.SetItemExpanded(root, false); + + // After collapse, child should not be visible + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsFalse(tv.Items.Contains(child)); + } + + [UITestMethod] + public void ExpandingItem_AddsChildrenToDisplayedItems() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + var items = new ObservableCollection { root }; + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = items + }; + + // Collapse then re-expand + tv.SetItemExpanded(root, false); + Assert.IsFalse(tv.Items.Contains(child)); + + tv.SetItemExpanded(root, true); + Assert.IsTrue(tv.Items.Contains(child)); + } + + // ─────────────────────── ChildrenSelector alternative ───────────────── + + [UITestMethod] + public void ChildrenSelector_WorksAsAlternative_ToChildrenPath() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + var items = new ObservableCollection { root }; + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenSelector = item => item is TreeNode n ? n.Children : null, + ItemsSource = items + }; + + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsTrue(tv.Items.Contains(child)); + Assert.AreEqual(0, tv.GetHierarchyLevel(root)); + Assert.AreEqual(1, tv.GetHierarchyLevel(child)); + } + + // ───────────────────── Dynamic source mutations ──────────────────────── + + [UITestMethod] + public void AddingRootItem_ToSource_UpdatesDisplayedItems() + { + var items = new ObservableCollection(); + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = items + }; + + Assert.AreEqual(0, tv.Items.Count); + + var root = new TreeNode("Root"); + items.Add(root); + tv.RefreshView(); // source mutation triggers async queue; flush synchronously + + Assert.IsTrue(tv.Items.Contains(root)); + } + + [UITestMethod] + public void RemovingRootItem_FromSource_UpdatesDisplayedItems() + { + var root = new TreeNode("Root"); + var items = new ObservableCollection { root }; + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = items + }; + + Assert.IsTrue(tv.Items.Contains(root)); + + items.Remove(root); + tv.RefreshView(); // source mutation triggers async queue; flush synchronously + + Assert.IsFalse(tv.Items.Contains(root)); + } + + [UITestMethod] + public void AddingExpandedParent_WithChildren_ToSource_ShowsAllNodes() + { + var items = new ObservableCollection(); + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = items + }; + + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + items.Add(root); + tv.RefreshView(); // source mutation triggers async queue; flush synchronously + + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsTrue(tv.Items.Contains(child)); + } + + [UITestMethod] + public void DynamicSource_NotObserved_WhenHierarchyDisabled() + { + var items = new ObservableCollection(); + var tv = new TableView + { + IsHierarchicalEnabled = false, + ChildrenPath = "Children", + ItemsSource = items + }; + + var root = new TreeNode("Root"); + items.Add(root); + tv.RefreshView(); // source mutation triggers async queue; flush synchronously + + // Non-hierarchy mode goes through _collectionView which watches the source directly + Assert.IsTrue(tv.Items.Contains(root)); + } + + // ─────────────────────────────── Filtering ──────────────────────────── + + [UITestMethod] + public void Filter_MatchingLeaf_ShowsLeafAndAllAncestors() + { + // Root → Parent → Child ("Child" matches filter) + var child = new TreeNode("Child"); + var parent = new TreeNode("Parent", child); + var root = new TreeNode("Root", parent); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), item => + item is TreeNode n && n.Name == "Child")); + + Assert.IsTrue(tv.Items.Contains(root), "Root (ancestor) should be visible"); + Assert.IsTrue(tv.Items.Contains(parent), "Parent (ancestor) should be visible"); + Assert.IsTrue(tv.Items.Contains(child), "Matching leaf should be visible"); + } + + [UITestMethod] + public void Filter_NonMatchingSubtree_IsHidden() + { + var matchingChild = new TreeNode("Match"); + var matchingParent = new TreeNode("MatchParent", matchingChild); + var otherParent = new TreeNode("OtherParent", new TreeNode("OtherChild")); + var root = new TreeNode("Root", matchingParent, otherParent); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), item => + item is TreeNode n && n.Name == "Match")); + + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsTrue(tv.Items.Contains(matchingParent)); + Assert.IsTrue(tv.Items.Contains(matchingChild)); + Assert.IsFalse(tv.Items.Contains(otherParent), "Subtree with no match should be hidden"); + } + + [UITestMethod] + public void Filter_ClearingFilter_RestoresAllItems() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), item => + item is TreeNode n && n.Name == "Child")); + + Assert.AreEqual(2, tv.Items.Count); + + tv.FilterDescriptions.Clear(); + + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsTrue(tv.Items.Contains(child)); + } + + [UITestMethod] + public void Filter_CollapsedAncestor_IsForceExpandedToShowMatch() + { + var child = new TreeNode("Match"); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + // Collapse root before applying filter + tv.SetItemExpanded(root, false); + Assert.AreEqual(1, tv.Items.Count, "Only root visible when collapsed"); + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), item => + item is TreeNode n && n.Name == "Match")); + + Assert.IsTrue(tv.Items.Contains(root), "Collapsed ancestor should appear"); + Assert.IsTrue(tv.Items.Contains(child), "Matching child should be force-expanded into view"); + } + + [UITestMethod] + public void Filter_DirectlyMatchingParent_ShowsWithChildren_WhenExpanded() + { + // Both parent and child have the same name — both pass the filter independently. + var matchingChild = new TreeNode("Match"); + var root = new TreeNode("Match", matchingChild); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), item => + item is TreeNode n && n.Name == "Match")); + + // Both root and matchingChild pass the filter directly; both are visible. + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsTrue(tv.Items.Contains(matchingChild)); + } + + [UITestMethod] + public void Filter_DirectlyMatchingParent_CollapsedShowsOnlyParent() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Match", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.SetItemExpanded(root, false); + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), item => + item is TreeNode n && n.Name == "Match")); + + Assert.IsTrue(tv.Items.Contains(root)); + // root matches directly and is collapsed — children not force-expanded + Assert.IsFalse(tv.Items.Contains(child)); + } + + [UITestMethod] + public void Filter_NoMatch_ShowsEmptyList() + { + var root = new TreeNode("Root", new TreeNode("Child")); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), item => + item is TreeNode n && n.Name == "NoSuchNode")); + + Assert.AreEqual(0, tv.Items.Count); + } + + [UITestMethod] + public void Filter_GetAllHierarchyItemsFlat_ReturnsAllNodesIncludingCollapsed() + { + var grandchild = new TreeNode("Grandchild"); + var child = new TreeNode("Child", grandchild); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + // Collapse root so grandchild is hidden from the display + tv.SetItemExpanded(root, false); + Assert.AreEqual(1, tv.Items.Count); + + var all = tv.GetAllHierarchyItemsFlat().ToList(); + Assert.AreEqual(3, all.Count); + Assert.IsTrue(all.Contains(root)); + Assert.IsTrue(all.Contains(child)); + Assert.IsTrue(all.Contains(grandchild)); + } + + [UITestMethod] + public void Filter_MultipleDescriptions_AndsLogic() + { + // Both filters must pass; only "AB" contains both "A" and "B". + var nodeAB = new TreeNode("AB"); + var nodeA = new TreeNode("A"); + var nodeB = new TreeNode("B"); + var nodeC = new TreeNode("C"); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { nodeC, nodeB, nodeA, nodeAB } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), + item => item is TreeNode n && n.Name.Contains("A"))); + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), + item => item is TreeNode n && n.Name.Contains("B"))); + + Assert.AreEqual(1, tv.Items.Count); + Assert.IsTrue(tv.Items.Contains(nodeAB)); + } + + [UITestMethod] + public void ClearAllFilters_InHierarchyMode_RemovesFilter() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + // Filter to only root; child doesn't match and has no matching descendants. + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), + item => item is TreeNode n && n.Name == "Root")); + + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsFalse(tv.Items.Contains(child)); + + tv.ClearAllFilters(); + + Assert.AreEqual(2, tv.Items.Count); + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsTrue(tv.Items.Contains(child)); + } + + // ─────────────────────────────── Sorting ────────────────────────────── + + [UITestMethod] + public void Sort_Ascending_OrdersRootItemsByName() + { + var rootC = new TreeNode("C"); + var rootA = new TreeNode("A"); + var rootB = new TreeNode("B"); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { rootC, rootA, rootB } + }; + + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Ascending)); + + var names = tv.Items.OfType().Select(x => x.Name).ToList(); + CollectionAssert.AreEqual(new List { "A", "B", "C" }, names); + } + + [UITestMethod] + public void Sort_Descending_OrdersRootItemsByName() + { + var rootC = new TreeNode("C"); + var rootA = new TreeNode("A"); + var rootB = new TreeNode("B"); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { rootA, rootC, rootB } + }; + + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Descending)); + + var names = tv.Items.OfType().Select(x => x.Name).ToList(); + CollectionAssert.AreEqual(new List { "C", "B", "A" }, names); + } + + [UITestMethod] + public void Sort_AppliedPerLevel_NotGlobally() + { + // Children of each parent are sorted within their own sibling group, + // not mixed together with children of other parents. + var childZA = new TreeNode("Z_of_A"); + var childAA = new TreeNode("A_of_A"); + var parentA = new TreeNode("ParentA", childZA, childAA); + + var childZB = new TreeNode("Z_of_B"); + var childAB = new TreeNode("A_of_B"); + var parentB = new TreeNode("ParentB", childZB, childAB); + + // Roots inserted in reverse order to also verify root-level sorting. + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { parentB, parentA } + }; + + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Ascending)); + + // Roots sorted: ParentA < ParentB. + // Children of ParentA sorted independently: A_of_A < Z_of_A. + // Children of ParentB sorted independently: A_of_B < Z_of_B. + var names = tv.Items.OfType().Select(x => x.Name).ToList(); + CollectionAssert.AreEqual( + new List { "ParentA", "A_of_A", "Z_of_A", "ParentB", "A_of_B", "Z_of_B" }, + names); + } + + [UITestMethod] + public void Sort_PreservesHierarchyLevels() + { + var childZ = new TreeNode("Z"); + var childA = new TreeNode("A"); + var root = new TreeNode("Root", childZ, childA); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Ascending)); + + Assert.AreEqual(0, tv.GetHierarchyLevel(root)); + Assert.AreEqual(1, tv.GetHierarchyLevel(childA)); + Assert.AreEqual(1, tv.GetHierarchyLevel(childZ)); + } + + [UITestMethod] + public void ClearAllSorting_InHierarchyMode_RestoresInsertionOrder() + { + var rootC = new TreeNode("C"); + var rootA = new TreeNode("A"); + var rootB = new TreeNode("B"); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { rootC, rootA, rootB } + }; + + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Ascending)); + CollectionAssert.AreEqual( + new List { "A", "B", "C" }, + tv.Items.OfType().Select(x => x.Name).ToList()); + + tv.ClearAllSorting(); + + // Original insertion order restored: C, A, B + CollectionAssert.AreEqual( + new List { "C", "A", "B" }, + tv.Items.OfType().Select(x => x.Name).ToList()); + } + + [UITestMethod] + public void Sort_WithFilter_BothApplied() + { + // Filter excludes one root; the remaining roots should be sorted. + var nodeExcluded = new TreeNode("Excluded"); + var nodeC = new TreeNode("C"); + var nodeA = new TreeNode("A"); + var nodeB = new TreeNode("B"); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { nodeExcluded, nodeC, nodeA, nodeB } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), + item => item is TreeNode n && n.Name != "Excluded")); + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Ascending)); + + var names = tv.Items.OfType().Select(x => x.Name).ToList(); + CollectionAssert.AreEqual(new List { "A", "B", "C" }, names); + } + + [UITestMethod] + public void Sort_WithChildrenSelector_WorksCorrectly() + { + var childZ = new TreeNode("Z"); + var childA = new TreeNode("A"); + var root = new TreeNode("Root"); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenSelector = item => item is TreeNode n && n.Name == "Root" + ? new TreeNode[] { childZ, childA } + : System.Array.Empty(), + ItemsSource = new List { root } + }; + + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Ascending)); + + // root's children [childZ, childA] should be sorted ascending to [childA, childZ] + var names = tv.Items.OfType().Select(x => x.Name).ToList(); + CollectionAssert.AreEqual(new List { "Root", "A", "Z" }, names); + } + + // ─────────────────── Multi-level and toggle scenarios ────────────────── + + [UITestMethod] + public void MultiLevel_CollapsingRoot_HidesAllDescendants() + { + // 3-level hierarchy: root → child → grandchild + var grandchild = new TreeNode("Grandchild"); + var child = new TreeNode("Child", grandchild); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + Assert.AreEqual(3, tv.Items.Count); + + tv.SetItemExpanded(root, false); + + // Collapsing root hides child AND grandchild + Assert.AreEqual(1, tv.Items.Count); + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsFalse(tv.Items.Contains(child)); + Assert.IsFalse(tv.Items.Contains(grandchild)); + } + + [UITestMethod] + public void DisablingHierarchy_AfterEnable_ResetsToFlatView() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + Assert.AreEqual(2, tv.Items.Count); + + tv.IsHierarchicalEnabled = false; + + // Flat mode: only top-level source items; child lives inside root.Children, not the source. + Assert.AreEqual(1, tv.Items.Count); + Assert.IsTrue(tv.Items.Contains(root)); + Assert.IsFalse(tv.Items.Contains(child)); + } + + // ──────────────────── Edge Cases / Null and Empty ───────────────────── + + [UITestMethod] + public void EmptySource_InHierarchyMode_ShowsNothing() + { + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List() + }; + + Assert.AreEqual(0, tv.Items.Count); + } + + [UITestMethod] + public void ChildrenPropertyNull_TreatedAsLeaf() + { + var item = new NullableChildrenNode("Leaf"); // Children property = null + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { item } + }; + + Assert.AreEqual(1, tv.Items.Count); + Assert.IsFalse(tv.HasChildItems(item)); + } + + [UITestMethod] + public void ChildrenSelector_ReturningEmpty_TreatedAsLeaf() + { + var item = new TreeNode("A"); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenSelector = _ => System.Array.Empty(), + ItemsSource = new List { item } + }; + + Assert.AreEqual(1, tv.Items.Count); + Assert.IsFalse(tv.HasChildItems(item)); + } + + [UITestMethod] + public void HasChildItems_ReturnsFalse_WhenHasChildrenPathReturnsFalse_EvenWithChildren() + { + // HasChildrenPath flag = false should override the actual children collection. + var item = new TreeNodeWithFlag("Parent", hasChildren: false); + item.Children.Add(new TreeNode("ShouldNotAppear")); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + HasChildrenPath = nameof(TreeNodeWithFlag.HasChildren), + ItemsSource = new List { item } + }; + + Assert.IsFalse(tv.HasChildItems(item)); + Assert.AreEqual(1, tv.Items.Count, "Child should not appear when HasChildrenPath returns false"); + } + + // ──────────────────────── Circular Reference ────────────────────────── + + [UITestMethod] + public void CircularReference_DoesNotHang() + { + var node = new TreeNode("A"); + node.Children.Add(node); // self-referential circular node + + // FlattenHierarchy has a path-based cycle guard; this must not hang. + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { node } + }; + + Assert.IsTrue(tv.Items.Count >= 1, "Node should appear at least once; cycle guard stops infinite recursion"); + } + + // ──────────────────── IsExpandedPath initial state ──────────────────── + + [UITestMethod] + public void IsExpandedPath_InitiallyFalse_ItemStartsCollapsed() + { + var child = new TreeNode("Child"); + var parent = new ExpandableNode("Parent", isExpanded: false, child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + IsExpandedPath = nameof(ExpandableNode.IsExpanded), + ItemsSource = new List { parent } + }; + + Assert.AreEqual(1, tv.Items.Count, "Parent with IsExpanded=false should start collapsed"); + Assert.IsFalse(tv.IsItemExpanded(parent)); + Assert.IsFalse(tv.Items.Contains(child)); + } + + // ──────────────────────── 3-Level Hierarchy ─────────────────────────── + + [UITestMethod] + public void DeepNesting_3Levels_AllVisible_WhenAllExpanded() + { + var grandchild = new TreeNode("GC"); + var child = new TreeNode("C", grandchild); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + // Depth-first order: root → child → grandchild + Assert.AreEqual(3, tv.Items.Count); + Assert.AreSame(root, tv.Items[0]); + Assert.AreSame(child, tv.Items[1]); + Assert.AreSame(grandchild, tv.Items[2]); + } + + [UITestMethod] + public void GetHierarchyLevel_ReturnsCorrectLevels_For3LevelHierarchy() + { + var grandchild = new TreeNode("GC"); + var child = new TreeNode("C", grandchild); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + Assert.AreEqual(0, tv.GetHierarchyLevel(root)); + Assert.AreEqual(1, tv.GetHierarchyLevel(child)); + Assert.AreEqual(2, tv.GetHierarchyLevel(grandchild)); + } + + [UITestMethod] + public void GetHierarchyLevel_ReturnsZero_ForCollapsedItem_NotInDisplay() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + Assert.AreEqual(1, tv.GetHierarchyLevel(child)); // visible → level 1 + + tv.SetItemExpanded(root, false); // collapse; child removed from the level map + + Assert.AreEqual(0, tv.GetHierarchyLevel(child), "Collapsed item not in display map; default level is 0"); + } + + // ──────────────── Dynamic Nested ObservableCollection ───────────────── + + [UITestMethod] + public void DynamicChild_AddToNestedObservableCollection_UpdatesDisplay() + { + var parent = new ObservableTreeNode("Parent"); + var source = new ObservableCollection { parent }; + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = source + }; + + Assert.AreEqual(1, tv.Items.Count); // only parent, no children yet + + parent.Children.Add(new ObservableTreeNode("NewChild")); + tv.RefreshView(); // flush async rebuild + + Assert.AreEqual(2, tv.Items.Count); + Assert.AreEqual("NewChild", ((ObservableTreeNode)tv.Items[1]).Name); + } + + [UITestMethod] + public void DynamicChild_RemoveFromNestedObservableCollection_UpdatesDisplay() + { + var child = new ObservableTreeNode("Child"); + var parent = new ObservableTreeNode("Parent"); + parent.Children.Add(child); + + var source = new ObservableCollection { parent }; + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = source + }; + + Assert.AreEqual(2, tv.Items.Count); // parent + child + + parent.Children.Remove(child); + tv.RefreshView(); + + Assert.AreEqual(1, tv.Items.Count); + Assert.AreSame(parent, tv.Items[0]); + } + + // ──────────────────────── Filter extended ───────────────────────────── + + [UITestMethod] + public void Filter_ForceExpand_DoesNotPermanentlyExpandCollapsedParent() + { + var child = new TreeNode("FilterTarget"); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.SetItemExpanded(root, false); + Assert.AreEqual(1, tv.Items.Count); // only root (collapsed) + + // Filter force-expands root because child matches. + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), + x => x is TreeNode n && n.Name == "FilterTarget")); + + Assert.AreEqual(2, tv.Items.Count, "Force-expand: matching child must be visible"); + Assert.IsTrue(tv.Items.Contains(child)); + + // Clearing the filter must restore the manually-collapsed state. + tv.ClearAllFilters(); + + Assert.AreEqual(1, tv.Items.Count, "Root should be collapsed again after filter is cleared"); + Assert.IsFalse(tv.Items.Contains(child)); + } + + [UITestMethod] + public void Filter_3Level_GrandchildMatch_ShowsEntireAncestorChain() + { + var grandchild = new TreeNode("Target"); + var child = new TreeNode("Mid", grandchild); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.FilterDescriptions.Add(new FilterDescription(nameof(TreeNode.Name), + x => x is TreeNode n && n.Name == "Target")); + + Assert.AreEqual(3, tv.Items.Count); + Assert.IsTrue(tv.Items.Contains(root), "Root (ancestor) must be force-shown"); + Assert.IsTrue(tv.Items.Contains(child), "Mid (ancestor) must be force-shown"); + Assert.IsTrue(tv.Items.Contains(grandchild), "Matching grandchild must be visible"); + } + + // ──────────────────────── Sort extended ─────────────────────────────── + + [UITestMethod] + public void Sort_CollapseAndReExpand_ChildrenRemainSorted() + { + var b = new TreeNode("B"); + var a = new TreeNode("A"); + var root = new TreeNode("Root", b, a); // inserted B before A + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + tv.SortDescriptions.Add(new SortDescription(nameof(TreeNode.Name), SortDirection.Ascending)); + + // After sort: Root[0], A[1], B[2] + Assert.AreSame(a, tv.Items[1]); + Assert.AreSame(b, tv.Items[2]); + + tv.SetItemExpanded(root, false); + Assert.AreEqual(1, tv.Items.Count); + + tv.SetItemExpanded(root, true); + + // Sort must be reapplied on re-expand. + Assert.AreEqual(3, tv.Items.Count); + Assert.AreSame(a, tv.Items[1], "Sort order must be preserved after collapse+re-expand"); + Assert.AreSame(b, tv.Items[2]); + } + + // ─────────────────────── Re-enabling Hierarchy ──────────────────────── + + [UITestMethod] + public void ReEnablingHierarchy_AfterDisable_ShowsExpandedTree() + { + var child = new TreeNode("Child"); + var root = new TreeNode("Root", child); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new List { root } + }; + + Assert.AreEqual(2, tv.Items.Count); + + tv.IsHierarchicalEnabled = false; + Assert.AreEqual(1, tv.Items.Count, "Flat mode: only root-level source items"); + + tv.IsHierarchicalEnabled = true; + Assert.AreEqual(2, tv.Items.Count, "Hierarchy restored: root + child visible"); + Assert.IsTrue(tv.Items.Contains(child)); + } + + // ─────────────── Reset action cleans up collapsed state ─────────────── + + [UITestMethod] + public void SourceReset_ClearsCollapsedState_AndRebuildsView() + { + var child = new ObservableTreeNode("Child"); + var root = new ObservableTreeNode("Root"); + root.Children.Add(child); + var source = new ObservableCollection { root }; + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = source + }; + + Assert.AreEqual(2, tv.Items.Count); + + tv.SetItemExpanded(root, false); + Assert.AreEqual(1, tv.Items.Count, "Root is collapsed"); + + // Replacing source via Clear+Add simulates a Reset via CollectionChanged. + // After Clear the new root is expanded by default so child should be visible. + var newChild = new ObservableTreeNode("NewChild"); + var newRoot = new ObservableTreeNode("NewRoot"); + newRoot.Children.Add(newChild); + source.Clear(); + source.Add(newRoot); + tv.RefreshView(); + + Assert.AreEqual(2, tv.Items.Count, "New root must be expanded (stale collapsed state cleared)"); + Assert.AreSame(newRoot, tv.Items[0]); + Assert.AreSame(newChild, tv.Items[1]); + } + + // ───────── Dynamically added item's children collection subscribed ───── + + [UITestMethod] + public void DynamicAdd_NewItemWithChildren_ChildCollectionChangesAreObserved() + { + var source = new ObservableCollection(); + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = source + }; + + Assert.AreEqual(0, tv.Items.Count); + + // Add a root with a pre-existing child. + var newRoot = new ObservableTreeNode("NewRoot"); + var existingChild = new ObservableTreeNode("Existing"); + newRoot.Children.Add(existingChild); + source.Add(newRoot); + tv.RefreshView(); + + Assert.AreEqual(2, tv.Items.Count, "NewRoot + Existing child"); + + // Now add a child to the newly-added root's collection. + // The subscription fix means this change should be detected. + var laterChild = new ObservableTreeNode("Later"); + newRoot.Children.Add(laterChild); + tv.RefreshView(); + + Assert.AreEqual(3, tv.Items.Count, "NewRoot + Existing + Later"); + Assert.IsTrue(tv.Items.Contains(laterChild), "Child added after root insertion must be visible"); + } + + // ─────────────────────────── Circular ref in flat enumeration ───────── + + [UITestMethod] + public void GetAllHierarchyItemsFlat_WithCircularRef_DoesNotStackOverflow() + { + // Build a circular reference: A -> B -> A + var nodeA = new ObservableTreeNode("A"); + var nodeB = new ObservableTreeNode("B"); + nodeA.Children.Add(nodeB); + nodeB.Children.Add(nodeA); // circular! + + var tv = new TableView + { + IsHierarchicalEnabled = true, + ChildrenPath = "Children", + ItemsSource = new ObservableCollection { nodeA } + }; + + // Should not stack overflow — the fix adds cycle detection to GetAllItemsFlatRecursive. + var allItems = tv.GetAllHierarchyItemsFlat().ToList(); + + Assert.IsTrue(allItems.Count >= 2, "A and B should both appear"); + Assert.IsTrue(allItems.Contains(nodeA)); + Assert.IsTrue(allItems.Contains(nodeB)); + } +} + +// ─────────────────────────────── Test helpers ───────────────────────────── + +internal class TreeNode +{ + public string Name { get; } + public List Children { get; } = []; + + public TreeNode(string name, params TreeNode[] children) + { + Name = name; + Children.AddRange(children); + } + + public override string ToString() => Name; +} + +internal class TreeNodeWithFlag +{ + public string Name { get; } + public bool HasChildren { get; } + public List Children { get; } = []; + + public TreeNodeWithFlag(string name, bool hasChildren) + { + Name = name; + HasChildren = hasChildren; + } +} + +internal class ExpandableNode : INotifyPropertyChanged +{ + private bool _isExpanded; + + public string Name { get; } + public List Children { get; } = []; + + public bool IsExpanded + { + get => _isExpanded; + set + { + if (_isExpanded != value) + { + _isExpanded = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded))); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public ExpandableNode(string name, bool isExpanded, params TreeNode[] children) + { + Name = name; + _isExpanded = isExpanded; + Children.AddRange(children); + } +} + +internal class NullableChildrenNode +{ + public string Name { get; } + public List? Children { get; } + + public NullableChildrenNode(string name, List? children = null) + { + Name = name; + Children = children; + } +} + +internal class ObservableTreeNode +{ + public string Name { get; } + public ObservableCollection Children { get; } = new(); + + public ObservableTreeNode(string name) + { + Name = name; + } + + public override string ToString() => Name; +}