diff --git a/src/Avalonia.Controls/IndexPath.cs b/src/Avalonia.Controls/IndexPath.cs new file mode 100644 index 0000000000..1251a022da --- /dev/null +++ b/src/Avalonia.Controls/IndexPath.cs @@ -0,0 +1,98 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls +{ + public sealed class IndexPath : IComparable + { + private readonly List _path = new List(); + + internal IndexPath(int index) + { + _path.Add(index); + } + + internal IndexPath(int groupIndex, int itemIndex) + { + _path.Add(groupIndex); + _path.Add(itemIndex); + } + + internal IndexPath(IEnumerable indices) + { + if (indices != null) + { + _path.AddRange(indices); + } + } + + public int GetSize() => _path.Count; + public int GetAt(int index) => _path[index]; + + public int CompareTo(IndexPath other) + { + var rhsPath = other; + int compareResult = 0; + int lhsCount = _path.Count; + int rhsCount = rhsPath._path.Count; + + if (lhsCount == 0 || rhsCount == 0) + { + // one of the paths are empty, compare based on size + compareResult = (lhsCount - rhsCount); + } + else + { + // both paths are non-empty, but can be of different size + for (int i = 0; i < Math.Min(lhsCount, rhsCount); i++) + { + if (_path[i] < rhsPath._path[i]) + { + compareResult = -1; + break; + } + else if (_path[i] > rhsPath._path[i]) + { + compareResult = 1; + break; + } + } + + // if both match upto min(lhsCount, rhsCount), compare based on size + compareResult = compareResult == 0 ? (lhsCount - rhsCount) : compareResult; + } + + if (compareResult != 0) + { + compareResult = compareResult > 0 ? 1 : -1; + } + + return compareResult; + } + + public IndexPath CloneWithChildIndex(int childIndex) + { + var newPath = new List(_path); + newPath.Add(childIndex); + return new IndexPath(newPath); + } + + public override string ToString() + { + return "R." + string.Join(".", _path); + } + + public static IndexPath CreateFrom(int index) => new IndexPath(index); + + public static IndexPath CreateFrom(int groupIndex, int itemIndex) => new IndexPath(groupIndex, itemIndex); + + public static IndexPath CreateFromIndices(IList indices) => new IndexPath(indices); + + } +} diff --git a/src/Avalonia.Controls/IndexRange.cs b/src/Avalonia.Controls/IndexRange.cs new file mode 100644 index 0000000000..f3820c087a --- /dev/null +++ b/src/Avalonia.Controls/IndexRange.cs @@ -0,0 +1,61 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls +{ + internal readonly struct IndexRange + { + public IndexRange(int begin, int end) + { + // Accept out of order begin/end pairs, just swap them. + if (begin > end) + { + int temp = begin; + begin = end; + end = temp; + } + + Begin = begin; + End = end; + } + + public int Begin { get; } + public int End { get; } + + public bool Contains(int index) + { + return index >= Begin && index <= End; + } + + public bool Split(int splitIndex, out IndexRange before, out IndexRange after) + { + bool afterIsValid; + + before = new IndexRange(Begin, splitIndex); + + if (splitIndex < End) + { + after = new IndexRange(splitIndex + 1, End); + afterIsValid = true; + } + else + { + after = new IndexRange(); + afterIsValid = false; + } + + return afterIsValid; + } + + public bool Intersects(IndexRange other) + { + return (Begin <= other.End) && (End >= other.Begin); + } + } +} diff --git a/src/Avalonia.Controls/SelectedItems.cs b/src/Avalonia.Controls/SelectedItems.cs new file mode 100644 index 0000000000..036788e7b2 --- /dev/null +++ b/src/Avalonia.Controls/SelectedItems.cs @@ -0,0 +1,55 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections; +using System.Collections.Generic; +using SelectedItemInfo = Avalonia.Controls.SelectionModel.SelectedItemInfo; + +namespace Avalonia.Controls +{ + internal class SelectedItems : IReadOnlyList + { + private readonly List _infos; + private readonly Func, int, T> _getAtImpl; + private int _totalCount; + + public SelectedItems( + List infos, + Func, int, T> getAtImpl) + { + _infos = infos; + _getAtImpl = getAtImpl; + + foreach (var info in infos) + { + var node = info.Node; + + if (node != null) + { + _totalCount += node.SelectedCount; + } + else + { + throw new InvalidOperationException("Selection changed after the SelectedIndices/Items property was read."); + } + } + } + + public T this[int index] => _getAtImpl(_infos, index); + + public int Count => _totalCount; + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < _totalCount; ++i) + { + yield return this[i]; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs new file mode 100644 index 0000000000..6b48e02a21 --- /dev/null +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -0,0 +1,710 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using Avalonia.Controls.Utils; + +namespace Avalonia.Controls +{ + public class SelectionModel : INotifyPropertyChanged, IDisposable + { + private SelectionNode _rootNode; + private bool _singleSelect; + private IReadOnlyList _selectedIndicesCached; + private IReadOnlyList _selectedItemsCached; + private SelectionModelChildrenRequestedEventArgs _childrenRequestedEventArgs; + private SelectionModelSelectionChangedEventArgs _selectionChangedEventArgs; + + public event EventHandler ChildrenRequested; + public event PropertyChangedEventHandler PropertyChanged; + public event EventHandler SelectionChanged; + + public SelectionModel() + { + _rootNode = new SelectionNode(this, null); + SharedLeafNode = new SelectionNode(this, null); + } + + public object Source + { + get => _rootNode.Source; + set + { + ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + _rootNode.Source = value; + OnSelectionChanged(); + RaisePropertyChanged("Source"); + } + } + + public bool SingleSelect + { + get => _singleSelect; + set + { + if (_singleSelect != value) + { + _singleSelect = value; + var selectedIndices = SelectedIndices; + + if (value && selectedIndices != null && selectedIndices.Count > 0) + { + // We want to be single select, so make sure there is only + // one selected item. + var firstSelectionIndexPath = selectedIndices[0]; + ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + SelectWithPathImpl(firstSelectionIndexPath, select: true, raiseSelectionChanged: false); + // Setting SelectedIndex will raise SelectionChanged event. + SelectedIndex = firstSelectionIndexPath; + } + + RaisePropertyChanged("SingleSelect"); + } + } + } + + + public IndexPath AnchorIndex + { + get + { + IndexPath anchor = null; + + if (_rootNode.AnchorIndex >= 0) + { + var path = new List(); + var current = _rootNode; + + while (current?.AnchorIndex >= 0) + { + path.Add(current.AnchorIndex); + current = current.GetAt(current.AnchorIndex, false); + } + + anchor = new IndexPath(path); + } + + return anchor; + } + set + { + if (value != null) + { + SelectionTreeHelper.TraverseIndexPath( + _rootNode, + value, + realizeChildren: true, + (currentNode, path, depth, childIndex) => currentNode.AnchorIndex = path.GetAt(depth)); + } + else + { + _rootNode.AnchorIndex = -1; + } + + RaisePropertyChanged("AnchorIndex"); + } + } + + public IndexPath SelectedIndex + { + get + { + IndexPath selectedIndex = null; + var selectedIndices = SelectedIndices; + + if (selectedIndices?.Count > 0) + { + selectedIndex = selectedIndices[0]; + } + + return selectedIndex; + } + set + { + var isSelected = IsSelectedAt(value); + + if (!isSelected.HasValue || !isSelected.Value) + { + ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + SelectWithPathImpl(value, select: true, raiseSelectionChanged: false); + OnSelectionChanged(); + } + } + } + + public object SelectedItem + { + get + { + object item = null; + var selectedItems = SelectedItems; + + if (selectedItems?.Count > 0) + { + item = selectedItems[0]; + } + + return item; + } + } + + public IReadOnlyList SelectedItems + { + get + { + if (_selectedItemsCached == null) + { + var selectedInfos = new List(); + + if (_rootNode.Source != null) + { + SelectionTreeHelper.Traverse( + _rootNode, + realizeChildren: false, + currentInfo => + { + if (currentInfo.Node.SelectedCount > 0) + { + selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); + } + }); + } + + // Instead of creating a dumb vector that takes up the space for all the selected items, + // we create a custom VectorView implimentation that calls back using a delegate to find + // the selected item at a particular index. This avoid having to create the storage and copying + // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an + // easier to consume flat vector view of objects. + var selectedItems = new SelectedItems ( + selectedInfos, + (infos, index) => + { + var currentIndex = 0; + object item = null; + + foreach (var info in infos) + { + var node = info.Node; + + if (node != null) + { + var currentCount = node.SelectedCount; + + if (index >= currentIndex && index < currentIndex + currentCount) + { + var targetIndex = node.SelectedIndices[index - currentIndex]; + item = node.ItemsSourceView.GetAt(targetIndex); + break; + } + + currentIndex += currentCount; + } + else + { + throw new InvalidOperationException( + "Selection has changed since SelectedItems property was read."); + } + } + + return item; + }); + + _selectedItemsCached = selectedItems; + } + + return _selectedItemsCached; + } + } + + public IReadOnlyList SelectedIndices + { + get + { + if (_selectedIndicesCached == null) + { + var selectedInfos = new List(); + SelectionTreeHelper.Traverse( + _rootNode, + false, + currentInfo => + { + if (currentInfo.Node.SelectedCount > 0) + { + selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); + } + }); + + // Instead of creating a dumb vector that takes up the space for all the selected indices, + // we create a custom VectorView implimentation that calls back using a delegate to find + // the IndexPath at a particular index. This avoid having to create the storage and copying + // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an + // easier to consume flat vector view of IndexPaths. + var indices = new SelectedItems( + selectedInfos, + (infos, index) => // callback for GetAt(index) + { + var currentIndex = 0; + IndexPath path = null; + + foreach (var info in infos) + { + var node = info.Node; + + if (node != null) + { + var currentCount = node.SelectedCount; + if (index >= currentIndex && index < currentIndex + currentCount) + { + int targetIndex = node.SelectedIndices[index - currentIndex]; + path = info.Path.CloneWithChildIndex(targetIndex); + break; + } + + currentIndex += currentCount; + } + else + { + throw new InvalidOperationException( + "Selection has changed since SelectedIndices property was read."); + } + } + + return path; + }); + + _selectedIndicesCached = indices; + } + + return _selectedIndicesCached; + } + } + + internal SelectionNode SharedLeafNode { get; private set; } + + public void Dispose() + { + ClearSelection(resetAnchor: false, raiseSelectionChanged: false); + _rootNode?.Dispose(); + _rootNode = null; + SharedLeafNode = null; + _selectedIndicesCached = null; + _selectedItemsCached = null; + } + + public void SetAnchorIndex(int index) => AnchorIndex = new IndexPath(index); + + public void SetAnchorIndex(int groupIndex, int index) => AnchorIndex = new IndexPath(groupIndex, index); + + public void Select(int index) => SelectImpl(index, select: true); + + public void Select(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: true); + + public void SelectAt(IndexPath index) => SelectWithPathImpl(index, select: true, raiseSelectionChanged: true); + + public void Deselect(int index) => SelectImpl(index, select: false); + + public void Deselect(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: false); + + public void DeselectAt(IndexPath index) => SelectWithPathImpl(index, select: false, raiseSelectionChanged: true); + + public bool? IsSelected(int index) + { + if (index < 0) + { + throw new ArgumentException("Index must be >= 0", nameof(index)); + } + + var isSelected = _rootNode.IsSelectedWithPartial(index); + return isSelected; + } + + public bool? IsSelected(int groupIndex, int itemIndex) + { + if (groupIndex < 0) + { + throw new ArgumentException("Group index must be >= 0", nameof(groupIndex)); + } + + if (itemIndex < 0) + { + throw new ArgumentException("Item index must be >= 0", nameof(itemIndex)); + } + + var isSelected = (bool?)false; + var childNode = _rootNode.GetAt(groupIndex, realizeChild: false); + + if (childNode != null) + { + isSelected = childNode.IsSelectedWithPartial(itemIndex); + } + + return isSelected; + } + + public bool? IsSelectedAt(IndexPath index) + { + var path = index; + var isRealized = true; + var node = _rootNode; + + for (int i = 0; i < path.GetSize() - 1; i++) + { + var childIndex = path.GetAt(i); + node = node.GetAt(childIndex, realizeChild: false); + + if (node == null) + { + isRealized = false; + break; + } + } + + var isSelected = (bool?)false; + + if (isRealized) + { + var size = path.GetSize(); + if (size == 0) + { + isSelected = SelectionNode.ConvertToNullableBool(node.EvaluateIsSelectedBasedOnChildrenNodes()); + } + else + { + isSelected = node.IsSelectedWithPartial(path.GetAt(size - 1)); + } + } + + return isSelected; + } + + public void SelectRangeFromAnchor(int index) + { + SelectRangeFromAnchorImpl(index, select: true); + } + + public void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex) + { + SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, select: true); + } + + public void SelectRangeFromAnchorTo(IndexPath index) + { + SelectRangeImpl(AnchorIndex, index, select: true); + } + + public void DeselectRangeFromAnchor(int index) + { + SelectRangeFromAnchorImpl(index, select: false); + } + + public void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex) + { + SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, false /* select */); + } + + public void DeselectRangeFromAnchorTo(IndexPath index) + { + SelectRangeImpl(AnchorIndex, index, select: false); + } + + public void SelectRange(IndexPath start, IndexPath end) + { + SelectRangeImpl(start, end, select: true); + } + + public void DeselectRange(IndexPath start, IndexPath end) + { + SelectRangeImpl(start, end, select: false); + } + + public void SelectAll() + { + SelectionTreeHelper.Traverse( + _rootNode, + realizeChildren: true, + info => + { + if (info.Node.DataCount > 0) + { + info.Node.SelectAll(); + } + }); + + OnSelectionChanged(); + } + + public void ClearSelection() + { + ClearSelection(resetAnchor: true, raiseSelectionChanged: true); + } + + protected void OnPropertyChanged(string propertyName) + { + RaisePropertyChanged(propertyName); + } + + private void RaisePropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public void OnSelectionInvalidatedDueToCollectionChange() + { + OnSelectionChanged(); + } + + internal object ResolvePath(object data, SelectionNode sourceNode) + { + object resolved = null; + + // Raise ChildrenRequested event if there is a handler + if (ChildrenRequested != null) + { + if (_childrenRequestedEventArgs == null) + { + _childrenRequestedEventArgs = new SelectionModelChildrenRequestedEventArgs(data, sourceNode); + } + else + { + _childrenRequestedEventArgs.Initialize(data, sourceNode); + } + + ChildrenRequested(this, _childrenRequestedEventArgs); + resolved = _childrenRequestedEventArgs.Children; + + // Clear out the values in the args so that it cannot be used after the event handler call. + _childrenRequestedEventArgs.Initialize(null, null); + } + else + { + // No handlers for ChildrenRequested event. If data is of type ItemsSourceView + // or a type that can be used to create a ItemsSourceView, then we can auto-resolve + // that as the child. If not, then we consider the value as a leaf. This is to + // avoid having to provide the event handler for the most common scenarios. If the + // app dev does not want this default behavior, they can provide the handler to + // override. + if (data is IEnumerable) + { + resolved = data; + } + } + + return resolved; + } + + private void ClearSelection(bool resetAnchor, bool raiseSelectionChanged) + { + SelectionTreeHelper.Traverse( + _rootNode, + realizeChildren: false, + info => info.Node.Clear()); + + if (resetAnchor) + { + AnchorIndex = null; + } + + if (raiseSelectionChanged) + { + OnSelectionChanged(); + } + } + + private void OnSelectionChanged() + { + _selectedIndicesCached = null; + _selectedItemsCached = null; + + // Raise SelectionChanged event + if (SelectionChanged != null) + { + if (_selectionChangedEventArgs == null) + { + _selectionChangedEventArgs = new SelectionModelSelectionChangedEventArgs(); + } + + SelectionChanged(this, _selectionChangedEventArgs); + } + + RaisePropertyChanged(nameof(SelectedIndex)); + RaisePropertyChanged(nameof(SelectedIndices)); + + if (_rootNode.Source != null) + { + RaisePropertyChanged(nameof(SelectedItem)); + RaisePropertyChanged(nameof(SelectedItems)); + } + } + + private void SelectImpl(int index, bool select) + { + if (_singleSelect) + { + ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + } + + var selected = _rootNode.Select(index, select); + + if (selected) + { + AnchorIndex = new IndexPath(index); + } + + OnSelectionChanged(); + } + + private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select) + { + if (_singleSelect) + { + ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + } + + var childNode = _rootNode.GetAt(groupIndex, realizeChild: true); + var selected = childNode.Select(itemIndex, select); + + if (selected) + { + AnchorIndex = new IndexPath(groupIndex, itemIndex); + } + + OnSelectionChanged(); + } + + private void SelectWithPathImpl(IndexPath index, bool select, bool raiseSelectionChanged) + { + bool selected = false; + + if (_singleSelect) + { + ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + } + + SelectionTreeHelper.TraverseIndexPath( + _rootNode, + index, + true, + (currentNode, path, depth, childIndex) => + { + if (depth == path.GetSize() - 1) + { + selected = currentNode.Select(childIndex, select); + } + } + ); + + if (selected) + { + AnchorIndex = index; + } + + if (raiseSelectionChanged) + { + OnSelectionChanged(); + } + } + + private void SelectRangeFromAnchorImpl(int index, bool select) + { + int anchorIndex = 0; + var anchor = AnchorIndex; + + if (anchor != null) + { + anchorIndex = anchor.GetAt(0); + } + + bool selected = _rootNode.SelectRange(new IndexRange(anchorIndex, index), select); + + if (selected) + { + OnSelectionChanged(); + } + } + + private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select) + { + var startGroupIndex = 0; + var startItemIndex = 0; + var anchorIndex = AnchorIndex; + + if (anchorIndex != null) + { + startGroupIndex = anchorIndex.GetAt(0); + startItemIndex = anchorIndex.GetAt(1); + } + + // Make sure start > end + if (startGroupIndex > endGroupIndex || + (startGroupIndex == endGroupIndex && startItemIndex > endItemIndex)) + { + int temp = startGroupIndex; + startGroupIndex = endGroupIndex; + endGroupIndex = temp; + temp = startItemIndex; + startItemIndex = endItemIndex; + endItemIndex = temp; + } + + var selected = false; + for (int groupIdx = startGroupIndex; groupIdx <= endGroupIndex; groupIdx++) + { + var groupNode = _rootNode.GetAt(groupIdx, realizeChild: true); + int startIndex = groupIdx == startGroupIndex ? startItemIndex : 0; + int endIndex = groupIdx == endGroupIndex ? endItemIndex : groupNode.DataCount - 1; + selected |= groupNode.SelectRange(new IndexRange(startIndex, endIndex), select); + } + + if (selected) + { + OnSelectionChanged(); + } + } + + private void SelectRangeImpl(IndexPath start, IndexPath end, bool select) + { + var winrtStart = start; + var winrtEnd = end; + + // Make sure start <= end + if (winrtEnd.CompareTo(winrtStart) == -1) + { + var temp = winrtStart; + winrtStart = winrtEnd; + winrtEnd = temp; + } + + // Note: Since we do not know the depth of the tree, we have to walk to each leaf + SelectionTreeHelper.TraverseRangeRealizeChildren( + _rootNode, + winrtStart, + winrtEnd, + info => + { + if (info.Node.DataCount == 0) + { + // Select only leaf nodes + info.ParentNode.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); + } + }); + + OnSelectionChanged(); + } + + internal class SelectedItemInfo + { + public SelectedItemInfo(SelectionNode node, IndexPath path) + { + Node = node; + Path = path; + } + + public SelectionNode Node { get; } + public IndexPath Path { get; } + } + } +} diff --git a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs new file mode 100644 index 0000000000..c5571b9f74 --- /dev/null +++ b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs @@ -0,0 +1,31 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls +{ + public class SelectionModelChildrenRequestedEventArgs : EventArgs + { + private SelectionNode _sourceNode; + + internal SelectionModelChildrenRequestedEventArgs(object source, SelectionNode sourceNode) + { + Initialize(source, sourceNode); + } + + public object Children { get; set; } + public object Source { get; private set; } + public IndexPath SourceIndex => _sourceNode.IndexPath; + + internal void Initialize(object source, SelectionNode sourceNode) + { + Source = source; + _sourceNode = sourceNode; + } + } +} diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs new file mode 100644 index 0000000000..8c9e0343de --- /dev/null +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -0,0 +1,15 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls +{ + public class SelectionModelSelectionChangedEventArgs : EventArgs + { + } +} diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs new file mode 100644 index 0000000000..f5d93681c4 --- /dev/null +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -0,0 +1,811 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; + +namespace Avalonia.Controls +{ + /// + /// Tracks nested selection. + /// + /// + /// SelectionNode is the internal tree data structure that we keep track of for selection in + /// a nested scenario. This would map to one ItemsSourceView/Collection. This node reacts to + /// collection changes and keeps the selected indices up to date. This can either be a leaf + /// node or a non leaf node. + /// + internal class SelectionNode : IDisposable + { + private readonly SelectionModel _manager; + private readonly List _childrenNodes = new List(); + private readonly SelectionNode _parent; + private readonly List _selected = new List(); + private object _source; + private ItemsSourceView _dataSource; + private int _selectedCount; + private List _selectedIndicesCached = new List(); + private bool _selectedIndicesCacheIsValid; + private int _realizedChildrenNodeCount; + + public SelectionNode(SelectionModel manager, SelectionNode parent) + { + _manager = manager; + _parent = parent; + } + + public int AnchorIndex { get; set; } = -1; + + public object Source + { + get => _source; + set + { + if (_source != value) + { + ClearSelection(); + UnhookCollectionChangedHandler(); + + _source = value; + + // Setup ItemsSourceView + var newDataSource = value as ItemsSourceView; + + if (value != null && newDataSource == null) + { + newDataSource = new ItemsSourceView((IEnumerable)value); + } + + _dataSource = newDataSource; + + HookupCollectionChangedHandler(); + OnSelectionChanged(); + } + } + } + + public ItemsSourceView ItemsSourceView => _dataSource; + public int DataCount => _dataSource?.Count ?? 0; + public int ChildrenNodeCount => _childrenNodes.Count; + public int RealizedChildrenNodeCount => _realizedChildrenNodeCount; + + public IndexPath IndexPath + { + get + { + var path = new List(); ; + var parent = _parent; + var child = this; + + while (parent != null) + { + var childNodes = parent._childrenNodes; + var index = childNodes.IndexOf(child); + + // We are walking up to the parent, so the path will be backwards + path.Insert(0, index); + child = parent; + parent = parent._parent; + } + + return new IndexPath(path); + } + } + + // For a genuine tree view, we dont know which node is leaf until we + // actually walk to it, so currently the tree builds up to the leaf. I don't + // create a bunch of leaf node instances - instead i use the same instance m_leafNode to avoid + // an explosion of node objects. However, I'm still creating the m_childrenNodes + // collection unfortunately. + public SelectionNode GetAt(int index, bool realizeChild) + { + SelectionNode child = null; + + if (realizeChild) + { + if (_childrenNodes.Count == 0) + { + if (_dataSource != null) + { + for (int i = 0; i < _dataSource.Count; i++) + { + _childrenNodes.Add(null); + } + } + } + + if (_childrenNodes[index] == null) + { + var childData = _dataSource.GetAt(index); + + if (childData != null) + { + var resolvedChild = _manager.ResolvePath(childData, this); + + if (resolvedChild != null) + { + child = new SelectionNode(_manager, parent: this); + child.Source = resolvedChild; + } + else + { + child = _manager.SharedLeafNode; + } + } + else + { + child = _manager.SharedLeafNode; + } + + _childrenNodes[index] = child; + _realizedChildrenNodeCount++; + } + else + { + child = _childrenNodes[index]; + } + } + else + { + if (_childrenNodes.Count > 0) + { + child = _childrenNodes[index]; + } + } + + return child; + } + + public int SelectedCount => _selectedCount; + + public bool IsSelected(int index) + { + var isSelected = false; + + foreach (var range in _selected) + { + if (range.Contains(index)) + { + isSelected = true; + break; + } + } + + return isSelected; + } + + // True -> Selected + // False -> Not Selected + // Null -> Some descendents are selected and some are not + public bool? IsSelectedWithPartial() + { + var isSelected = (bool?)false; + + if (_parent != null) + { + var parentsChildren = _parent._childrenNodes; + + var myIndexInParent = parentsChildren.IndexOf(this); + + if (myIndexInParent != -1) + { + isSelected = _parent.IsSelectedWithPartial(myIndexInParent); + } + } + + return isSelected; + } + + // True -> Selected + // False -> Not Selected + // Null -> Some descendents are selected and some are not + public bool? IsSelectedWithPartial(int index) + { + var selectionState = SelectionState.NotSelected; + + if (_childrenNodes.Count == 0 || // no nodes realized + _childrenNodes.Count <= index || // target node is not realized + _childrenNodes[index] == null || // target node is not realized + _childrenNodes[index] == _manager.SharedLeafNode) // target node is a leaf node. + { + // Ask parent if the target node is selected. + selectionState = IsSelected(index) ? SelectionState.Selected : SelectionState.NotSelected; + } + else + { + // targetNode is the node representing the index. This node is the parent. + // targetNode is a non-leaf node, containing one or many children nodes. Evaluate + // based on children of targetNode. + var targetNode = _childrenNodes[index]; + selectionState = targetNode.EvaluateIsSelectedBasedOnChildrenNodes(); + } + + return ConvertToNullableBool(selectionState); + } + + public int SelectedIndex + { + get => SelectedCount > 0 ? SelectedIndices[0] : -1; + set + { + if (IsValidIndex(value) && (SelectedCount != 1 || !IsSelected(value))) + { + ClearSelection(); + + if (value != -1) + { + Select(value, true); + } + } + } + } + + public List SelectedIndices + { + get + { + if (!_selectedIndicesCacheIsValid) + { + _selectedIndicesCacheIsValid = true; + + foreach (var range in _selected) + { + for (int index = range.Begin; index <= range.End; index++) + { + // Avoid duplicates + if (!_selectedIndicesCached.Contains(index)) + { + _selectedIndicesCached.Add(index); + } + } + } + + // Sort the list for easy consumption + _selectedIndicesCached.Sort(); + } + + return _selectedIndicesCached; + } + } + + public void Dispose() + { + _dataSource?.Dispose(); + UnhookCollectionChangedHandler(); + } + + public bool Select(int index, bool select) + { + return Select(index, select, raiseOnSelectionChanged: true); + } + + public bool ToggleSelect(int index) + { + return Select(index, !IsSelected(index)); + } + + public void SelectAll() + { + if (_dataSource != null) + { + var size = _dataSource.Count; + + if (size > 0) + { + SelectRange(new IndexRange(0, size - 1), select: true); + } + } + } + + public void Clear() => ClearSelection(); + + public bool SelectRange(IndexRange range, bool select) + { + if (IsValidIndex(range.Begin) && IsValidIndex(range.End)) + { + if (select) + { + AddRange(range, raiseOnSelectionChanged: true); + } + else + { + RemoveRange(range, raiseOnSelectionChanged: true); + } + + return true; + } + + return false; + } + + private void HookupCollectionChangedHandler() + { + if (_dataSource != null) + { + _dataSource.CollectionChanged += OnSourceListChanged; + } + } + + private void UnhookCollectionChangedHandler() + { + if (_dataSource != null) + { + _dataSource.CollectionChanged -= OnSourceListChanged; + } + } + + private bool IsValidIndex(int index) + { + return ItemsSourceView == null || (index >= 0 && index < ItemsSourceView.Count); + } + + private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged) + { + // TODO: Check for duplicates (Task 14107720) + // TODO: Optimize by merging adjacent ranges (Task 14107720) + var oldCount = SelectedCount; + + for (int i = addRange.Begin; i <= addRange.End; i++) + { + if (!IsSelected(i)) + { + _selectedCount++; + } + } + + if (oldCount != _selectedCount) + { + _selected.Add(addRange); + + if (raiseOnSelectionChanged) + { + OnSelectionChanged(); + } + } + } + + private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged) + { + int oldCount = _selectedCount; + + // TODO: Prevent overlap of Ranges in _selected (Task 14107720) + for (int i = removeRange.Begin; i <= removeRange.End; i++) + { + if (IsSelected(i)) + { + _selectedCount--; + } + } + + if (oldCount != _selectedCount) + { + // Build up a both a list of Ranges to remove and ranges to add + var toRemove = new List(); + var toAdd = new List(); + + foreach (var range in _selected) + { + // If this range intersects the remove range, we have to do something + if (removeRange.Intersects(range)) + { + // Intersection with the beginning of the range + // Anything to the left of the point (exclusive) stays + // Anything to the right of the point (inclusive) gets clipped + if (range.Contains(removeRange.Begin - 1)) + { + range.Split(removeRange.Begin - 1, out var before, out _); + toAdd.Add(before); + } + + // Intersection with the end of the range + // Anything to the left of the point (inclusive) gets clipped + // Anything to the right of the point (exclusive) stays + if (range.Contains(removeRange.End)) + { + if (range.Split(removeRange.End, out _, out var after)) + { + toAdd.Add(after); + } + } + + // Remove this Range from the collection + // New ranges will be added for any remaining subsections + toRemove.Add(range); + } + } + + bool change = ((toRemove.Count > 0) || (toAdd.Count > 0)); + + if (change) + { + // Remove tagged ranges + foreach (var remove in toRemove) + { + _selected.Remove(remove); + } + + // Add new ranges + _selected.AddRange(toAdd); + + if (raiseOnSelectionChanged) + { + OnSelectionChanged(); + } + } + } + } + + private void ClearSelection() + { + // Deselect all items + if (_selected.Count > 0) + { + _selected.Clear(); + OnSelectionChanged(); + } + + _selectedCount = 0; + AnchorIndex = -1; + + // This will throw away all the children SelectionNodes + // causing them to be unhooked from their data source. This + // essentially cleans up the tree. + foreach (var child in _childrenNodes) + { + child?.Dispose(); + } + + _childrenNodes.Clear(); + } + + private bool Select(int index, bool select, bool raiseOnSelectionChanged) + { + if (IsValidIndex(index)) + { + // Ignore duplicate selection calls + if (IsSelected(index) == select) + { + return true; + } + + var range = new IndexRange(index, index); + + if (select) + { + AddRange(range, raiseOnSelectionChanged); + } + else + { + RemoveRange(range, raiseOnSelectionChanged); + } + + return true; + } + + return false; + } + + private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) + { + bool selectionInvalidated = false; + + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + { + selectionInvalidated = OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); + break; + } + + case NotifyCollectionChangedAction.Remove: + { + selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); + break; + } + + case NotifyCollectionChangedAction.Reset: + { + ClearSelection(); + selectionInvalidated = true; + break; + } + + case NotifyCollectionChangedAction.Replace: + { + selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); + selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); + break; + } + } + + if (selectionInvalidated) + { + OnSelectionChanged(); + _manager.OnSelectionInvalidatedDueToCollectionChange(); + } + } + + private bool OnItemsAdded(int index, int count) + { + var selectionInvalidated = false; + + // Update ranges for leaf items + var toAdd = new List(); + + for (int i = 0; i < _selected.Count; i++) + { + var range = _selected[i]; + + // The range is after the inserted items, need to shift the range right + if (range.End >= index) + { + int begin = range.Begin; + + // If the index left of newIndex is inside the range, + // Split the range and remember the left piece to add later + if (range.Contains(index - 1)) + { + range.Split(index - 1, out var before, out _); + toAdd.Add(before); + begin = index; + } + + // Shift the range to the right + _selected[i] = new IndexRange(begin + count, range.End + count); + selectionInvalidated = true; + } + } + + // Add the left sides of the split ranges + _selected.AddRange(toAdd); + + // Update for non-leaf if we are tracking non-leaf nodes + if (_childrenNodes.Count > 0) + { + selectionInvalidated = true; + for (int i = 0; i < count; i++) + { + _childrenNodes.Insert(index, null); + } + } + + // Adjust the anchor + if (AnchorIndex >= index) + { + AnchorIndex = AnchorIndex + count; + } + + // Check if adding a node invalidated an ancestors + // selection state. For example if parent was selected before + // adding a new item makes the parent partially selected now. + if (!selectionInvalidated) + { + var parent = _parent; + + while (parent != null) + { + var isSelected = parent.IsSelectedWithPartial(); + + // If a parent is selected, then it will become partially selected. + // If it is not selected or partially selected - there is no change. + if (isSelected == true) + { + selectionInvalidated = true; + break; + } + + parent = parent._parent; + } + } + + return selectionInvalidated; + } + + private bool OnItemsRemoved(int index, int count) + { + bool selectionInvalidated = false; + + // Remove the items from the selection for leaf + if (ItemsSourceView.Count > 0) + { + bool isSelected = false; + + for (int i = index; i <= index + count - 1; i++) + { + if (IsSelected(i)) + { + isSelected = true; + break; + } + } + + if (isSelected) + { + RemoveRange(new IndexRange(index, index + count - 1), raiseOnSelectionChanged: false); + selectionInvalidated = true; + } + + for (int i = 0; i < _selected.Count; i++) + { + var range = _selected[i]; + + // The range is after the removed items, need to shift the range left + if (range.End > index) + { + // Shift the range to the left + _selected[i] = new IndexRange(range.Begin - count, range.End - count); + selectionInvalidated = true; + } + } + + // Update for non-leaf if we are tracking non-leaf nodes + if (_childrenNodes.Count > 0) + { + selectionInvalidated = true; + for (int i = 0; i < count; i++) + { + if (_childrenNodes[index] != null) + { + _realizedChildrenNodeCount--; + } + _childrenNodes.RemoveAt(index); + } + } + + //Adjust the anchor + if (AnchorIndex >= index) + { + AnchorIndex = AnchorIndex - count; + } + } + else + { + // No more items in the list, clear + ClearSelection(); + _realizedChildrenNodeCount = 0; + selectionInvalidated = true; + } + + // Check if removing a node invalidated an ancestors + // selection state. For example if parent was partially selected before + // removing an item, it could be selected now. + if (!selectionInvalidated) + { + var parent = _parent; + + while (parent != null) + { + var isSelected = parent.IsSelectedWithPartial(); + // If a parent is partially selected, then it will become selected. + // If it is selected or not selected - there is no change. + if (!isSelected.HasValue) + { + selectionInvalidated = true; + break; + } + + parent = parent._parent; + } + } + + return selectionInvalidated; + } + + private void OnSelectionChanged() + { + _selectedIndicesCacheIsValid = false; + _selectedIndicesCached.Clear(); + } + + public static bool? ConvertToNullableBool(SelectionState isSelected) + { + bool? result = null; // PartialySelected + + if (isSelected == SelectionState.Selected) + { + result = true; + } + else if (isSelected == SelectionState.NotSelected) + { + result = false; + } + + return result; + } + + public SelectionState EvaluateIsSelectedBasedOnChildrenNodes() + { + SelectionState selectionState = SelectionState.NotSelected; + int realizedChildrenNodeCount = RealizedChildrenNodeCount; + int selectedCount = SelectedCount; + + if (realizedChildrenNodeCount != 0 || selectedCount != 0) + { + // There are realized children or some selected leaves. + int dataCount = DataCount; + if (realizedChildrenNodeCount == 0 && selectedCount > 0) + { + // All nodes are leaves under it - we didn't create children nodes as an optimization. + // See if all/some or none of the leaves are selected. + selectionState = dataCount != selectedCount ? + SelectionState.PartiallySelected : + dataCount == selectedCount ? SelectionState.Selected : SelectionState.NotSelected; + } + else + { + // There are child nodes, walk them individually and evaluate based on each child + // being selected/not selected or partially selected. + bool isSelected = false; + selectedCount = 0; + int notSelectedCount = 0; + for (int i = 0; i < ChildrenNodeCount; i++) + { + var child = GetAt(i, realizeChild: false); + + if (child != null) + { + // child is realized, ask it. + var isChildSelected = IsSelectedWithPartial(i); + if (isChildSelected == null) + { + selectionState = SelectionState.PartiallySelected; + break; + } + else if (isChildSelected == true) + { + selectedCount++; + } + else + { + notSelectedCount++; + } + } + else + { + // not realized. + if (IsSelected(i)) + { + selectedCount++; + } + else + { + notSelectedCount++; + } + } + + if (selectedCount > 0 && notSelectedCount > 0) + { + selectionState = SelectionState.PartiallySelected; + break; + } + } + + if (selectionState != SelectionState.PartiallySelected) + { + if (selectedCount != 0 && selectedCount != dataCount) + { + selectionState = SelectionState.PartiallySelected; + } + else + { + selectionState = selectedCount == dataCount ? SelectionState.Selected : SelectionState.NotSelected; + } + } + } + } + + return selectionState; + } + + public enum SelectionState + { + Selected, + NotSelected, + PartiallySelected + } + } +} diff --git a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs new file mode 100644 index 0000000000..52e29da0e5 --- /dev/null +++ b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs @@ -0,0 +1,183 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Avalonia.Controls.Utils +{ + internal static class SelectionTreeHelper + { + public static void TraverseIndexPath( + SelectionNode root, + IndexPath path, + bool realizeChildren, + Action nodeAction) + { + var node = root; + + for (int depth = 0; depth < path.GetSize(); depth++) + { + int childIndex = path.GetAt(depth); + nodeAction(node, path, depth, childIndex); + + if (depth < path.GetSize() - 1) + { + node = node.GetAt(childIndex, realizeChildren); + } + } + } + + public static void Traverse( + SelectionNode root, + bool realizeChildren, + Action nodeAction) + { + var pendingNodes = new List(); + var current = new IndexPath(null); + + pendingNodes.Add(new TreeWalkNodeInfo(root, current)); + + while (pendingNodes.Count > 0) + { + var nextNode = pendingNodes.Last(); + pendingNodes.RemoveAt(pendingNodes.Count - 1); + int count = realizeChildren ? nextNode.Node.DataCount : nextNode.Node.ChildrenNodeCount; + for (int i = count - 1; i >= 0; i--) + { + SelectionNode child = nextNode.Node.GetAt(i, realizeChildren); + var childPath = nextNode.Path.CloneWithChildIndex(i); + if (child != null) + { + pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, nextNode.Node)); + } + } + + // Queue the children first and then perform the action. This way + // the action can remove the children in the action if necessary + nodeAction(nextNode); + } + } + + public static void TraverseRangeRealizeChildren( + SelectionNode root, + IndexPath start, + IndexPath end, + Action nodeAction) + { + var pendingNodes = new List(); + var current = start; + + // Build up the stack to account for the depth first walk up to the + // start index path. + TraverseIndexPath( + root, + start, + true, + (node, path, depth, childIndex) => + { + var currentPath = StartPath(path, depth); + bool isStartPath = IsSubSet(start, currentPath); + bool isEndPath = IsSubSet(end, currentPath); + + int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0; + int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : node.DataCount - 1; + + for (int i = endIndex; i >= startIndex; i--) + { + var child = node.GetAt(i, realizeChild: true); + if (child != null) + { + var childPath = currentPath.CloneWithChildIndex(i); + pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, node)); + } + } + }); + + // From the start index path, do a depth first walk as long as the + // current path is less than the end path. + while (pendingNodes.Count > 0) + { + var info = pendingNodes.Last(); + pendingNodes.RemoveAt(pendingNodes.Count - 1); + int depth = info.Path.GetSize(); + bool isStartPath = IsSubSet(start, info.Path); + bool isEndPath = IsSubSet(end, info.Path); + int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0; + int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : info.Node.DataCount - 1; + for (int i = endIndex; i >= startIndex; i--) + { + var child = info.Node.GetAt(i, realizeChild: true); + if (child != null) + { + var childPath = info.Path.CloneWithChildIndex(i); + pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, info.Node)); + } + } + + nodeAction(info); + + if (info.Path.CompareTo(end) == 0) + { + // We reached the end index path. stop iterating. + break; + } + } + } + + private static bool IsSubSet(IndexPath path, IndexPath subset) + { + bool isSubset = true; + for (int i = 0; i < subset.GetSize(); i++) + { + isSubset = path.GetAt(i) == subset.GetAt(i); + if (!isSubset) + break; + } + + return isSubset; + } + + private static IndexPath StartPath(IndexPath path, int length) + { + var subPath = new List(); + for (int i = 0; i < length; i++) + { + subPath.Add(path.GetAt(i)); + } + + return new IndexPath(subPath); + } + + public struct TreeWalkNodeInfo + { + public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath, SelectionNode parent) + { + node = node ?? throw new ArgumentNullException(nameof(node)); + indexPath = indexPath ?? throw new ArgumentNullException(nameof(indexPath)); + + Node = node; + Path = indexPath; + ParentNode = parent; + } + + public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath) + { + node = node ?? throw new ArgumentNullException(nameof(node)); + indexPath = indexPath ?? throw new ArgumentNullException(nameof(indexPath)); + + Node = node; + Path = indexPath; + ParentNode = null; + } + + public SelectionNode Node { get; } + public IndexPath Path { get; } + public SelectionNode ParentNode { get; } + }; + + } +} diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs new file mode 100644 index 0000000000..6c3137c636 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -0,0 +1,1314 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Collections; +using Avalonia.Diagnostics; +using Xunit; +using Xunit.Abstractions; + +namespace Avalonia.Controls.UnitTests +{ + public class SelectionModelTests + { + private LogWrapper Log { get; } + + public SelectionModelTests(ITestOutputHelper output) + { + Log = new LogWrapper(output); + } + + [Fact] + public void ValidateOneLevelSingleSelectionNoSource() + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; + Log.Comment("No source set."); + Select(selectionModel, 4, true); + ValidateSelection(selectionModel, new List() { Path(4) }); + Select(selectionModel, 4, false); + ValidateSelection(selectionModel, new List() { }); + }); + } + + [Fact] + public void ValidateOneLevelSingleSelection() + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; + Log.Comment("Set the source to 10 items"); + selectionModel.Source = Enumerable.Range(0, 10).ToList(); + Select(selectionModel, 3, true); + ValidateSelection(selectionModel, new List() { Path(3) }, new List() { Path() }); + Select(selectionModel, 3, false); + ValidateSelection(selectionModel, new List() { }); + }); + } + + [Fact] + public void ValidateSelectionChangedEvent() + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel(); + selectionModel.Source = Enumerable.Range(0, 10).ToList(); + + int selectionChangedFiredCount = 0; + selectionModel.SelectionChanged += delegate (object sender, SelectionModelSelectionChangedEventArgs args) + { + selectionChangedFiredCount++; + ValidateSelection(selectionModel, new List() { Path(4) }, new List() { Path() }); + }; + + Select(selectionModel, 4, true); + ValidateSelection(selectionModel, new List() { Path(4) }, new List() { Path() }); + Assert.Equal(1, selectionChangedFiredCount); + }); + } + + [Fact] + public void ValidateCanSetSelectedIndex() + { + RunOnUIThread.Execute(() => + { + var model = new SelectionModel(); + var ip = IndexPath.CreateFrom(34); + model.SelectedIndex = ip; + Assert.Equal(0, ip.CompareTo(model.SelectedIndex)); + }); + } + + [Fact] + public void ValidateOneLevelMultipleSelection() + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel(); + selectionModel.Source = Enumerable.Range(0, 10).ToList(); + + Select(selectionModel, 4, true); + ValidateSelection(selectionModel, new List() { Path(4) }, new List() { Path() }); + SelectRangeFromAnchor(selectionModel, 8, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(4), + Path(5), + Path(6), + Path(7), + Path(8) + }, + new List() { Path() }); + + ClearSelection(selectionModel); + SetAnchorIndex(selectionModel, 6); + SelectRangeFromAnchor(selectionModel, 3, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4), + Path(5), + Path(6) + }, + new List() { Path() }); + + SetAnchorIndex(selectionModel, 4); + SelectRangeFromAnchor(selectionModel, 5, false /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(6) + }, + new List() { Path() }); + }); + } + + [Fact] + public void ValidateTwoLevelSingleSelection() + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel(); + Log.Comment("Setting the source"); + selectionModel.Source = CreateNestedData(1 /* levels */ , 2 /* groupsAtLevel */, 2 /* countAtLeaf */); + Select(selectionModel, 1, 1, true); + ValidateSelection(selectionModel, + new List() { Path(1, 1) }, new List() { Path(), Path(1) }); + Select(selectionModel, 1, 1, false); + ValidateSelection(selectionModel, new List() { }); + }); + } + + [Fact] + public void ValidateTwoLevelMultipleSelection() + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel(); + Log.Comment("Setting the source"); + selectionModel.Source = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); + + Select(selectionModel, 1, 2, true); + ValidateSelection(selectionModel, new List() { Path(1, 2) }, new List() { Path(), Path(1) }); + SelectRangeFromAnchor(selectionModel, 2, 2, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(1, 2), + Path(2), // Inner node should be selected since everything 2.* is selected + Path(2, 0), + Path(2, 1), + Path(2, 2) + }, + new List() + { + Path(), + Path(1) + }, + 1 /* selectedInnerNodes */); + + ClearSelection(selectionModel); + SetAnchorIndex(selectionModel, 2, 1); + SelectRangeFromAnchor(selectionModel, 0, 1, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2), + Path(1, 0), + Path(1, 1), + Path(1, 2), + Path(1), + Path(2, 0), + Path(2, 1) + }, + new List() + { + Path(), + Path(0), + Path(2), + }, + 1 /* selectedInnerNodes */); + + SetAnchorIndex(selectionModel, 1, 1); + SelectRangeFromAnchor(selectionModel, 2, 0, false /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2), + Path(1, 0), + Path(2, 1) + }, + new List() + { + Path(), + Path(1), + Path(0), + Path(2), + }, + 0 /* selectedInnerNodes */); + + ClearSelection(selectionModel); + ValidateSelection(selectionModel, new List() { }); + }); + } + + [Fact] + public void ValidateNestedSingleSelection() + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; + Log.Comment("Setting the source"); + selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 2 /* countAtLeaf */); + var path = Path(1, 0, 1, 1); + Select(selectionModel, path, true); + ValidateSelection(selectionModel, + new List() { path }, + new List() + { + Path(), + Path(1), + Path(1, 0), + Path(1, 0, 1), + }); + Select(selectionModel, Path(0, 0, 1, 0), true); + ValidateSelection(selectionModel, + new List() + { + Path(0, 0, 1, 0) + }, + new List() + { + Path(), + Path(0), + Path(0, 0), + Path(0, 0, 1) + }); + Select(selectionModel, Path(0, 0, 1, 0), false); + ValidateSelection(selectionModel, new List() { }); + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ValidateNestedMultipleSelection(bool handleChildrenRequested) + { + RunOnUIThread.Execute(() => + { + SelectionModel selectionModel = new SelectionModel(); + List sourcePaths = new List(); + + Log.Comment("Setting the source"); + selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 4 /* countAtLeaf */); + if (handleChildrenRequested) + { + selectionModel.ChildrenRequested += (object sender, SelectionModelChildrenRequestedEventArgs args) => + { + Log.Comment("ChildrenRequestedIndexPath:" + args.SourceIndex); + sourcePaths.Add(args.SourceIndex); + args.Children = args.Source is IEnumerable ? args.Source : null; + }; + } + + var startPath = Path(1, 0, 1, 0); + Select(selectionModel, startPath, true); + ValidateSelection(selectionModel, + new List() { startPath }, + new List() + { + Path(), + Path(1), + Path(1, 0), + Path(1, 0, 1) + }); + + var endPath = Path(1, 1, 1, 0); + SelectRangeFromAnchor(selectionModel, endPath, true /* select */); + + if (handleChildrenRequested) + { + // Validate SourceIndices. + var expectedSourceIndices = new List() + { + Path(), + Path(1), + Path(1, 0), + Path(1), + Path(1, 0, 1), + Path(1, 0, 1), + Path(1, 0, 1), + Path(1, 0, 1), + Path(1, 1), + Path(1, 1), + Path(1, 1, 0), + Path(1, 1, 0), + Path(1, 1, 0), + Path(1, 1, 0), + Path(1, 1, 1) + }; + + Assert.Equal(expectedSourceIndices.Count, sourcePaths.Count); + for (int i = 0; i < expectedSourceIndices.Count; i++) + { + Assert.True(AreEqual(expectedSourceIndices[i], sourcePaths[i])); + } + } + + ValidateSelection(selectionModel, + new List() + { + Path(1, 0, 1, 0), + Path(1, 0, 1, 1), + Path(1, 0, 1, 2), + Path(1, 0, 1, 3), + Path(1, 0, 1), + Path(1, 1, 0, 0), + Path(1, 1, 0, 1), + Path(1, 1, 0, 2), + Path(1, 1, 0, 3), + Path(1, 1, 0), + Path(1, 1, 1, 0), + }, + new List() + { + Path(), + Path(1), + Path(1, 0), + Path(1, 1), + Path(1, 1, 1), + }, + 2 /* selectedInnerNodes */); + + ClearSelection(selectionModel); + ValidateSelection(selectionModel, new List() { }); + + startPath = Path(0, 1, 0, 2); + SetAnchorIndex(selectionModel, startPath); + endPath = Path(0, 0, 0, 2); + SelectRangeFromAnchor(selectionModel, endPath, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(0, 0, 0, 2), + Path(0, 0, 0, 3), + Path(0, 0, 1, 0), + Path(0, 0, 1, 1), + Path(0, 0, 1, 2), + Path(0, 0, 1, 3), + Path(0, 0, 1), + Path(0, 1, 0, 0), + Path(0, 1, 0, 1), + Path(0, 1, 0, 2), + }, + new List() + { + Path(), + Path(0), + Path(0, 0), + Path(0, 0, 0), + Path(0, 1), + Path(0, 1, 0), + }, + 1 /* selectedInnerNodes */); + + startPath = Path(0, 1, 0, 2); + SetAnchorIndex(selectionModel, startPath); + endPath = Path(0, 0, 0, 2); + SelectRangeFromAnchor(selectionModel, endPath, false /* select */); + ValidateSelection(selectionModel, new List() { }); + }); + } + + [Fact] + public void ValidateInserts() + { + RunOnUIThread.Execute(() => + { + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(3); + selectionModel.Select(4); + selectionModel.Select(5); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4), + Path(5), + }, + new List() + { + Path() + }); + + Log.Comment("Insert in selected range: Inserting 3 items at index 4"); + data.Insert(4, 41); + data.Insert(4, 42); + data.Insert(4, 43); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(7), + Path(8), + }, + new List() + { + Path() + }); + + Log.Comment("Insert before selected range: Inserting 3 items at index 0"); + data.Insert(0, 100); + data.Insert(0, 101); + data.Insert(0, 102); + ValidateSelection(selectionModel, + new List() + { + Path(6), + Path(10), + Path(11), + }, + new List() + { + Path() + }); + + Log.Comment("Insert after selected range: Inserting 3 items at index 12"); + data.Insert(12, 1000); + data.Insert(12, 1001); + data.Insert(12, 1002); + ValidateSelection(selectionModel, + new List() + { + Path(6), + Path(10), + Path(11), + }, + new List() + { + Path() + }); + }); + } + + [Fact] + public void ValidateGroupInserts() + { + RunOnUIThread.Execute(() => + { + var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(1, 1); + ValidateSelection(selectionModel, + new List() + { + Path(1, 1), + }, + new List() + { + Path(), + Path(1), + }); + + Log.Comment("Insert before selected range: Inserting item at group index 0"); + data.Insert(0, 100); + ValidateSelection(selectionModel, + new List() + { + Path(2, 1) + }, + new List() + { + Path(), + Path(2), + }); + + Log.Comment("Insert after selected range: Inserting item at group index 3"); + data.Insert(3, 1000); + ValidateSelection(selectionModel, + new List() + { + Path(2, 1) + }, + new List() + { + Path(), + Path(2), + }); + }); + } + + [Fact] + public void ValidateRemoves() + { + RunOnUIThread.Execute(() => + { + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(6); + selectionModel.Select(7); + selectionModel.Select(8); + ValidateSelection(selectionModel, + new List() + { + Path(6), + Path(7), + Path(8) + }, + new List() + { + Path() + }); + + Log.Comment("Remove before selected range: Removing item at index 0"); + data.RemoveAt(0); + ValidateSelection(selectionModel, + new List() + { + Path(5), + Path(6), + Path(7) + }, + new List() + { + Path() + }); + + Log.Comment("Remove from before to middle of selected range: Removing items at index 3, 4, 5"); + data.RemoveAt(3); + data.RemoveAt(3); + data.RemoveAt(3); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4) + }, + new List() + { + Path() + }); + + Log.Comment("Remove after selected range: Removing item at index 5"); + data.RemoveAt(5); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4) + }, + new List() + { + Path() + }); + }); + } + + [Fact] + public void ValidateGroupRemoves() + { + RunOnUIThread.Execute(() => + { + var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(1, 1); + selectionModel.Select(1, 2); + ValidateSelection(selectionModel, + new List() + { + Path(1, 1), + Path(1, 2) + }, + new List() + { + Path(), + Path(1), + }); + + Log.Comment("Remove before selected range: Removing item at group index 0"); + data.RemoveAt(0); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2) + }, + new List() + { + Path(), + Path(0), + }); + + Log.Comment("Remove after selected range: Removing item at group index 1"); + data.RemoveAt(1); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2) + }, + new List() + { + Path(), + Path(0), + }); + + Log.Comment("Remove group containing selected items"); + data.RemoveAt(0); + ValidateSelection(selectionModel, new List()); + }); + } + + [Fact] + public void CanReplaceItem() + { + RunOnUIThread.Execute(() => + { + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(3); + selectionModel.Select(4); + selectionModel.Select(5); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4), + Path(5), + }, + new List() + { + Path() + }); + + data[3] = 300; + data[4] = 400; + ValidateSelection(selectionModel, + new List() + { + Path(5), + }, + new List() + { + Path() + }); + }); + } + + [Fact] + public void ValidateGroupReplaceLosesSelection() + { + RunOnUIThread.Execute(() => + { + var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(1, 1); + ValidateSelection(selectionModel, + new List() + { + Path(1, 1) + }, + new List() + { + Path(), + Path(1) + }); + + data[1] = new ObservableCollection(Enumerable.Range(0, 5)); + ValidateSelection(selectionModel, new List()); + }); + } + + [Fact] + public void ValidateClear() + { + RunOnUIThread.Execute(() => + { + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(3); + selectionModel.Select(4); + selectionModel.Select(5); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4), + Path(5), + }, + new List() + { + Path() + }); + + data.Clear(); + ValidateSelection(selectionModel, new List()); + }); + } + + [Fact] + public void ValidateGroupClear() + { + RunOnUIThread.Execute(() => + { + var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + + selectionModel.Select(1, 1); + ValidateSelection(selectionModel, + new List() + { + Path(1, 1) + }, + new List() + { + Path(), + Path(1) + }); + + (data[1] as IList).Clear(); + ValidateSelection(selectionModel, new List()); + }); + } + + // In some cases the leaf node might get a collection change that affects an ancestors selection + // state. In this case we were not raising selection changed event. For example, if all elements + // in a group are selected and a new item gets inserted - the parent goes from selected to partially + // selected. In that case we need to raise the selection changed event so that the header containers + // can show the correct visual. + [Fact] + public void ValidateEventWhenInnerNodeChangesSelectionState() + { + RunOnUIThread.Execute(() => + { + bool selectionChangedRaised = false; + var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); + var selectionModel = new SelectionModel(); + selectionModel.Source = data; + selectionModel.SelectionChanged += (sender, args) => { selectionChangedRaised = true; }; + + selectionModel.Select(1, 0); + selectionModel.Select(1, 1); + selectionModel.Select(1, 2); + ValidateSelection(selectionModel, + new List() + { + Path(1, 0), + Path(1, 1), + Path(1, 2), + Path(1) + }, + new List() + { + Path(), + }, + 1 /* selectedInnerNodes */); + + Log.Comment("Inserting 1.0"); + selectionChangedRaised = false; + (data[1] as AvaloniaList).Insert(0, 100); + Assert.True(selectionChangedRaised, "SelectionChanged event was not raised"); + ValidateSelection(selectionModel, + new List() + { + Path(1, 1), + Path(1, 2), + Path(1, 3), + }, + new List() + { + Path(), + Path(1), + }); + + Log.Comment("Removing 1.0"); + selectionChangedRaised = false; + (data[1] as AvaloniaList).RemoveAt(0); + Assert.True(selectionChangedRaised, "SelectionChanged event was not raised"); + ValidateSelection(selectionModel, + new List() + { + Path(1, 0), + Path(1, 1), + Path(1, 2), + Path(1) + }, + new List() + { + Path(), + }, + 1 /* selectedInnerNodes */); + }); + } + + [Fact] + public void ValidatePropertyChangedEventIsRaised() + { + RunOnUIThread.Execute(() => + { + var selectionModel = new SelectionModel(); + Log.Comment("Set the source to 10 items"); + selectionModel.Source = Enumerable.Range(0, 10).ToList(); + + bool selectedIndexChanged = false; + bool selectedIndicesChanged = false; + bool SelectedItemChanged = false; + bool SelectedItemsChanged = false; + bool AnchorIndexChanged = false; + selectionModel.PropertyChanged += (sender, args) => + { + switch (args.PropertyName) + { + case "SelectedIndex": + selectedIndexChanged = true; + break; + case "SelectedIndices": + selectedIndicesChanged = true; + break; + case "SelectedItem": + SelectedItemChanged = true; + break; + case "SelectedItems": + SelectedItemsChanged = true; + break; + case "AnchorIndex": + AnchorIndexChanged = true; + break; + + default: + throw new InvalidOperationException(); + } + }; + + Select(selectionModel, 3, true); + + Assert.True(selectedIndexChanged); + Assert.True(selectedIndicesChanged); + Assert.True(SelectedItemChanged); + Assert.True(SelectedItemsChanged); + Assert.True(AnchorIndexChanged); + }); + } + + [Fact] + public void CanExtendSelectionModelINPC() + { + RunOnUIThread.Execute(() => + { + var selectionModel = new CustomSelectionModel(); + bool intPropertyChanged = false; + selectionModel.PropertyChanged += (sender, args) => + { + if (args.PropertyName == "IntProperty") + { + intPropertyChanged = true; + } + }; + + selectionModel.IntProperty = 5; + Assert.True(intPropertyChanged); + }); + } + + + [Fact] + public void Disposing_Unhooks_CollectionChanged_Handlers() + { + var data = CreateNestedData(2, 2, 2); + var target = new SelectionModel { Source = data }; + + target.SelectAll(); + VerifyCollectionChangedHandlers(1, data); + + target.Dispose(); + + VerifyCollectionChangedHandlers(0, data); + } + + [Fact] + public void Clearing_Selection_Unhooks_CollectionChanged_Handlers() + { + var data = CreateNestedData(2, 2, 2); + var target = new SelectionModel { Source = data }; + + target.SelectAll(); + VerifyCollectionChangedHandlers(1, data); + + target.ClearSelection(); + + // Root subscription not unhooked until SelectionModel is disposed. + Assert.Equal(1, GetSubscriberCount(data)); + + foreach (AvaloniaList i in data) + { + VerifyCollectionChangedHandlers(0, i); + } + } + + [Fact] + public void Should_Not_Treat_Strings_As_Nested_Selections() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data }; + + target.SelectAll(); + + Assert.Equal(3, target.SelectedItems.Count); + } + + private int GetSubscriberCount(AvaloniaList list) + { + return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; + } + + private void VerifyCollectionChangedHandlers(int expected, AvaloniaList list) + { + var count = GetSubscriberCount(list); + + Assert.Equal(expected, count); + + foreach (var i in list) + { + if (i is AvaloniaList l) + { + VerifyCollectionChangedHandlers(expected, l); + } + } + } + + private void Select(SelectionModel manager, int index, bool select) + { + Log.Comment((select ? "Selecting " : "DeSelecting ") + index); + if (select) + { + manager.Select(index); + } + else + { + manager.Deselect(index); + } + } + + private void Select(SelectionModel manager, int groupIndex, int itemIndex, bool select) + { + Log.Comment((select ? "Selecting " : "DeSelecting ") + groupIndex + "." + itemIndex); + if (select) + { + manager.Select(groupIndex, itemIndex); + } + else + { + manager.Deselect(groupIndex, itemIndex); + } + } + + private void Select(SelectionModel manager, IndexPath index, bool select) + { + Log.Comment((select ? "Selecting " : "DeSelecting ") + index); + if (select) + { + manager.SelectAt(index); + } + else + { + manager.DeselectAt(index); + } + } + + private void SelectRangeFromAnchor(SelectionModel manager, int index, bool select) + { + Log.Comment("SelectRangeFromAnchor " + index + " select: " + select.ToString()); + if (select) + { + manager.SelectRangeFromAnchor(index); + } + else + { + manager.DeselectRangeFromAnchor(index); + } + } + + private void SelectRangeFromAnchor(SelectionModel manager, int groupIndex, int itemIndex, bool select) + { + Log.Comment("SelectRangeFromAnchor " + groupIndex + "." + itemIndex + " select:" + select.ToString()); + if (select) + { + manager.SelectRangeFromAnchor(groupIndex, itemIndex); + } + else + { + manager.DeselectRangeFromAnchor(groupIndex, itemIndex); + } + } + + private void SelectRangeFromAnchor(SelectionModel manager, IndexPath index, bool select) + { + Log.Comment("SelectRangeFromAnchor " + index + " select: " + select.ToString()); + if (select) + { + manager.SelectRangeFromAnchorTo(index); + } + else + { + manager.DeselectRangeFromAnchorTo(index); + } + } + + private void ClearSelection(SelectionModel manager) + { + Log.Comment("ClearSelection"); + manager.ClearSelection(); + } + + private void SetAnchorIndex(SelectionModel manager, int index) + { + Log.Comment("SetAnchorIndex " + index); + manager.SetAnchorIndex(index); + } + + private void SetAnchorIndex(SelectionModel manager, int groupIndex, int itemIndex) + { + Log.Comment("SetAnchor " + groupIndex + "." + itemIndex); + manager.SetAnchorIndex(groupIndex, itemIndex); + } + + private void SetAnchorIndex(SelectionModel manager, IndexPath index) + { + Log.Comment("SetAnchor " + index); + manager.AnchorIndex = index; + } + + private void ValidateSelection( + SelectionModel selectionModel, + List expectedSelected, + List expectedPartialSelected = null, + int selectedInnerNodes = 0) + { + Log.Comment("Validating Selection..."); + + Log.Comment("Selection contains indices:"); + foreach (var index in selectionModel.SelectedIndices) + { + Log.Comment(" " + index.ToString()); + } + + Log.Comment("Selection contains items:"); + foreach (var item in selectionModel.SelectedItems) + { + Log.Comment(" " + item.ToString()); + } + + if (selectionModel.Source != null) + { + List allIndices = GetIndexPathsInSource(selectionModel.Source); + foreach (var index in allIndices) + { + bool? isSelected = selectionModel.IsSelectedAt(index); + if (Contains(expectedSelected, index)) + { + Assert.True(isSelected.Value, index + " is Selected"); + } + else if (expectedPartialSelected != null && Contains(expectedPartialSelected, index)) + { + Assert.True(isSelected == null, index + " is partially Selected"); + } + else + { + if (isSelected == null) + { + Log.Comment("*************" + index + " is null"); + Assert.True(false, "Expected false but got null");; + } + else + { + Assert.False(isSelected.Value, index + " is not Selected"); + } + } + } + } + else + { + foreach (var index in expectedSelected) + { + Assert.True(selectionModel.IsSelectedAt(index).Value, index + " is Selected"); + } + } + if (expectedSelected.Count > 0) + { + Log.Comment("SelectedIndex is " + selectionModel.SelectedIndex); + Assert.Equal(0, selectionModel.SelectedIndex.CompareTo(expectedSelected[0])); + if (selectionModel.Source != null) + { + Assert.Equal(selectionModel.SelectedItem, GetData(selectionModel, expectedSelected[0])); + } + + int itemsCount = selectionModel.SelectedItems.Count(); + Assert.Equal(selectionModel.Source != null ? expectedSelected.Count - selectedInnerNodes : 0, itemsCount); + int indicesCount = selectionModel.SelectedIndices.Count(); + Assert.Equal(expectedSelected.Count - selectedInnerNodes, indicesCount); + } + + Log.Comment("Validating Selection... done"); + } + + private object GetData(SelectionModel selectionModel, IndexPath indexPath) + { + var data = selectionModel.Source; + for (int i = 0; i < indexPath.GetSize(); i++) + { + var listData = data as IList; + data = listData[indexPath.GetAt(i)]; + } + + return data; + } + + private bool AreEqual(IndexPath a, IndexPath b) + { + if (a.GetSize() != b.GetSize()) + { + return false; + } + + for (int i = 0; i < a.GetSize(); i++) + { + if (a.GetAt(i) != b.GetAt(i)) + { + return false; + } + } + + return true; + } + + private List GetIndexPathsInSource(object source) + { + List paths = new List(); + Traverse(source, (TreeWalkNodeInfo node) => + { + if (!paths.Contains(node.Path)) + { + paths.Add(node.Path); + } + }); + + Log.Comment("All Paths in source.."); + foreach (var path in paths) + { + Log.Comment(path.ToString()); + } + Log.Comment("done."); + + return paths; + } + + private static void Traverse(object root, Action nodeAction) + { + var pendingNodes = new Stack(); + IndexPath current = Path(null); + pendingNodes.Push(new TreeWalkNodeInfo() { Current = root, Path = current }); + + while (pendingNodes.Count > 0) + { + var currentNode = pendingNodes.Pop(); + var currentObject = currentNode.Current as IList; + + if (currentObject != null) + { + for (int i = currentObject.Count - 1; i >= 0; i--) + { + var child = currentObject[i]; + List path = new List(); + for (int idx = 0; idx < currentNode.Path.GetSize(); idx++) + { + path.Add(currentNode.Path.GetAt(idx)); + } + + path.Add(i); + var childPath = IndexPath.CreateFromIndices(path); + if (child != null) + { + pendingNodes.Push(new TreeWalkNodeInfo() { Current = child, Path = childPath }); + } + } + } + + nodeAction(currentNode); + } + } + + private bool Contains(List list, IndexPath index) + { + bool contains = false; + foreach (var item in list) + { + if (item.CompareTo(index) == 0) + { + contains = true; + break; + } + } + + return contains; + } + + public static AvaloniaList CreateNestedData(int levels = 3, int groupsAtLevel = 5, int countAtLeaf = 10) + { + var nextData = 0; + return CreateNestedData(levels, groupsAtLevel, countAtLeaf, ref nextData); + } + + public static AvaloniaList CreateNestedData( + int levels, + int groupsAtLevel, + int countAtLeaf, + ref int nextData) + { + var data = new AvaloniaList(); + if (levels != 0) + { + for (int i = 0; i < groupsAtLevel; i++) + { + data.Add(CreateNestedData(levels - 1, groupsAtLevel, countAtLeaf, ref nextData)); + } + } + else + { + for (int i = 0; i < countAtLeaf; i++) + { + data.Add(nextData++); + } + } + + return data; + } + + static IndexPath Path(params int[] path) + { + return IndexPath.CreateFromIndices(path); + } + + private static int _nextData = 0; + private struct TreeWalkNodeInfo + { + public object Current { get; set; } + + public IndexPath Path { get; set; } + } + + private static class RunOnUIThread + { + public static void Execute(Action a) => a(); + } + + private class LogWrapper + { + private readonly ITestOutputHelper _output; + public LogWrapper(ITestOutputHelper output) => _output = output; + public void Comment(string s) => _output.WriteLine(s); + } + } + + class CustomSelectionModel : SelectionModel + { + public int IntProperty + { + get { return _intProperty; } + set + { + _intProperty = value; + OnPropertyChanged("IntProperty"); + } + } + + private int _intProperty; + } +}