Browse Source

Ported SelectionModel and friends from WinUI.

pull/3469/head
Steven Kirk 6 years ago
parent
commit
6bd7b4f335
  1. 98
      src/Avalonia.Controls/IndexPath.cs
  2. 61
      src/Avalonia.Controls/IndexRange.cs
  3. 55
      src/Avalonia.Controls/SelectedItems.cs
  4. 710
      src/Avalonia.Controls/SelectionModel.cs
  5. 31
      src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs
  6. 15
      src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs
  7. 811
      src/Avalonia.Controls/SelectionNode.cs
  8. 183
      src/Avalonia.Controls/Utils/SelectionTreeHelper.cs
  9. 1314
      tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs

98
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<IndexPath>
{
private readonly List<int> _path = new List<int>();
internal IndexPath(int index)
{
_path.Add(index);
}
internal IndexPath(int groupIndex, int itemIndex)
{
_path.Add(groupIndex);
_path.Add(itemIndex);
}
internal IndexPath(IEnumerable<int> 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<int>(_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<int> indices) => new IndexPath(indices);
}
}

61
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);
}
}
}

55
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<T> : IReadOnlyList<T>
{
private readonly List<SelectedItemInfo> _infos;
private readonly Func<List<SelectedItemInfo>, int, T> _getAtImpl;
private int _totalCount;
public SelectedItems(
List<SelectedItemInfo> infos,
Func<List<SelectedItemInfo>, 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<T> GetEnumerator()
{
for (var i = 0; i < _totalCount; ++i)
{
yield return this[i];
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}

710
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<IndexPath> _selectedIndicesCached;
private IReadOnlyList<object> _selectedItemsCached;
private SelectionModelChildrenRequestedEventArgs _childrenRequestedEventArgs;
private SelectionModelSelectionChangedEventArgs _selectionChangedEventArgs;
public event EventHandler<SelectionModelChildrenRequestedEventArgs> ChildrenRequested;
public event PropertyChangedEventHandler PropertyChanged;
public event EventHandler<SelectionModelSelectionChangedEventArgs> 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<int>();
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<object> SelectedItems
{
get
{
if (_selectedItemsCached == null)
{
var selectedInfos = new List<SelectedItemInfo>();
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<object> (
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<IndexPath> SelectedIndices
{
get
{
if (_selectedIndicesCached == null)
{
var selectedInfos = new List<SelectedItemInfo>();
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<IndexPath>(
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<object>)
{
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; }
}
}
}

31
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;
}
}
}

15
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
{
}
}

811
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
{
/// <summary>
/// Tracks nested selection.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal class SelectionNode : IDisposable
{
private readonly SelectionModel _manager;
private readonly List<SelectionNode> _childrenNodes = new List<SelectionNode>();
private readonly SelectionNode _parent;
private readonly List<IndexRange> _selected = new List<IndexRange>();
private object _source;
private ItemsSourceView _dataSource;
private int _selectedCount;
private List<int> _selectedIndicesCached = new List<int>();
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<int>(); ;
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<int> 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<IndexRange>();
var toAdd = new List<IndexRange>();
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<IndexRange>();
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
}
}
}

183
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<SelectionNode, IndexPath, int, int> 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<TreeWalkNodeInfo> nodeAction)
{
var pendingNodes = new List<TreeWalkNodeInfo>();
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<TreeWalkNodeInfo> nodeAction)
{
var pendingNodes = new List<TreeWalkNodeInfo>();
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<int>();
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; }
};
}
}

1314
tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs

File diff suppressed because it is too large
Loading…
Cancel
Save