From 85f205a94f33384348df13bcc517b5e64b45dc36 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sun, 12 Jan 2020 20:32:17 +0100 Subject: [PATCH 01/52] Failing unit tests for SelectingItemsControl events --- .../SelectingItemsControlTests_Multiple.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 952a00a14e..492e1744f8 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -683,6 +683,57 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); } + [Fact] + public void Ctrl_Selecting_Raises_SelectionChanged_Events() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Qux" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + SelectionChangedEventArgs receivedArgs = null; + + target.SelectionChanged += (_, args) => receivedArgs = null; + + void VerifyAdded(string selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(new[] { selection }, receivedArgs.AddedItems); + Assert.Empty(receivedArgs.RemovedItems); + } + + void VerifyRemoved(string selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(new[] { selection }, receivedArgs.RemovedItems); + Assert.Empty(receivedArgs.AddedItems); + } + + _helper.Click((Interactive)target.Presenter.Panel.Children[1]); + + VerifyAdded("Bar"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control); + + VerifyAdded("Baz"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control); + + VerifyAdded("Qux"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control); + + VerifyRemoved("Bar"); + } + [Fact] public void Ctrl_Selecting_SelectedItem_With_Multiple_Selection_Active_Sets_SelectedItem_To_Next_Selection() { @@ -797,6 +848,52 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target)); } + [Fact] + public void Shift_Selecting_Raises_SelectionChanged_Events() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Qux" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + SelectionChangedEventArgs receivedArgs = null; + + target.SelectionChanged += (_, args) => receivedArgs = null; + + void VerifyAdded(params string[] selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(selection, receivedArgs.AddedItems); + Assert.Empty(receivedArgs.RemovedItems); + } + + void VerifyRemoved(string selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(new[] { selection }, receivedArgs.RemovedItems); + Assert.Empty(receivedArgs.AddedItems); + } + + _helper.Click((Interactive)target.Presenter.Panel.Children[1]); + + VerifyAdded("Bar"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Shift); + + VerifyAdded("Baz" ,"Qux"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Shift); + + VerifyRemoved("Qux"); + } + [Fact] public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order() { @@ -845,6 +942,30 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal("Foo", target.SelectedItem); } + [Fact] + public void SelectAll_Raises_SelectionChanged_Event() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + SelectionChangedEventArgs receivedArgs = null; + + target.SelectionChanged += (_, e) => receivedArgs = e; + + target.SelectAll(); + + Assert.NotNull(receivedArgs); + Assert.Equal(target.Items, receivedArgs.AddedItems); + Assert.Empty(receivedArgs.RemovedItems); + } + [Fact] public void UnselectAll_Clears_SelectedIndex_And_SelectedItem() { From c6fceb84546aa84270c01192dbee05e9fd777975 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sun, 12 Jan 2020 20:55:08 +0100 Subject: [PATCH 02/52] Verify that removing items raises events as well. --- .../Primitives/SelectingItemsControlTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index d819581000..2943c2cb32 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -522,10 +522,19 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(items[1], target.SelectedItem); Assert.Equal(1, target.SelectedIndex); + SelectionChangedEventArgs receivedArgs = null; + + target.SelectionChanged += (_, args) => receivedArgs = null; + + var removed = items[1]; + items.RemoveAt(1); Assert.Null(target.SelectedItem); Assert.Equal(-1, target.SelectedIndex); + Assert.NotNull(receivedArgs); + Assert.Empty(receivedArgs.AddedItems); + Assert.Equal(new[] { removed }, receivedArgs.RemovedItems); } [Fact] From 41ab586d4617c443c06bd7ab73ba9870db62fb71 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Fri, 17 Jan 2020 20:52:45 +0100 Subject: [PATCH 03/52] Fix unit tests. --- .../Primitives/SelectingItemsControlTests.cs | 2 +- .../Primitives/SelectingItemsControlTests_Multiple.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 2943c2cb32..e5bbebdec2 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -524,7 +524,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectionChangedEventArgs receivedArgs = null; - target.SelectionChanged += (_, args) => receivedArgs = null; + target.SelectionChanged += (_, args) => receivedArgs = args; var removed = items[1]; diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 492e1744f8..3a8c98983f 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -698,7 +698,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectionChangedEventArgs receivedArgs = null; - target.SelectionChanged += (_, args) => receivedArgs = null; + target.SelectionChanged += (_, args) => receivedArgs = args; void VerifyAdded(string selection) { @@ -863,7 +863,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectionChangedEventArgs receivedArgs = null; - target.SelectionChanged += (_, args) => receivedArgs = null; + target.SelectionChanged += (_, args) => receivedArgs = args; void VerifyAdded(params string[] selection) { @@ -957,7 +957,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectionChangedEventArgs receivedArgs = null; - target.SelectionChanged += (_, e) => receivedArgs = e; + target.SelectionChanged += (_, args) => receivedArgs = args; target.SelectAll(); From 640a5c5d8bbf25407a890cb8472b84ea215df006 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 14 Jan 2020 12:53:33 +0100 Subject: [PATCH 04/52] Swap order of parameters to event args. As described in #3437 the argument ordering was different to WPF/UWP causing bugs in some code ported from WPF/UWP. Use the same argument ordering as WPF/UWP. Fixes #3437 --- src/Avalonia.Controls/AutoCompleteBox.cs | 2 +- src/Avalonia.Controls/Calendar/DatePicker.cs | 2 +- src/Avalonia.Controls/Calendar/SelectedDatesCollection.cs | 4 ++-- src/Avalonia.Controls/Primitives/SelectingItemsControl.cs | 8 ++++---- .../Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs | 4 ++-- src/Avalonia.Controls/SelectionChangedEventArgs.cs | 6 +++--- src/Avalonia.Controls/TreeView.cs | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index 6deddef0d0..bf177d64cd 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -683,7 +683,7 @@ namespace Avalonia.Controls added.Add(e.NewValue); } - OnSelectionChanged(new SelectionChangedEventArgs(SelectionChangedEvent, added, removed)); + OnSelectionChanged(new SelectionChangedEventArgs(SelectionChangedEvent, removed, added)); } /// diff --git a/src/Avalonia.Controls/Calendar/DatePicker.cs b/src/Avalonia.Controls/Calendar/DatePicker.cs index b4d4fed9fc..445182a6c1 100644 --- a/src/Avalonia.Controls/Calendar/DatePicker.cs +++ b/src/Avalonia.Controls/Calendar/DatePicker.cs @@ -788,7 +788,7 @@ namespace Avalonia.Controls removedItems.Add(removedDate.Value); } - handler(this, new SelectionChangedEventArgs(SelectingItemsControl.SelectionChangedEvent, addedItems, removedItems)); + handler(this, new SelectionChangedEventArgs(SelectingItemsControl.SelectionChangedEvent, removedItems, addedItems)); } } private void OnCalendarClosed(EventArgs e) diff --git a/src/Avalonia.Controls/Calendar/SelectedDatesCollection.cs b/src/Avalonia.Controls/Calendar/SelectedDatesCollection.cs index e6e0cf7c4f..3573c67057 100644 --- a/src/Avalonia.Controls/Calendar/SelectedDatesCollection.cs +++ b/src/Avalonia.Controls/Calendar/SelectedDatesCollection.cs @@ -49,7 +49,7 @@ namespace Avalonia.Controls.Primitives private void InvokeCollectionChanged(System.Collections.IList removedItems, System.Collections.IList addedItems) { - _owner.OnSelectedDatesCollectionChanged(new SelectionChangedEventArgs(null, addedItems, removedItems)); + _owner.OnSelectedDatesCollectionChanged(new SelectionChangedEventArgs(null, removedItems, addedItems)); } /// @@ -119,7 +119,7 @@ namespace Avalonia.Controls.Primitives } } - _owner.OnSelectedDatesCollectionChanged(new SelectionChangedEventArgs(null, _addedItems, _owner.RemovedItems)); + _owner.OnSelectedDatesCollectionChanged(new SelectionChangedEventArgs(null, _owner.RemovedItems, _addedItems)); _owner.RemovedItems.Clear(); _owner.UpdateMonths(); _isRangeAdded = false; diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 69da211aa4..6bc4e71508 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -939,8 +939,8 @@ namespace Avalonia.Controls.Primitives { var changed = new SelectionChangedEventArgs( SelectionChangedEvent, - added ?? Empty, - removed ?? Empty); + removed ?? Empty, + added ?? Empty); RaiseEvent(changed); } } @@ -1055,8 +1055,8 @@ namespace Avalonia.Controls.Primitives var e = new SelectionChangedEventArgs( SelectionChangedEvent, - added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty(), - removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty()); + removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty(), + added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty()); RaiseEvent(e); } diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs index 7ca68140b2..bf1b80f947 100644 --- a/src/Avalonia.Controls/Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs +++ b/src/Avalonia.Controls/Repeater/ItemsRepeaterElementIndexChangedEventArgs.cs @@ -12,11 +12,11 @@ namespace Avalonia.Controls /// public class ItemsRepeaterElementIndexChangedEventArgs : EventArgs { - internal ItemsRepeaterElementIndexChangedEventArgs(IControl element, int newIndex, int oldIndex) + internal ItemsRepeaterElementIndexChangedEventArgs(IControl element, int oldIndex, int newIndex) { Element = element; - NewIndex = newIndex; OldIndex = oldIndex; + NewIndex = newIndex; } /// diff --git a/src/Avalonia.Controls/SelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionChangedEventArgs.cs index 267ddf036e..fb412a64a8 100644 --- a/src/Avalonia.Controls/SelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionChangedEventArgs.cs @@ -16,13 +16,13 @@ namespace Avalonia.Controls /// Initializes a new instance of the class. /// /// The event being raised. - /// The items added to the selection. /// The items removed from the selection. - public SelectionChangedEventArgs(RoutedEvent routedEvent, IList addedItems, IList removedItems) + /// The items added to the selection. + public SelectionChangedEventArgs(RoutedEvent routedEvent, IList removedItems, IList addedItems) : base(routedEvent) { - AddedItems = addedItems; RemovedItems = removedItems; + AddedItems = addedItems; } /// diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 738d9d0b51..3a7ad97763 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -324,8 +324,8 @@ namespace Avalonia.Controls { var changed = new SelectionChangedEventArgs( SelectingItemsControl.SelectionChangedEvent, - added ?? Empty, - removed ?? Empty); + removed ?? Empty, + added ?? Empty); RaiseEvent(changed); } } From 6bd7b4f3359b43c2d2e329942643ae98ab245dcd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 25 Jan 2020 12:09:35 +0100 Subject: [PATCH 05/52] Ported SelectionModel and friends from WinUI. --- src/Avalonia.Controls/IndexPath.cs | 98 ++ src/Avalonia.Controls/IndexRange.cs | 61 + src/Avalonia.Controls/SelectedItems.cs | 55 + src/Avalonia.Controls/SelectionModel.cs | 710 +++++++++ ...electionModelChildrenRequestedEventArgs.cs | 31 + ...SelectionModelSelectionChangedEventArgs.cs | 15 + src/Avalonia.Controls/SelectionNode.cs | 811 ++++++++++ .../Utils/SelectionTreeHelper.cs | 183 +++ .../SelectionModelTests.cs | 1314 +++++++++++++++++ 9 files changed, 3278 insertions(+) create mode 100644 src/Avalonia.Controls/IndexPath.cs create mode 100644 src/Avalonia.Controls/IndexRange.cs create mode 100644 src/Avalonia.Controls/SelectedItems.cs create mode 100644 src/Avalonia.Controls/SelectionModel.cs create mode 100644 src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs create mode 100644 src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs create mode 100644 src/Avalonia.Controls/SelectionNode.cs create mode 100644 src/Avalonia.Controls/Utils/SelectionTreeHelper.cs create mode 100644 tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs 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; + } +} From e4c6c858266295ca305f546c89ea02cb44973a1c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 25 Jan 2020 15:09:01 +0100 Subject: [PATCH 06/52] Refactor IndexPath. - #nullable enable - Make it a `readonly struct` - Most of the time it will only hold a single `int`, so optimize for the common case by having an `int _index` field - Make `_path` an array rather than a list as it will be fixed-size - Implement equality - Implement operators --- src/Avalonia.Controls/IndexPath.cs | 115 +++++++++++++++--- src/Avalonia.Controls/SelectionModel.cs | 8 +- .../Utils/SelectionTreeHelper.cs | 2 - .../IndexPathTests.cs | 81 ++++++++++++ 4 files changed, 180 insertions(+), 26 deletions(-) create mode 100644 tests/Avalonia.Controls.UnitTests/IndexPathTests.cs diff --git a/src/Avalonia.Controls/IndexPath.cs b/src/Avalonia.Controls/IndexPath.cs index 1251a022da..32d8c2b051 100644 --- a/src/Avalonia.Controls/IndexPath.cs +++ b/src/Avalonia.Controls/IndexPath.cs @@ -5,42 +5,68 @@ using System; using System.Collections.Generic; -using System.Text; +using System.Linq; + +#nullable enable namespace Avalonia.Controls { - public sealed class IndexPath : IComparable + public readonly struct IndexPath : IComparable, IEquatable { - private readonly List _path = new List(); + public static readonly IndexPath Unselected = default; + + private readonly int _index; + private readonly int[]? _path; - internal IndexPath(int index) + public IndexPath(int index) { - _path.Add(index); + _index = index + 1; + _path = null; } - internal IndexPath(int groupIndex, int itemIndex) + public IndexPath(int groupIndex, int itemIndex) { - _path.Add(groupIndex); - _path.Add(itemIndex); + _index = 0; + _path = new[] { groupIndex, itemIndex }; } - internal IndexPath(IEnumerable indices) + public IndexPath(IEnumerable? indices) { if (indices != null) { - _path.AddRange(indices); + _index = 0; + _path = indices.ToArray(); + } + else + { + _index = 0; + _path = null; } } - public int GetSize() => _path.Count; - public int GetAt(int index) => _path[index]; + private IndexPath(int[] basePath, int index) + { + basePath = basePath ?? throw new ArgumentNullException(nameof(basePath)); + + _index = 0; + _path = new int[basePath.Length + 1]; + Array.Copy(basePath, _path, basePath.Length); + _path[basePath.Length] = index; + } + + public int GetSize() => _path?.Length ?? (_index == 0 ? 0 : 1); + + public int GetAt(int index) + { + return _path?[index] ?? (_index - 1); + } public int CompareTo(IndexPath other) { var rhsPath = other; int compareResult = 0; - int lhsCount = _path.Count; - int rhsCount = rhsPath._path.Count; + int lhsCount = GetSize(); + int rhsCount = rhsPath.GetSize(); if (lhsCount == 0 || rhsCount == 0) { @@ -52,12 +78,12 @@ namespace Avalonia.Controls // 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]) + if (GetAt(i) < rhsPath.GetAt(i)) { compareResult = -1; break; } - else if (_path[i] > rhsPath._path[i]) + else if (GetAt(i) > rhsPath.GetAt(i)) { compareResult = 1; break; @@ -78,14 +104,34 @@ namespace Avalonia.Controls public IndexPath CloneWithChildIndex(int childIndex) { - var newPath = new List(_path); - newPath.Add(childIndex); - return new IndexPath(newPath); + if (_path != null) + { + return new IndexPath(_path, childIndex); + } + else if (_index != 0) + { + return new IndexPath(_index - 1, childIndex); + } + else + { + return new IndexPath(childIndex); + } } public override string ToString() { - return "R." + string.Join(".", _path); + if (_path != null) + { + return "R" + string.Join(".", _path); + } + else if (_index != 0) + { + return "R" + (_index - 1); + } + else + { + return "R"; + } } public static IndexPath CreateFrom(int index) => new IndexPath(index); @@ -94,5 +140,34 @@ namespace Avalonia.Controls public static IndexPath CreateFromIndices(IList indices) => new IndexPath(indices); + public override bool Equals(object obj) => obj is IndexPath other && Equals(other); + + public bool Equals(IndexPath other) => CompareTo(other) == 0; + + public override int GetHashCode() + { + var hashCode = -504981047; + + if (_path != null) + { + foreach (var i in _path) + { + hashCode = hashCode * -1521134295 + i.GetHashCode(); + } + } + else + { + hashCode = hashCode * -1521134295 + _index.GetHashCode(); + } + + return hashCode; + } + + public static bool operator <(IndexPath x, IndexPath y) { return x.CompareTo(y) < 0; } + public static bool operator >(IndexPath x, IndexPath y) { return x.CompareTo(y) > 0; } + public static bool operator <=(IndexPath x, IndexPath y) { return x.CompareTo(y) <= 0; } + public static bool operator >=(IndexPath x, IndexPath y) { return x.CompareTo(y) >= 0; } + public static bool operator ==(IndexPath x, IndexPath y) { return x.CompareTo(y) == 0; } + public static bool operator !=(IndexPath x, IndexPath y) { return x.CompareTo(y) != 0; } } } diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 6b48e02a21..ea3a09d4e7 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -72,7 +72,7 @@ namespace Avalonia.Controls { get { - IndexPath anchor = null; + IndexPath anchor = default; if (_rootNode.AnchorIndex >= 0) { @@ -113,7 +113,7 @@ namespace Avalonia.Controls { get { - IndexPath selectedIndex = null; + IndexPath selectedIndex = default; var selectedIndices = SelectedIndices; if (selectedIndices?.Count > 0) @@ -248,7 +248,7 @@ namespace Avalonia.Controls (infos, index) => // callback for GetAt(index) { var currentIndex = 0; - IndexPath path = null; + IndexPath path = default; foreach (var info in infos) { @@ -505,7 +505,7 @@ namespace Avalonia.Controls if (resetAnchor) { - AnchorIndex = null; + AnchorIndex = default; } if (raiseSelectionChanged) diff --git a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs index 52e29da0e5..38b1dde5d7 100644 --- a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs +++ b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs @@ -157,7 +157,6 @@ namespace Avalonia.Controls.Utils 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; @@ -167,7 +166,6 @@ namespace Avalonia.Controls.Utils public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath) { node = node ?? throw new ArgumentNullException(nameof(node)); - indexPath = indexPath ?? throw new ArgumentNullException(nameof(indexPath)); Node = node; Path = indexPath; diff --git a/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs b/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs new file mode 100644 index 0000000000..190e92ed5e --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs @@ -0,0 +1,81 @@ +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class IndexPathTests + { + [Fact] + public void Simple_Index() + { + var a = new IndexPath(1); + + Assert.Equal(1, a.GetSize()); + Assert.Equal(1, a.GetAt(0)); + } + + [Fact] + public void Equal_Paths() + { + var a = new IndexPath(1); + var b = new IndexPath(1); + + Assert.True(a == b); + Assert.False(a != b); + Assert.True(a.Equals(b)); + Assert.Equal(0, a.CompareTo(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Unequal_Paths() + { + var a = new IndexPath(1); + var b = new IndexPath(2); + + Assert.False(a == b); + Assert.True(a != b); + Assert.False(a.Equals(b)); + Assert.Equal(-1, a.CompareTo(b)); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Equal_Null_Path() + { + var a = new IndexPath(null); + var b = new IndexPath(null); + + Assert.True(a == b); + Assert.False(a != b); + Assert.True(a.Equals(b)); + Assert.Equal(0, a.CompareTo(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Unequal_Null_Path() + { + var a = new IndexPath(null); + var b = new IndexPath(2); + + Assert.False(a == b); + Assert.True(a != b); + Assert.False(a.Equals(b)); + Assert.Equal(-1, a.CompareTo(b)); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Default_Is_Null_Path() + { + var a = new IndexPath(null); + var b = default(IndexPath); + + Assert.True(a == b); + Assert.False(a != b); + Assert.True(a.Equals(b)); + Assert.Equal(0, a.CompareTo(b)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + } +} From d615cdebcb7491f984589936fe5e3d775315001e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 25 Jan 2020 19:32:43 +0100 Subject: [PATCH 07/52] Add nullability annotations to SelectionModel etc. And ran a few of VS' lightbulb suggestions. --- src/Avalonia.Controls/IndexRange.cs | 4 +- src/Avalonia.Controls/SelectedItems.cs | 9 +- src/Avalonia.Controls/SelectionModel.cs | 56 ++++++------ ...electionModelChildrenRequestedEventArgs.cs | 44 ++++++++-- ...SelectionModelSelectionChangedEventArgs.cs | 4 +- src/Avalonia.Controls/SelectionNode.cs | 87 ++++++++++--------- .../Utils/SelectionTreeHelper.cs | 10 ++- 7 files changed, 122 insertions(+), 92 deletions(-) diff --git a/src/Avalonia.Controls/IndexRange.cs b/src/Avalonia.Controls/IndexRange.cs index f3820c087a..b1a112ab39 100644 --- a/src/Avalonia.Controls/IndexRange.cs +++ b/src/Avalonia.Controls/IndexRange.cs @@ -3,9 +3,7 @@ // // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. -using System; -using System.Collections.Generic; -using System.Text; +#nullable enable namespace Avalonia.Controls { diff --git a/src/Avalonia.Controls/SelectedItems.cs b/src/Avalonia.Controls/SelectedItems.cs index 036788e7b2..af43742670 100644 --- a/src/Avalonia.Controls/SelectedItems.cs +++ b/src/Avalonia.Controls/SelectedItems.cs @@ -8,13 +8,14 @@ using System.Collections; using System.Collections.Generic; using SelectedItemInfo = Avalonia.Controls.SelectionModel.SelectedItemInfo; +#nullable enable + namespace Avalonia.Controls { internal class SelectedItems : IReadOnlyList { private readonly List _infos; private readonly Func, int, T> _getAtImpl; - private int _totalCount; public SelectedItems( List infos, @@ -29,7 +30,7 @@ namespace Avalonia.Controls if (node != null) { - _totalCount += node.SelectedCount; + Count += node.SelectedCount; } else { @@ -40,11 +41,11 @@ namespace Avalonia.Controls public T this[int index] => _getAtImpl(_infos, index); - public int Count => _totalCount; + public int Count { get; } public IEnumerator GetEnumerator() { - for (var i = 0; i < _totalCount; ++i) + for (var i = 0; i < Count; ++i) { yield return this[i]; } diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index ea3a09d4e7..34d5f78434 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -8,20 +8,22 @@ using System.Collections.Generic; using System.ComponentModel; using Avalonia.Controls.Utils; +#nullable enable + namespace Avalonia.Controls { public class SelectionModel : INotifyPropertyChanged, IDisposable { - private SelectionNode _rootNode; + private readonly SelectionNode _rootNode; private bool _singleSelect; - private IReadOnlyList _selectedIndicesCached; - private IReadOnlyList _selectedItemsCached; - private SelectionModelChildrenRequestedEventArgs _childrenRequestedEventArgs; - private SelectionModelSelectionChangedEventArgs _selectionChangedEventArgs; + 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 event EventHandler? ChildrenRequested; + public event PropertyChangedEventHandler? PropertyChanged; + public event EventHandler? SelectionChanged; public SelectionModel() { @@ -29,9 +31,9 @@ namespace Avalonia.Controls SharedLeafNode = new SelectionNode(this, null); } - public object Source + public object? Source { - get => _rootNode.Source; + get => _rootNode?.Source; set { ClearSelection(resetAnchor: true, raiseSelectionChanged: false); @@ -74,10 +76,10 @@ namespace Avalonia.Controls { IndexPath anchor = default; - if (_rootNode.AnchorIndex >= 0) + if (_rootNode?.AnchorIndex >= 0) { var path = new List(); - var current = _rootNode; + SelectionNode? current = _rootNode; while (current?.AnchorIndex >= 0) { @@ -136,11 +138,11 @@ namespace Avalonia.Controls } } - public object SelectedItem + public object? SelectedItem { get { - object item = null; + object? item = null; var selectedItems = SelectedItems; if (selectedItems?.Count > 0) @@ -152,7 +154,7 @@ namespace Avalonia.Controls } } - public IReadOnlyList SelectedItems + public IReadOnlyList SelectedItems { get { @@ -179,12 +181,12 @@ namespace Avalonia.Controls // 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 ( + var selectedItems = new SelectedItems ( selectedInfos, (infos, index) => { var currentIndex = 0; - object item = null; + object? item = null; foreach (var info in infos) { @@ -197,7 +199,7 @@ namespace Avalonia.Controls if (index >= currentIndex && index < currentIndex + currentCount) { var targetIndex = node.SelectedIndices[index - currentIndex]; - item = node.ItemsSourceView.GetAt(targetIndex); + item = node.ItemsSourceView!.GetAt(targetIndex); break; } @@ -289,8 +291,6 @@ namespace Avalonia.Controls { ClearSelection(resetAnchor: false, raiseSelectionChanged: false); _rootNode?.Dispose(); - _rootNode = null; - SharedLeafNode = null; _selectedIndicesCached = null; _selectedItemsCached = null; } @@ -349,7 +349,7 @@ namespace Avalonia.Controls { var path = index; var isRealized = true; - var node = _rootNode; + SelectionNode? node = _rootNode; for (int i = 0; i < path.GetSize() - 1; i++) { @@ -370,11 +370,11 @@ namespace Avalonia.Controls var size = path.GetSize(); if (size == 0) { - isSelected = SelectionNode.ConvertToNullableBool(node.EvaluateIsSelectedBasedOnChildrenNodes()); + isSelected = SelectionNode.ConvertToNullableBool(node!.EvaluateIsSelectedBasedOnChildrenNodes()); } else { - isSelected = node.IsSelectedWithPartial(path.GetAt(size - 1)); + isSelected = node!.IsSelectedWithPartial(path.GetAt(size - 1)); } } @@ -457,9 +457,9 @@ namespace Avalonia.Controls OnSelectionChanged(); } - internal object ResolvePath(object data, SelectionNode sourceNode) + internal object? ResolvePath(object data, SelectionNode sourceNode) { - object resolved = null; + object? resolved = null; // Raise ChildrenRequested event if there is a handler if (ChildrenRequested != null) @@ -565,7 +565,7 @@ namespace Avalonia.Controls } var childNode = _rootNode.GetAt(groupIndex, realizeChild: true); - var selected = childNode.Select(itemIndex, select); + var selected = childNode!.Select(itemIndex, select); if (selected) { @@ -653,7 +653,7 @@ namespace Avalonia.Controls var selected = false; for (int groupIdx = startGroupIndex; groupIdx <= endGroupIndex; groupIdx++) { - var groupNode = _rootNode.GetAt(groupIdx, realizeChild: true); + 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); @@ -688,7 +688,7 @@ namespace Avalonia.Controls if (info.Node.DataCount == 0) { // Select only leaf nodes - info.ParentNode.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); + info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); } }); diff --git a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs index c5571b9f74..aa5a9b5cad 100644 --- a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs @@ -4,27 +4,53 @@ // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. using System; -using System.Collections.Generic; -using System.Text; + +#nullable enable namespace Avalonia.Controls { public class SelectionModelChildrenRequestedEventArgs : EventArgs { - private SelectionNode _sourceNode; + private object? _source; + private SelectionNode? _sourceNode; internal SelectionModelChildrenRequestedEventArgs(object source, SelectionNode sourceNode) { - Initialize(source, sourceNode); + _source = source; + _sourceNode = sourceNode; } - public object Children { get; set; } - public object Source { get; private set; } - public IndexPath SourceIndex => _sourceNode.IndexPath; + public object? Children { get; set; } + + public object Source + { + get + { + if (_source == null) + { + throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs)); + } + + return _source; + } + } + + public IndexPath SourceIndex + { + get + { + if (_sourceNode == null) + { + throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs)); + } + + return _sourceNode.IndexPath; + } + } - internal void Initialize(object source, SelectionNode sourceNode) + internal void Initialize(object? source, SelectionNode? sourceNode) { - Source = source; + _source = source; _sourceNode = sourceNode; } } diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index 8c9e0343de..c8edc1f8ae 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -4,8 +4,8 @@ // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. using System; -using System.Collections.Generic; -using System.Text; + +#nullable enable namespace Avalonia.Controls { diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index f5d93681c4..363eb35b94 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -8,6 +8,8 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +#nullable enable + namespace Avalonia.Controls { /// @@ -22,17 +24,14 @@ namespace Avalonia.Controls internal class SelectionNode : IDisposable { private readonly SelectionModel _manager; - private readonly List _childrenNodes = new List(); - private readonly SelectionNode _parent; + 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 readonly List _selectedIndicesCached = new List(); + private object? _source; private bool _selectedIndicesCacheIsValid; - private int _realizedChildrenNodeCount; - public SelectionNode(SelectionModel manager, SelectionNode parent) + public SelectionNode(SelectionModel manager, SelectionNode? parent) { _manager = manager; _parent = parent; @@ -40,7 +39,7 @@ namespace Avalonia.Controls public int AnchorIndex { get; set; } = -1; - public object Source + public object? Source { get => _source; set @@ -60,7 +59,7 @@ namespace Avalonia.Controls newDataSource = new ItemsSourceView((IEnumerable)value); } - _dataSource = newDataSource; + ItemsSourceView = newDataSource; HookupCollectionChangedHandler(); OnSelectionChanged(); @@ -68,10 +67,10 @@ namespace Avalonia.Controls } } - public ItemsSourceView ItemsSourceView => _dataSource; - public int DataCount => _dataSource?.Count ?? 0; + public ItemsSourceView? ItemsSourceView { get; private set; } + public int DataCount => ItemsSourceView?.Count ?? 0; public int ChildrenNodeCount => _childrenNodes.Count; - public int RealizedChildrenNodeCount => _realizedChildrenNodeCount; + public int RealizedChildrenNodeCount { get; private set; } public IndexPath IndexPath { @@ -101,17 +100,22 @@ namespace Avalonia.Controls // 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) + public SelectionNode? GetAt(int index, bool realizeChild) { - SelectionNode child = null; + SelectionNode? child = null; if (realizeChild) { + if (ItemsSourceView == null || index < 0 || index >= ItemsSourceView.Count) + { + throw new IndexOutOfRangeException(); + } + if (_childrenNodes.Count == 0) { - if (_dataSource != null) + if (ItemsSourceView != null) { - for (int i = 0; i < _dataSource.Count; i++) + for (int i = 0; i < ItemsSourceView.Count; i++) { _childrenNodes.Add(null); } @@ -120,7 +124,7 @@ namespace Avalonia.Controls if (_childrenNodes[index] == null) { - var childData = _dataSource.GetAt(index); + var childData = ItemsSourceView!.GetAt(index); if (childData != null) { @@ -142,7 +146,7 @@ namespace Avalonia.Controls } _childrenNodes[index] = child; - _realizedChildrenNodeCount++; + RealizedChildrenNodeCount++; } else { @@ -160,7 +164,7 @@ namespace Avalonia.Controls return child; } - public int SelectedCount => _selectedCount; + public int SelectedCount { get; private set; } public bool IsSelected(int index) { @@ -205,7 +209,7 @@ namespace Avalonia.Controls // Null -> Some descendents are selected and some are not public bool? IsSelectedWithPartial(int index) { - var selectionState = SelectionState.NotSelected; + SelectionState selectionState; if (_childrenNodes.Count == 0 || // no nodes realized _childrenNodes.Count <= index || // target node is not realized @@ -221,7 +225,7 @@ namespace Avalonia.Controls // 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(); + selectionState = targetNode!.EvaluateIsSelectedBasedOnChildrenNodes(); } return ConvertToNullableBool(selectionState); @@ -274,7 +278,7 @@ namespace Avalonia.Controls public void Dispose() { - _dataSource?.Dispose(); + ItemsSourceView?.Dispose(); UnhookCollectionChangedHandler(); } @@ -290,9 +294,9 @@ namespace Avalonia.Controls public void SelectAll() { - if (_dataSource != null) + if (ItemsSourceView != null) { - var size = _dataSource.Count; + var size = ItemsSourceView.Count; if (size > 0) { @@ -324,17 +328,17 @@ namespace Avalonia.Controls private void HookupCollectionChangedHandler() { - if (_dataSource != null) + if (ItemsSourceView != null) { - _dataSource.CollectionChanged += OnSourceListChanged; + ItemsSourceView.CollectionChanged += OnSourceListChanged; } } private void UnhookCollectionChangedHandler() { - if (_dataSource != null) + if (ItemsSourceView != null) { - _dataSource.CollectionChanged -= OnSourceListChanged; + ItemsSourceView.CollectionChanged -= OnSourceListChanged; } } @@ -353,11 +357,11 @@ namespace Avalonia.Controls { if (!IsSelected(i)) { - _selectedCount++; + SelectedCount++; } } - if (oldCount != _selectedCount) + if (oldCount != SelectedCount) { _selected.Add(addRange); @@ -370,18 +374,18 @@ namespace Avalonia.Controls private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged) { - int oldCount = _selectedCount; + 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--; + SelectedCount--; } } - if (oldCount != _selectedCount) + if (oldCount != SelectedCount) { // Build up a both a list of Ranges to remove and ranges to add var toRemove = new List(); @@ -448,7 +452,7 @@ namespace Avalonia.Controls OnSelectionChanged(); } - _selectedCount = 0; + SelectedCount = 0; AnchorIndex = -1; // This will throw away all the children SelectionNodes @@ -576,7 +580,7 @@ namespace Avalonia.Controls // Adjust the anchor if (AnchorIndex >= index) { - AnchorIndex = AnchorIndex + count; + AnchorIndex += count; } // Check if adding a node invalidated an ancestors @@ -610,7 +614,7 @@ namespace Avalonia.Controls bool selectionInvalidated = false; // Remove the items from the selection for leaf - if (ItemsSourceView.Count > 0) + if (ItemsSourceView!.Count > 0) { bool isSelected = false; @@ -650,7 +654,7 @@ namespace Avalonia.Controls { if (_childrenNodes[index] != null) { - _realizedChildrenNodeCount--; + RealizedChildrenNodeCount--; } _childrenNodes.RemoveAt(index); } @@ -659,14 +663,14 @@ namespace Avalonia.Controls //Adjust the anchor if (AnchorIndex >= index) { - AnchorIndex = AnchorIndex - count; + AnchorIndex -= count; } } else { // No more items in the list, clear ClearSelection(); - _realizedChildrenNodeCount = 0; + RealizedChildrenNodeCount = 0; selectionInvalidated = true; } @@ -719,7 +723,7 @@ namespace Avalonia.Controls public SelectionState EvaluateIsSelectedBasedOnChildrenNodes() { - SelectionState selectionState = SelectionState.NotSelected; + var selectionState = SelectionState.NotSelected; int realizedChildrenNodeCount = RealizedChildrenNodeCount; int selectedCount = SelectedCount; @@ -739,7 +743,6 @@ namespace Avalonia.Controls { // 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++) diff --git a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs index 38b1dde5d7..93102a7b5b 100644 --- a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs +++ b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs @@ -7,6 +7,8 @@ using System; using System.Collections.Generic; using System.Linq; +#nullable enable + namespace Avalonia.Controls.Utils { internal static class SelectionTreeHelper @@ -26,7 +28,7 @@ namespace Avalonia.Controls.Utils if (depth < path.GetSize() - 1) { - node = node.GetAt(childIndex, realizeChildren); + node = node.GetAt(childIndex, realizeChildren)!; } } } @@ -48,7 +50,7 @@ namespace Avalonia.Controls.Utils 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 child = nextNode.Node.GetAt(i, realizeChildren); var childPath = nextNode.Path.CloneWithChildIndex(i); if (child != null) { @@ -154,7 +156,7 @@ namespace Avalonia.Controls.Utils public struct TreeWalkNodeInfo { - public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath, SelectionNode parent) + public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath, SelectionNode? parent) { node = node ?? throw new ArgumentNullException(nameof(node)); @@ -174,7 +176,7 @@ namespace Avalonia.Controls.Utils public SelectionNode Node { get; } public IndexPath Path { get; } - public SelectionNode ParentNode { get; } + public SelectionNode? ParentNode { get; } }; } From 8640393a26f5d4ff786c56ee2ea092591e5b530e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 26 Jan 2020 09:59:48 +0100 Subject: [PATCH 08/52] Add `IndexRange` list add/remove methods. Add or remove index ranges from a list of index ranges, merging and splitting ranges as required. --- src/Avalonia.Controls/IndexRange.cs | 173 +++++++++- .../IndexRangeTests.cs | 307 ++++++++++++++++++ 2 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs diff --git a/src/Avalonia.Controls/IndexRange.cs b/src/Avalonia.Controls/IndexRange.cs index b1a112ab39..124f1e0500 100644 --- a/src/Avalonia.Controls/IndexRange.cs +++ b/src/Avalonia.Controls/IndexRange.cs @@ -3,12 +3,17 @@ // // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. +using System; +using System.Collections.Generic; + #nullable enable namespace Avalonia.Controls { - internal readonly struct IndexRange + internal readonly struct IndexRange : IEquatable { + private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue); + public IndexRange(int begin, int end) { // Accept out of order begin/end pairs, just swap them. @@ -25,11 +30,9 @@ namespace Avalonia.Controls public int Begin { get; } public int End { get; } + public int Count => (End - Begin) + 1; - public bool Contains(int index) - { - return index >= Begin && index <= End; - } + public bool Contains(int index) => index >= Begin && index <= End; public bool Split(int splitIndex, out IndexRange before, out IndexRange after) { @@ -54,6 +57,164 @@ namespace Avalonia.Controls public bool Intersects(IndexRange other) { return (Begin <= other.End) && (End >= other.Begin); - } + } + + public bool Adjacent(IndexRange other) + { + return Begin == other.End + 1 || End == other.Begin - 1; + } + + public override bool Equals(object? obj) + { + return obj is IndexRange range && Equals(range); + } + + public bool Equals(IndexRange other) + { + return Begin == other.Begin && End == other.End; + } + + public override int GetHashCode() + { + var hashCode = 1903003160; + hashCode = hashCode * -1521134295 + Begin.GetHashCode(); + hashCode = hashCode * -1521134295 + End.GetHashCode(); + return hashCode; + } + + public override string ToString() => $"[{Begin}..{End}]"; + + public static bool operator ==(IndexRange left, IndexRange right) => left.Equals(right); + public static bool operator !=(IndexRange left, IndexRange right) => !(left == right); + + public static int Add( + IList ranges, + IndexRange range, + IList? added = null) + { + var result = 0; + + for (var i = 0; i < ranges.Count && range != s_invalid; ++i) + { + var existing = ranges[i]; + + if (range.Intersects(existing) || range.Adjacent(existing)) + { + if (range.Begin < existing.Begin) + { + var add = new IndexRange(range.Begin, existing.Begin - 1); + ranges[i] = new IndexRange(range.Begin, existing.End); + added?.Add(add); + result += add.Count; + } + + range = range.End <= existing.End ? + s_invalid : + new IndexRange(existing.End + 1, range.End); + } + else if (range.End < existing.Begin) + { + ranges.Insert(i, range); + added?.Add(range); + result += range.Count; + range = s_invalid; + } + } + + if (range != s_invalid) + { + ranges.Add(range); + added?.Add(range); + result += range.Count; + } + + MergeRanges(ranges); + return result; + } + + public static int Remove( + IList ranges, + IndexRange range, + IList? removed = null) + { + var result = 0; + + for (var i = 0; i < ranges.Count; ++i) + { + var existing = ranges[i]; + + if (range.Intersects(existing)) + { + if (range.Begin <= existing.Begin && range.End >= existing.End) + { + ranges.RemoveAt(i--); + removed?.Add(existing); + result += existing.Count; + } + else if (range.Begin > existing.Begin && range.End >= existing.End) + { + ranges[i] = new IndexRange(existing.Begin, range.Begin - 1); + removed?.Add(new IndexRange(range.Begin, existing.End)); + result += existing.End - (range.Begin - 1); + } + else if (range.Begin > existing.Begin && range.End < existing.End) + { + ranges[i] = new IndexRange(existing.Begin, range.Begin - 1); + ranges.Insert(++i, new IndexRange(range.End + 1, existing.End)); + removed?.Add(range); + result += range.Count; + } + else if (range.End <= existing.End) + { + var remove = new IndexRange(existing.Begin, range.End); + ranges[i] = new IndexRange(range.End + 1, existing.End); + removed?.Add(remove); + result += remove.Count; + } + } + } + + return result; + } + + public static IEnumerable Subtract( + IndexRange lhs, + IEnumerable rhs) + { + var result = new List { lhs }; + + foreach (var range in rhs) + { + Remove(result, range); + } + + return result; + } + + public static IEnumerable EnumerateIndices(IEnumerable ranges) + { + foreach (var range in ranges) + { + for (var i = range.Begin; i <= range.End; ++i) + { + yield return i; + } + } + } + + private static void MergeRanges(IList ranges) + { + for (var i = ranges.Count - 2; i >= 0; --i) + { + var r = ranges[i]; + var r1 = ranges[i + 1]; + + if (r.Intersects(r1) || r.End == r1.Begin - 1) + { + ranges[i] = new IndexRange(r.Begin, r1.End); + ranges.RemoveAt(i + 1); + } + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs b/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs new file mode 100644 index 0000000000..e0f46d9fa9 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class IndexRangeTests + { + [Fact] + public void Add_Should_Add_Range_To_Empty_List() + { + var ranges = new List(); + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(0, 4) }, ranges); + Assert.Equal(new[] { new IndexRange(0, 4) }, selected); + } + + [Fact] + public void Add_Should_Add_Non_Intersecting_Range_At_End() + { + var ranges = new List { new IndexRange(0, 4) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, selected); + } + + [Fact] + public void Add_Should_Add_Non_Intersecting_Range_At_Beginning() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges); + Assert.Equal(new[] { new IndexRange(0, 4) }, selected); + } + + [Fact] + public void Add_Should_Add_Non_Intersecting_Range_In_Middle() + { + var ranges = new List { new IndexRange(0, 4), new IndexRange(14, 16) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10), new IndexRange(14, 16) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, selected); + } + + [Fact] + public void Add_Should_Add_Intersecting_Range_Start() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(6, 9), selected); + + Assert.Equal(2, result); + Assert.Equal(new[] { new IndexRange(6, 10) }, ranges); + Assert.Equal(new[] { new IndexRange(6, 7) }, selected); + } + + [Fact] + public void Add_Should_Add_Intersecting_Range_End() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(9, 12), selected); + + Assert.Equal(2, result); + Assert.Equal(new[] { new IndexRange(8, 12) }, ranges); + Assert.Equal(new[] { new IndexRange(11, 12) }, selected); + } + + [Fact] + public void Add_Should_Add_Intersecting_Range_Both() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(6, 12), selected); + + Assert.Equal(4, result); + Assert.Equal(new[] { new IndexRange(6, 12) }, ranges); + Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 12) }, selected); + } + + [Fact] + public void Add_Should_Join_Two_Intersecting_Ranges() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(8, 14), selected); + + Assert.Equal(1, result); + Assert.Equal(new[] { new IndexRange(8, 14) }, ranges); + Assert.Equal(new[] { new IndexRange(11, 11) }, selected); + } + + [Fact] + public void Add_Should_Join_Two_Intersecting_Ranges_And_Add_Ranges() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(6, 18), selected); + + Assert.Equal(7, result); + Assert.Equal(new[] { new IndexRange(6, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 11), new IndexRange(15, 18) }, selected); + } + + [Fact] + public void Add_Should_Not_Add_Already_Selected_Range() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(9, 10), selected); + + Assert.Equal(0, result); + Assert.Equal(new[] { new IndexRange(8, 10) }, ranges); + Assert.Empty(selected); + } + + [Fact] + public void Remove_Should_Remove_Entire_Range() + { + var ranges = new List { new IndexRange(8, 10) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected); + + Assert.Equal(3, result); + Assert.Empty(ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Start_Of_Range() + { + var ranges = new List { new IndexRange(8, 12) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(11, 12) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_End_Of_Range() + { + var ranges = new List { new IndexRange(8, 12) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(10, 12), deselected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(8, 9) }, ranges); + Assert.Equal(new[] { new IndexRange(10, 12) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Overlapping_End_Of_Range() + { + var ranges = new List { new IndexRange(8, 12) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(10, 14), deselected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(8, 9) }, ranges); + Assert.Equal(new[] { new IndexRange(10, 12) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Middle_Of_Range() + { + var ranges = new List { new IndexRange(10, 20) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(12, 16), deselected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(10, 11), new IndexRange(17, 20) }, ranges); + Assert.Equal(new[] { new IndexRange(12, 16) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_Ranges() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(6, 15), deselected); + + Assert.Equal(6, result); + Assert.Equal(new[] { new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 14) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_And_Partial_Ranges_1() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(9, 15), deselected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 14) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_And_Partial_Ranges_2() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(8, 13), deselected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(14, 14), new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 13) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_And_Partial_Ranges_3() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(9, 13), deselected); + + Assert.Equal(4, result); + Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(14, 14), new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 13) }, deselected); + } + + [Fact] + public void Remove_Should_Do_Nothing_For_Unselected_Range() + { + var ranges = new List { new IndexRange(8, 10) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(2, 4), deselected); + + Assert.Equal(0, result); + Assert.Equal(new[] { new IndexRange(8, 10) }, ranges); + Assert.Empty(deselected); + } + + [Fact] + public void Stress_Test() + { + const int iterations = 100; + var random = new Random(0); + var selection = new List(); + var expected = new List(); + + IndexRange Generate() + { + var start = random.Next(100); + return new IndexRange(start, start + random.Next(20)); + } + + for (var i = 0; i < iterations; ++i) + { + var toAdd = random.Next(5); + + for (var j = 0; j < toAdd; ++j) + { + var range = Generate(); + IndexRange.Add(selection, range); + + for (var k = range.Begin; k <= range.End; ++k) + { + if (!expected.Contains(k)) + { + expected.Add(k); + } + } + + var actual = IndexRange.EnumerateIndices(selection).ToList(); + expected.Sort(); + Assert.Equal(expected, actual); + } + + var toRemove = random.Next(5); + + for (var j = 0; j < toRemove; ++j) + { + var range = Generate(); + IndexRange.Remove(selection, range); + + for (var k = range.Begin; k <= range.End; ++k) + { + expected.Remove(k); + } + + var actual = IndexRange.EnumerateIndices(selection).ToList(); + Assert.Equal(expected, actual); + } + + selection.Clear(); + expected.Clear(); + } + } + } +} From 88794773229e5c9fe93f29672c41b9cbac601a89 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 26 Jan 2020 11:47:51 +0100 Subject: [PATCH 09/52] Added SelectionModel changed args. `SelectionModel` as ported from WinUI has no information about what changed in a `SelectionChanged` event. This adds that information along with unit tests. --- src/Avalonia.Controls/SelectionModel.cs | 191 +++++--- .../SelectionModelChangeSet.cs | 144 ++++++ ...SelectionModelSelectionChangedEventArgs.cs | 45 ++ src/Avalonia.Controls/SelectionNode.cs | 150 +++---- .../SelectionModelTests.cs | 416 ++++++++++++++++++ 5 files changed, 803 insertions(+), 143 deletions(-) create mode 100644 src/Avalonia.Controls/SelectionModelChangeSet.cs diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 34d5f78434..5e2fb32243 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using Avalonia.Controls.Utils; #nullable enable @@ -19,7 +20,6 @@ namespace Avalonia.Controls private IReadOnlyList? _selectedIndicesCached; private IReadOnlyList? _selectedItemsCached; private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs; - private SelectionModelSelectionChangedEventArgs? _selectionChangedEventArgs; public event EventHandler? ChildrenRequested; public event PropertyChangedEventHandler? PropertyChanged; @@ -36,9 +36,12 @@ namespace Avalonia.Controls get => _rootNode?.Source; set { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + using (var operation = new Operation(this)) + { + ClearSelection(resetAnchor: true); + } + _rootNode.Source = value; - OnSelectionChanged(); RaisePropertyChanged("Source"); } } @@ -55,12 +58,13 @@ namespace Avalonia.Controls if (value && selectedIndices != null && selectedIndices.Count > 0) { + using var operation = new Operation(this); + // 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. + ClearSelection(resetAnchor: true); + SelectWithPathImpl(firstSelectionIndexPath, select: true); SelectedIndex = firstSelectionIndexPath; } @@ -131,9 +135,9 @@ namespace Avalonia.Controls if (!isSelected.HasValue || !isSelected.Value) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); - SelectWithPathImpl(value, select: true, raiseSelectionChanged: false); - OnSelectionChanged(); + using var operation = new Operation(this); + ClearSelection(resetAnchor: true); + SelectWithPathImpl(value, select: true); } } } @@ -289,7 +293,7 @@ namespace Avalonia.Controls public void Dispose() { - ClearSelection(resetAnchor: false, raiseSelectionChanged: false); + ClearSelection(resetAnchor: false); _rootNode?.Dispose(); _selectedIndicesCached = null; _selectedItemsCached = null; @@ -299,17 +303,41 @@ namespace Avalonia.Controls 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 index) + { + using var operation = new Operation(this); + SelectImpl(index, select: true); + } - public void Select(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: true); + public void Select(int groupIndex, int itemIndex) + { + using var operation = new Operation(this); + SelectWithGroupImpl(groupIndex, itemIndex, select: true); + } - public void SelectAt(IndexPath index) => SelectWithPathImpl(index, select: true, raiseSelectionChanged: true); + public void SelectAt(IndexPath index) + { + using var operation = new Operation(this); + SelectWithPathImpl(index, select: true); + } - public void Deselect(int index) => SelectImpl(index, select: false); + public void Deselect(int index) + { + using var operation = new Operation(this); + SelectImpl(index, select: false); + } - public void Deselect(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: false); + public void Deselect(int groupIndex, int itemIndex) + { + using var operation = new Operation(this); + SelectWithGroupImpl(groupIndex, itemIndex, select: false); + } - public void DeselectAt(IndexPath index) => SelectWithPathImpl(index, select: false, raiseSelectionChanged: true); + public void DeselectAt(IndexPath index) + { + using var operation = new Operation(this); + SelectWithPathImpl(index, select: false); + } public bool? IsSelected(int index) { @@ -383,46 +411,56 @@ namespace Avalonia.Controls public void SelectRangeFromAnchor(int index) { + using var operation = new Operation(this); SelectRangeFromAnchorImpl(index, select: true); } public void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex) { + using var operation = new Operation(this); SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, select: true); } public void SelectRangeFromAnchorTo(IndexPath index) { + using var operation = new Operation(this); SelectRangeImpl(AnchorIndex, index, select: true); } public void DeselectRangeFromAnchor(int index) { + using var operation = new Operation(this); SelectRangeFromAnchorImpl(index, select: false); } public void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex) { + using var operation = new Operation(this); SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, false /* select */); } public void DeselectRangeFromAnchorTo(IndexPath index) { + using var operation = new Operation(this); SelectRangeImpl(AnchorIndex, index, select: false); } public void SelectRange(IndexPath start, IndexPath end) { + using var operation = new Operation(this); SelectRangeImpl(start, end, select: true); } public void DeselectRange(IndexPath start, IndexPath end) { + using var operation = new Operation(this); SelectRangeImpl(start, end, select: false); } public void SelectAll() { + using var operation = new Operation(this); + SelectionTreeHelper.Traverse( _rootNode, realizeChildren: true, @@ -433,13 +471,12 @@ namespace Avalonia.Controls info.Node.SelectAll(); } }); - - OnSelectionChanged(); } public void ClearSelection() { - ClearSelection(resetAnchor: true, raiseSelectionChanged: true); + using var operation = new Operation(this); + ClearSelection(resetAnchor: true); } protected void OnPropertyChanged(string propertyName) @@ -452,9 +489,15 @@ namespace Avalonia.Controls PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public void OnSelectionInvalidatedDueToCollectionChange() + public void OnSelectionInvalidatedDueToCollectionChange( + IEnumerable? removedItems) { - OnSelectionChanged(); + var e = new SelectionModelSelectionChangedEventArgs( + Enumerable.Empty(), + Enumerable.Empty(), + removedItems ?? Enumerable.Empty(), + Enumerable.Empty()); + OnSelectionChanged(e); } internal object? ResolvePath(object data, SelectionNode sourceNode) @@ -496,7 +539,7 @@ namespace Avalonia.Controls return resolved; } - private void ClearSelection(bool resetAnchor, bool raiseSelectionChanged) + private void ClearSelection(bool resetAnchor) { SelectionTreeHelper.Traverse( _rootNode, @@ -507,27 +550,17 @@ namespace Avalonia.Controls { AnchorIndex = default; } - - if (raiseSelectionChanged) - { - OnSelectionChanged(); - } } - private void OnSelectionChanged() + private void OnSelectionChanged(SelectionModelSelectionChangedEventArgs? e = null) { _selectedIndicesCached = null; _selectedItemsCached = null; // Raise SelectionChanged event - if (SelectionChanged != null) + if (e != null) { - if (_selectionChangedEventArgs == null) - { - _selectionChangedEventArgs = new SelectionModelSelectionChangedEventArgs(); - } - - SelectionChanged(this, _selectionChangedEventArgs); + SelectionChanged?.Invoke(this, e); } RaisePropertyChanged(nameof(SelectedIndex)); @@ -544,7 +577,7 @@ namespace Avalonia.Controls { if (_singleSelect) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + ClearSelection(resetAnchor: true); } var selected = _rootNode.Select(index, select); @@ -553,15 +586,13 @@ namespace Avalonia.Controls { AnchorIndex = new IndexPath(index); } - - OnSelectionChanged(); } private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select) { if (_singleSelect) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + ClearSelection(resetAnchor: true); } var childNode = _rootNode.GetAt(groupIndex, realizeChild: true); @@ -571,17 +602,15 @@ namespace Avalonia.Controls { AnchorIndex = new IndexPath(groupIndex, itemIndex); } - - OnSelectionChanged(); } - private void SelectWithPathImpl(IndexPath index, bool select, bool raiseSelectionChanged) + private void SelectWithPathImpl(IndexPath index, bool select) { bool selected = false; if (_singleSelect) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + ClearSelection(resetAnchor: true); } SelectionTreeHelper.TraverseIndexPath( @@ -601,11 +630,6 @@ namespace Avalonia.Controls { AnchorIndex = index; } - - if (raiseSelectionChanged) - { - OnSelectionChanged(); - } } private void SelectRangeFromAnchorImpl(int index, bool select) @@ -618,12 +642,7 @@ namespace Avalonia.Controls anchorIndex = anchor.GetAt(0); } - bool selected = _rootNode.SelectRange(new IndexRange(anchorIndex, index), select); - - if (selected) - { - OnSelectionChanged(); - } + _rootNode.SelectRange(new IndexRange(anchorIndex, index), select); } private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select) @@ -650,18 +669,12 @@ namespace Avalonia.Controls 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(); + groupNode.SelectRange(new IndexRange(startIndex, endIndex), select); } } @@ -691,8 +704,55 @@ namespace Avalonia.Controls info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); } }); + } + + private void BeginOperation() + { + if (SelectionChanged != null) + { + _rootNode.BeginOperation(); + } + } + + private void EndOperation() + { + static IEnumerable? Concat(IEnumerable? a, IEnumerable b) + { + return a == null ? b : a.Concat(b); + } - OnSelectionChanged(); + SelectionModelSelectionChangedEventArgs? e = null; + + if (SelectionChanged != null) + { + IEnumerable? selectedIndices = null; + IEnumerable? deselectedIndices = null; + IEnumerable? selectedItems = null; + IEnumerable? deselectedItems = null; + + foreach (var changes in _rootNode.EndOperation()) + { + if (changes.HasChanges) + { + selectedIndices = Concat(selectedIndices, changes.SelectedIndices); + deselectedIndices = Concat(deselectedIndices, changes.DeselectedIndices); + selectedItems = Concat(selectedItems, changes.SelectedItems); + deselectedItems = Concat(deselectedItems, changes.DeselectedItems); + } + } + + if (selectedIndices != null || deselectedIndices != null || + selectedItems != null || deselectedItems != null) + { + e = new SelectionModelSelectionChangedEventArgs( + deselectedIndices ?? Enumerable.Empty(), + selectedIndices ?? Enumerable.Empty(), + deselectedItems ?? Enumerable.Empty(), + selectedItems ?? Enumerable.Empty()); + } + } + + OnSelectionChanged(e); } internal class SelectedItemInfo @@ -706,5 +766,12 @@ namespace Avalonia.Controls public SelectionNode Node { get; } public IndexPath Path { get; } } + + private struct Operation : IDisposable + { + private readonly SelectionModel _manager; + public Operation(SelectionModel manager) => (_manager = manager).BeginOperation(); + public void Dispose() => _manager.EndOperation(); + } } } diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs new file mode 100644 index 0000000000..989136ac8d --- /dev/null +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls +{ + internal class SelectionModelChangeSet + { + private SelectionNode _owner; + private List? _selected; + private List? _deselected; + + public SelectionModelChangeSet(SelectionNode owner) => _owner = owner; + + public bool IsTracking { get; private set; } + public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0; + public IEnumerable SelectedIndices => EnumerateIndices(_selected); + public IEnumerable DeselectedIndices => EnumerateIndices(_deselected); + public IEnumerable SelectedItems => EnumerateItems(_selected); + public IEnumerable DeselectedItems => EnumerateItems(_deselected); + + public void BeginOperation() + { + if (IsTracking) + { + throw new AvaloniaInternalException("SelectionModel change operation already in progress."); + } + + IsTracking = true; + _selected?.Clear(); + _deselected?.Clear(); + } + + public void EndOperation() => IsTracking = false; + + public void Selected(IndexRange range) + { + if (!IsTracking) + { + return; + } + + Add(range, ref _selected, _deselected); + } + + public void Selected(IEnumerable ranges) + { + if (!IsTracking) + { + return; + } + + foreach (var range in ranges) + { + Selected(range); + } + } + + public void Deselected(IndexRange range) + { + if (!IsTracking) + { + return; + } + + Add(range, ref _deselected, _selected); + } + + public void Deselected(IEnumerable ranges) + { + if (!IsTracking) + { + return; + } + + foreach (var range in ranges) + { + Deselected(range); + } + } + + private static void Add( + IndexRange range, + ref List? add, + List? remove) + { + if (remove != null) + { + var removed = new List(); + IndexRange.Remove(remove, range, removed); + var selected = IndexRange.Subtract(range, removed); + + if (selected.Any()) + { + add ??= new List(); + + foreach (var r in selected) + { + IndexRange.Add(add, r); + } + } + } + else + { + add ??= new List(); + IndexRange.Add(add, range); + } + } + + private IEnumerable EnumerateIndices(IEnumerable? ranges) + { + var path = _owner.IndexPath; + + if (ranges != null) + { + foreach (var range in ranges) + { + for (var i = range.Begin; i <= range.End; ++i) + { + yield return path.CloneWithChildIndex(i); + } + } + } + } + + private IEnumerable EnumerateItems(IEnumerable? ranges) + { + var items = _owner.ItemsSourceView; + + if (ranges != null && items != null) + { + foreach (var range in ranges) + { + for (var i = range.Begin; i <= range.End; ++i) + { + yield return items.GetAt(i); + } + } + } + } + } +} diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index c8edc1f8ae..4976bf1827 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -4,6 +4,7 @@ // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. using System; +using System.Collections.Generic; #nullable enable @@ -11,5 +12,49 @@ namespace Avalonia.Controls { public class SelectionModelSelectionChangedEventArgs : EventArgs { + private readonly IEnumerable _selectedIndicesSource; + private readonly IEnumerable _deselectedIndicesSource; + private readonly IEnumerable _selectedItemsSource; + private readonly IEnumerable _deselectedItemsSource; + private List? _selectedIndices; + private List? _deselectedIndices; + private List? _selectedItems; + private List? _deselectedItems; + + public SelectionModelSelectionChangedEventArgs( + IEnumerable deselectedIndices, + IEnumerable selectedIndices, + IEnumerable deselectedItems, + IEnumerable selectedItems) + { + _selectedIndicesSource = selectedIndices; + _deselectedIndicesSource = deselectedIndices; + _selectedItemsSource = selectedItems; + _deselectedItemsSource = deselectedItems; + } + + /// + /// Gets the indices of the items that were added to the selection. + /// + public IReadOnlyList SelectedIndices => + _selectedIndices ?? (_selectedIndices = new List(_selectedIndicesSource)); + + /// + /// Gets the indices of the items that were removed from the selection. + /// + public IReadOnlyList DeselectedIndices => + _deselectedIndices ?? (_deselectedIndices = new List(_deselectedIndicesSource)); + + /// + /// Gets the items that were added to the selection. + /// + public IReadOnlyList SelectedItems => + _selectedItems ?? (_selectedItems = new List(_selectedItemsSource)); + + /// + /// Gets the items that were removed from the selection. + /// + public IReadOnlyList DeselectedItems => + _deselectedItems ?? (_deselectedItems = new List(_deselectedItemsSource)); } } diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 363eb35b94..d462a51228 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -7,6 +7,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.Linq; #nullable enable @@ -28,6 +29,7 @@ namespace Avalonia.Controls private readonly SelectionNode? _parent; private readonly List _selected = new List(); private readonly List _selectedIndicesCached = new List(); + private SelectionModelChangeSet? _changes; private object? _source; private bool _selectedIndicesCacheIsValid; @@ -134,6 +136,11 @@ namespace Avalonia.Controls { child = new SelectionNode(_manager, parent: this); child.Source = resolvedChild; + + if (_changes?.IsTracking == true) + { + child.BeginOperation(); + } } else { @@ -276,12 +283,50 @@ namespace Avalonia.Controls } } + public IEnumerable SelectedItems + { + get => SelectedIndices.Select(x => ItemsSourceView!.GetAt(x)); + } + public void Dispose() { ItemsSourceView?.Dispose(); UnhookCollectionChangedHandler(); } + public void BeginOperation() + { + _changes ??= new SelectionModelChangeSet(this); + _changes.BeginOperation(); + + for (var i = 0; i < _childrenNodes.Count; ++i) + { + _childrenNodes[i]?.BeginOperation(); + } + } + + public IEnumerable EndOperation() + { + if (_changes != null) + { + _changes.EndOperation(); + yield return _changes; + + for (var i = 0; i < _childrenNodes.Count; ++i) + { + var child = _childrenNodes[i]; + + if (child != null) + { + foreach (var changes in child.EndOperation()) + { + yield return changes; + } + } + } + } + } + public bool Select(int index, bool select) { return Select(index, select, raiseOnSelectionChanged: true); @@ -349,21 +394,13 @@ namespace Avalonia.Controls private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged) { - // TODO: Check for duplicates (Task 14107720) - // TODO: Optimize by merging adjacent ranges (Task 14107720) - var oldCount = SelectedCount; + var selected = new List(); - for (int i = addRange.Begin; i <= addRange.End; i++) - { - if (!IsSelected(i)) - { - SelectedCount++; - } - } + SelectedCount += IndexRange.Add(_selected, addRange, selected); - if (oldCount != SelectedCount) + if (selected.Count > 0) { - _selected.Add(addRange); + _changes?.Selected(selected); if (raiseOnSelectionChanged) { @@ -374,71 +411,17 @@ namespace Avalonia.Controls private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged) { - int oldCount = SelectedCount; + var removed = new List(); - // TODO: Prevent overlap of Ranges in _selected (Task 14107720) - for (int i = removeRange.Begin; i <= removeRange.End; i++) - { - if (IsSelected(i)) - { - SelectedCount--; - } - } + SelectedCount -= IndexRange.Remove(_selected, removeRange, removed); - if (oldCount != SelectedCount) + if (removed.Count > 0) { - // 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); - } + _changes?.Deselected(removed); - // 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) + if (raiseOnSelectionChanged) { - // Remove tagged ranges - foreach (var remove in toRemove) - { - _selected.Remove(remove); - } - - // Add new ranges - _selected.AddRange(toAdd); - - if (raiseOnSelectionChanged) - { - OnSelectionChanged(); - } + OnSelectionChanged(); } } } @@ -448,6 +431,7 @@ namespace Avalonia.Controls // Deselect all items if (_selected.Count > 0) { + _changes?.Deselected(_selected); _selected.Clear(); OnSelectionChanged(); } @@ -496,6 +480,7 @@ namespace Avalonia.Controls private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) { bool selectionInvalidated = false; + IList? removed = null; switch (args.Action) { @@ -507,7 +492,7 @@ namespace Avalonia.Controls case NotifyCollectionChangedAction.Remove: { - selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); + (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems); break; } @@ -520,7 +505,7 @@ namespace Avalonia.Controls case NotifyCollectionChangedAction.Replace: { - selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); + (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems); selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); break; } @@ -529,7 +514,7 @@ namespace Avalonia.Controls if (selectionInvalidated) { OnSelectionChanged(); - _manager.OnSelectionInvalidatedDueToCollectionChange(); + _manager.OnSelectionInvalidatedDueToCollectionChange(removed); } } @@ -609,21 +594,23 @@ namespace Avalonia.Controls return selectionInvalidated; } - private bool OnItemsRemoved(int index, int count) + private (bool, IList) OnItemsRemoved(int index, IList items) { - bool selectionInvalidated = false; + var selectionInvalidated = false; + var removed = new List(); + var count = items.Count; // Remove the items from the selection for leaf if (ItemsSourceView!.Count > 0) { bool isSelected = false; - for (int i = index; i <= index + count - 1; i++) + for (int i = 0; i <= count - 1; i++) { - if (IsSelected(i)) + if (IsSelected(index + i)) { isSelected = true; - break; + removed.Add(items[i]); } } @@ -654,6 +641,7 @@ namespace Avalonia.Controls { if (_childrenNodes[index] != null) { + removed.AddRange(_childrenNodes[index]!.SelectedItems); RealizedChildrenNodeCount--; } _childrenNodes.RemoveAt(index); @@ -696,7 +684,7 @@ namespace Avalonia.Controls } } - return selectionInvalidated; + return (selectionInvalidated, removed); } private void OnSelectionChanged() diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 6c3137c636..3e908681e6 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -898,6 +898,422 @@ namespace Avalonia.Controls.UnitTests }); } + [Fact] + public void Selecting_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.Select(4); + + Assert.Equal(1, raised); + } + + [Fact] + public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.SelectionChanged += (s, e) => ++raised; + target.Select(4); + + Assert.Equal(0, raised); + } + + [Fact] + public void SingleSelecting_Item_Raises_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(3); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(3) }, e.DeselectedIndices); + Assert.Equal(new object[] { 3 }, e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.Select(4); + + Assert.Equal(1, raised); + } + + [Fact] + public void SingleSelecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.SelectionChanged += (s, e) => ++raised; + target.Select(4); + + Assert.Equal(0, raised); + } + + [Fact] + public void Selecting_Item_With_Group_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 3); + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.Select(1, 1); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectAt_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 3); + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.SelectAt(new IndexPath(1, 1)); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectAll_Raises_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(0, 10); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.SelectAll(); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectAll_With_Already_Selected_Items_Raises_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(0, 10).Except(new[] { 4 }); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.SelectAll(); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRangeFromAnchor_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(4, 3); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.AnchorIndex = new IndexPath(4); + target.SelectRangeFromAnchor(6); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRangeFromAnchor_With_Group_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 10); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(11, 6); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.AnchorIndex = new IndexPath(1, 1); + target.SelectRangeFromAnchor(1, 6); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRangeFromAnchorTo_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 10); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(11, 6); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.AnchorIndex = new IndexPath(1, 1); + target.SelectRangeFromAnchorTo(new IndexPath(1, 6)); + + Assert.Equal(1, raised); + } + + [Fact] + public void ClearSelection_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(4, 2); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices); + Assert.Equal(expected, e.DeselectedItems.Cast()); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.ClearSelection(); + + Assert.Equal(1, raised); + } + + [Fact] + public void Changing_Source_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(4, 2); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices); + Assert.Equal(expected, e.DeselectedItems.Cast()); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = Enumerable.Range(20, 10).ToList(); + + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(4), new IndexPath(5) }, e.DeselectedIndices); + Assert.Equal(new object[] { 4, 5 }, e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(6) }, e.SelectedIndices); + Assert.Equal(new object[] { 6 }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = new IndexPath(6); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Selected_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var raised = 0; + + target.Source = data; + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new object[] { 4 }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Remove(4); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Selected_Child_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = CreateNestedData(1, 2, 3); + var raised = 0; + + target.Source = data; + target.SelectRange(new IndexPath(0), new IndexPath(1, 1)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new object[] { 1}, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + ((AvaloniaList)data[0]).RemoveAt(1); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Selected_Item_With_Children_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = CreateNestedData(1, 2, 3); + var raised = 0; + + target.Source = data; + target.SelectRange(new IndexPath(0), new IndexPath(1, 1)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new object[] { 0, 1, 2 }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.RemoveAt(0); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Unselected_Item_Before_Selected_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var raised = 0; + + target.Source = data; + target.Select(8); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Remove(6); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Unselected_Item_After_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = new SelectionModel(); + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var raised = 0; + + target.Source = data; + target.Select(4); + + target.SelectionChanged += (s, e) => ++raised; + + data.Remove(6); + + Assert.Equal(0, raised); + } [Fact] public void Disposing_Unhooks_CollectionChanged_Handlers() From 2336f85d02b2b8909d0b7a0ebe3b740c6239eb88 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 29 Jan 2020 21:48:14 +0100 Subject: [PATCH 10/52] Added some failing tests. That demonstrate some problems with the `SelectionModel` change notifications found so far. --- .../SelectionModelTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 3e908681e6..b9149bf42b 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1360,6 +1360,38 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(3, target.SelectedItems.Count); } + [Fact] + public void Not_Enumerating_Changes_Does_Not_Prevent_Further_Operations() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data }; + + target.SelectionChanged += (s, e) => { }; + + target.SelectAll(); + target.ClearSelection(); + } + + [Fact] + public void Can_Change_Selection_From_SelectionChanged() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data }; + var raised = 0; + + target.SelectionChanged += (s, e) => + { + if (raised++ == 0) + { + target.ClearSelection(); + } + }; + + target.SelectAll(); + + Assert.Equal(2, raised); + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; From ea968c49c2c6495016df139b53e77278d5234f98 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 29 Jan 2020 21:48:43 +0100 Subject: [PATCH 11/52] Refactor of SelectionModel change notifications. To address issues found. --- src/Avalonia.Controls/SelectionModel.cs | 53 ++----- .../SelectionModelChangeSet.cs | 140 +++++------------- ...SelectionModelSelectionChangedEventArgs.cs | 80 ++++++---- src/Avalonia.Controls/SelectionNode.cs | 60 +++++--- .../SelectionNodeOperation.cs | 80 ++++++++++ 5 files changed, 215 insertions(+), 198 deletions(-) create mode 100644 src/Avalonia.Controls/SelectionNodeOperation.cs diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 5e2fb32243..49f2c8d2f6 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -490,13 +490,9 @@ namespace Avalonia.Controls } public void OnSelectionInvalidatedDueToCollectionChange( - IEnumerable? removedItems) + IReadOnlyList? removedItems) { - var e = new SelectionModelSelectionChangedEventArgs( - Enumerable.Empty(), - Enumerable.Empty(), - removedItems ?? Enumerable.Empty(), - Enumerable.Empty()); + var e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null); OnSelectionChanged(e); } @@ -706,50 +702,19 @@ namespace Avalonia.Controls }); } - private void BeginOperation() - { - if (SelectionChanged != null) - { - _rootNode.BeginOperation(); - } - } + private void BeginOperation() => _rootNode.BeginOperation(); private void EndOperation() { - static IEnumerable? Concat(IEnumerable? a, IEnumerable b) - { - return a == null ? b : a.Concat(b); - } + var changes = new List(); + _rootNode.EndOperation(changes); SelectionModelSelectionChangedEventArgs? e = null; - - if (SelectionChanged != null) + + if (changes.Count > 0) { - IEnumerable? selectedIndices = null; - IEnumerable? deselectedIndices = null; - IEnumerable? selectedItems = null; - IEnumerable? deselectedItems = null; - - foreach (var changes in _rootNode.EndOperation()) - { - if (changes.HasChanges) - { - selectedIndices = Concat(selectedIndices, changes.SelectedIndices); - deselectedIndices = Concat(deselectedIndices, changes.DeselectedIndices); - selectedItems = Concat(selectedItems, changes.SelectedItems); - deselectedItems = Concat(deselectedItems, changes.DeselectedItems); - } - } - - if (selectedIndices != null || deselectedIndices != null || - selectedItems != null || deselectedItems != null) - { - e = new SelectionModelSelectionChangedEventArgs( - deselectedIndices ?? Enumerable.Empty(), - selectedIndices ?? Enumerable.Empty(), - deselectedItems ?? Enumerable.Empty(), - selectedItems ?? Enumerable.Empty()); - } + var changeSet = new SelectionModelChangeSet(changes); + e = changeSet.CreateEventArgs(); } OnSelectionChanged(e); diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index 989136ac8d..b195117ae6 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -1,144 +1,80 @@ using System; using System.Collections.Generic; -using System.Linq; - -#nullable enable namespace Avalonia.Controls { internal class SelectionModelChangeSet { - private SelectionNode _owner; - private List? _selected; - private List? _deselected; - - public SelectionModelChangeSet(SelectionNode owner) => _owner = owner; - - public bool IsTracking { get; private set; } - public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0; - public IEnumerable SelectedIndices => EnumerateIndices(_selected); - public IEnumerable DeselectedIndices => EnumerateIndices(_deselected); - public IEnumerable SelectedItems => EnumerateItems(_selected); - public IEnumerable DeselectedItems => EnumerateItems(_deselected); + private List _changes; - public void BeginOperation() + public SelectionModelChangeSet(List changes) { - if (IsTracking) - { - throw new AvaloniaInternalException("SelectionModel change operation already in progress."); - } - - IsTracking = true; - _selected?.Clear(); - _deselected?.Clear(); + _changes = changes; } - public void EndOperation() => IsTracking = false; - - public void Selected(IndexRange range) + public SelectionModelSelectionChangedEventArgs CreateEventArgs() { - if (!IsTracking) - { - return; - } - - Add(range, ref _selected, _deselected); + return new SelectionModelSelectionChangedEventArgs( + CreateIndices(x => x.DeselectedRanges), + CreateIndices(x => x.SelectedRanges), + CreateItems(x => x.DeselectedRanges), + CreateItems(x => x.SelectedRanges)); } - public void Selected(IEnumerable ranges) + private IReadOnlyList CreateIndices(Func?> selector) { - if (!IsTracking) + if (_changes == null) { - return; + return Array.Empty(); } - foreach (var range in ranges) - { - Selected(range); - } - } - - public void Deselected(IndexRange range) - { - if (!IsTracking) - { - return; - } - - Add(range, ref _deselected, _selected); - } + var result = new List(); - public void Deselected(IEnumerable ranges) - { - if (!IsTracking) + foreach (var i in _changes) { - return; - } + var ranges = selector(i); - foreach (var range in ranges) - { - Deselected(range); - } - } - - private static void Add( - IndexRange range, - ref List? add, - List? remove) - { - if (remove != null) - { - var removed = new List(); - IndexRange.Remove(remove, range, removed); - var selected = IndexRange.Subtract(range, removed); - - if (selected.Any()) + if (ranges != null) { - add ??= new List(); - - foreach (var r in selected) + foreach (var j in ranges) { - IndexRange.Add(add, r); + for (var k = j.Begin; k <= j.End; ++k) + { + result.Add(i.Path.CloneWithChildIndex(k)); + } } } } - else - { - add ??= new List(); - IndexRange.Add(add, range); - } + + return result; } - private IEnumerable EnumerateIndices(IEnumerable? ranges) + private IReadOnlyList CreateItems(Func?> selector) { - var path = _owner.IndexPath; - - if (ranges != null) + if (_changes == null) { - foreach (var range in ranges) - { - for (var i = range.Begin; i <= range.End; ++i) - { - yield return path.CloneWithChildIndex(i); - } - } + return Array.Empty(); } - } - private IEnumerable EnumerateItems(IEnumerable? ranges) - { - var items = _owner.ItemsSourceView; + var result = new List(); - if (ranges != null && items != null) + foreach (var i in _changes) { - foreach (var range in ranges) + var ranges = selector(i); + + if (ranges != null && i.Items != null) { - for (var i = range.Begin; i <= range.End; ++i) + foreach (var j in ranges) { - yield return items.GetAt(i); + for (var k = j.Begin; k <= j.End; ++k) + { + result.Add(i.Items.GetAt(k)); + } } } } + + return result; } } } diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index 4976bf1827..ae98f6a1ce 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -12,49 +12,73 @@ namespace Avalonia.Controls { public class SelectionModelSelectionChangedEventArgs : EventArgs { - private readonly IEnumerable _selectedIndicesSource; - private readonly IEnumerable _deselectedIndicesSource; - private readonly IEnumerable _selectedItemsSource; - private readonly IEnumerable _deselectedItemsSource; - private List? _selectedIndices; - private List? _deselectedIndices; - private List? _selectedItems; - private List? _deselectedItems; + private readonly IEnumerable? _deselectedIndicesSource; + private readonly IEnumerable? _selectedIndicesSource; + private readonly IEnumerable? _deselectedItemsSource; + private readonly IEnumerable? _selectedItemsSource; + private IReadOnlyList? _deselectedIndices; + private IReadOnlyList? _selectedIndices; + private IReadOnlyList? _deselectedItems; + private IReadOnlyList? _selectedItems; public SelectionModelSelectionChangedEventArgs( - IEnumerable deselectedIndices, - IEnumerable selectedIndices, - IEnumerable deselectedItems, - IEnumerable selectedItems) + IReadOnlyList? deselectedIndices, + IReadOnlyList? selectedIndices, + IReadOnlyList? deselectedItems, + IReadOnlyList? selectedItems) { - _selectedIndicesSource = selectedIndices; - _deselectedIndicesSource = deselectedIndices; - _selectedItemsSource = selectedItems; - _deselectedItemsSource = deselectedItems; + _deselectedIndices = deselectedIndices ?? Array.Empty(); + _selectedIndices = selectedIndices ?? Array.Empty(); + _deselectedItems = deselectedItems ?? Array.Empty(); + _selectedItems= selectedItems ?? Array.Empty(); } - /// - /// Gets the indices of the items that were added to the selection. - /// - public IReadOnlyList SelectedIndices => - _selectedIndices ?? (_selectedIndices = new List(_selectedIndicesSource)); + public SelectionModelSelectionChangedEventArgs( + IEnumerable? deselectedIndices, + IEnumerable? selectedIndices, + IEnumerable? deselectedItems, + IEnumerable? selectedItems) + { + static void Set(IEnumerable? source, ref IEnumerable? sourceField, ref IReadOnlyList? field) + { + if (source != null) + { + sourceField = source; + } + else + { + field = Array.Empty(); + } + } + + Set(deselectedIndices, ref _deselectedIndicesSource, ref _deselectedIndices); + Set(selectedIndices, ref _selectedIndicesSource, ref _selectedIndices); + Set(deselectedItems, ref _deselectedItemsSource, ref _deselectedItems); + Set(selectedItems, ref _selectedItemsSource, ref _selectedItems); + } /// /// Gets the indices of the items that were removed from the selection. /// - public IReadOnlyList DeselectedIndices => - _deselectedIndices ?? (_deselectedIndices = new List(_deselectedIndicesSource)); + public IReadOnlyList DeselectedIndices + => _deselectedIndices ??= new List(_deselectedIndicesSource); /// - /// Gets the items that were added to the selection. + /// Gets the indices of the items that were added to the selection. /// - public IReadOnlyList SelectedItems => - _selectedItems ?? (_selectedItems = new List(_selectedItemsSource)); + public IReadOnlyList SelectedIndices + => _selectedIndices ??= new List(_selectedIndicesSource); /// /// Gets the items that were removed from the selection. /// - public IReadOnlyList DeselectedItems => - _deselectedItems ?? (_deselectedItems = new List(_deselectedItemsSource)); + public IReadOnlyList DeselectedItems + => _deselectedItems ??= new List(_deselectedItemsSource); + + /// + /// Gets the items that were added to the selection. + /// + public IReadOnlyList SelectedItems + => _selectedItems ??= new List(_selectedItemsSource); } } diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index d462a51228..85db801100 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -29,7 +29,7 @@ namespace Avalonia.Controls private readonly SelectionNode? _parent; private readonly List _selected = new List(); private readonly List _selectedIndicesCached = new List(); - private SelectionModelChangeSet? _changes; + private SelectionNodeOperation? _operation; private object? _source; private bool _selectedIndicesCacheIsValid; @@ -137,7 +137,7 @@ namespace Avalonia.Controls child = new SelectionNode(_manager, parent: this); child.Source = resolvedChild; - if (_changes?.IsTracking == true) + if (_operation != null) { child.BeginOperation(); } @@ -296,33 +296,45 @@ namespace Avalonia.Controls public void BeginOperation() { - _changes ??= new SelectionModelChangeSet(this); - _changes.BeginOperation(); + if (_operation != null) + { + throw new AvaloniaInternalException("Selection operation already in progress."); + } + + _operation = new SelectionNodeOperation(this); for (var i = 0; i < _childrenNodes.Count; ++i) { - _childrenNodes[i]?.BeginOperation(); + var child = _childrenNodes[i]; + + if (child != null && child != _manager.SharedLeafNode) + { + child.BeginOperation(); + } } } - public IEnumerable EndOperation() + public void EndOperation(List changes) { - if (_changes != null) + if (_operation == null) { - _changes.EndOperation(); - yield return _changes; + throw new AvaloniaInternalException("No selection operation in progress."); + } - for (var i = 0; i < _childrenNodes.Count; ++i) - { - var child = _childrenNodes[i]; + if (_operation.HasChanges) + { + changes.Add(_operation); + } - if (child != null) - { - foreach (var changes in child.EndOperation()) - { - yield return changes; - } - } + _operation = null; + + for (var i = 0; i < _childrenNodes.Count; ++i) + { + var child = _childrenNodes[i]; + + if (child != null && child != _manager.SharedLeafNode) + { + child.EndOperation(changes); } } } @@ -400,7 +412,7 @@ namespace Avalonia.Controls if (selected.Count > 0) { - _changes?.Selected(selected); + _operation?.Selected(selected); if (raiseOnSelectionChanged) { @@ -417,7 +429,7 @@ namespace Avalonia.Controls if (removed.Count > 0) { - _changes?.Deselected(removed); + _operation?.Deselected(removed); if (raiseOnSelectionChanged) { @@ -431,7 +443,7 @@ namespace Avalonia.Controls // Deselect all items if (_selected.Count > 0) { - _changes?.Deselected(_selected); + _operation?.Deselected(_selected); _selected.Clear(); OnSelectionChanged(); } @@ -480,7 +492,7 @@ namespace Avalonia.Controls private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) { bool selectionInvalidated = false; - IList? removed = null; + List? removed = null; switch (args.Action) { @@ -594,7 +606,7 @@ namespace Avalonia.Controls return selectionInvalidated; } - private (bool, IList) OnItemsRemoved(int index, IList items) + private (bool, List) OnItemsRemoved(int index, IList items) { var selectionInvalidated = false; var removed = new List(); diff --git a/src/Avalonia.Controls/SelectionNodeOperation.cs b/src/Avalonia.Controls/SelectionNodeOperation.cs new file mode 100644 index 0000000000..04b8554f7c --- /dev/null +++ b/src/Avalonia.Controls/SelectionNodeOperation.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls +{ + internal class SelectionNodeOperation + { + private readonly SelectionNode _owner; + private List? _selected; + private List? _deselected; + + public SelectionNodeOperation(SelectionNode owner) + { + _owner = owner; + } + + public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0; + public List? SelectedRanges => _selected; + public List? DeselectedRanges => _deselected; + public IndexPath Path => _owner.IndexPath; + public ItemsSourceView? Items => _owner.ItemsSourceView; + + public void Selected(IndexRange range) + { + Add(range, ref _selected, _deselected); + } + + public void Selected(IEnumerable ranges) + { + foreach (var range in ranges) + { + Selected(range); + } + } + + public void Deselected(IndexRange range) + { + Add(range, ref _deselected, _selected); + } + + public void Deselected(IEnumerable ranges) + { + foreach (var range in ranges) + { + Deselected(range); + } + } + + private static void Add( + IndexRange range, + ref List? add, + List? remove) + { + if (remove != null) + { + var removed = new List(); + IndexRange.Remove(remove, range, removed); + var selected = IndexRange.Subtract(range, removed); + + if (selected.Any()) + { + add ??= new List(); + + foreach (var r in selected) + { + IndexRange.Add(add, r); + } + } + } + else + { + add ??= new List(); + IndexRange.Add(add, range); + } + } + } +} From 27e11150b78f0b62071de74f48bf3248d167e120 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 30 Jan 2020 11:52:13 +0100 Subject: [PATCH 12/52] Make comparing IndexPath with null do something useful. --- src/Avalonia.Controls/IndexPath.cs | 7 +++++++ .../Avalonia.Controls.UnitTests/IndexPathTests.cs | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/Avalonia.Controls/IndexPath.cs b/src/Avalonia.Controls/IndexPath.cs index 32d8c2b051..6c5aaf7ad1 100644 --- a/src/Avalonia.Controls/IndexPath.cs +++ b/src/Avalonia.Controls/IndexPath.cs @@ -58,6 +58,11 @@ namespace Avalonia.Controls public int GetAt(int index) { + if (index >= GetSize()) + { + throw new IndexOutOfRangeException(); + } + return _path?[index] ?? (_index - 1); } @@ -169,5 +174,7 @@ namespace Avalonia.Controls public static bool operator >=(IndexPath x, IndexPath y) { return x.CompareTo(y) >= 0; } public static bool operator ==(IndexPath x, IndexPath y) { return x.CompareTo(y) == 0; } public static bool operator !=(IndexPath x, IndexPath y) { return x.CompareTo(y) != 0; } + public static bool operator ==(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) == 0; } + public static bool operator !=(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) != 0; } } } diff --git a/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs b/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs index 190e92ed5e..1e4aa0a2b8 100644 --- a/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs +++ b/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs @@ -77,5 +77,19 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(0, a.CompareTo(b)); Assert.Equal(a.GetHashCode(), b.GetHashCode()); } + + [Fact] + public void Null_Equality() + { + var a = new IndexPath(null); + var b = new IndexPath(1); + + // Implementing operator == on a struct automatically implements an operator which + // accepts null, so make sure this does something useful. + Assert.True(a == null); + Assert.False(a != null); + Assert.False(b == null); + Assert.True(b != null); + } } } From 307b751f680f79b2951b428fae4327fab87867bb Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 31 Jan 2020 10:17:56 +0100 Subject: [PATCH 13/52] Lazily create the selected/deselected lists. --- .../SelectionModelChangeSet.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index b195117ae6..e927afb9b9 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -21,15 +21,13 @@ namespace Avalonia.Controls CreateItems(x => x.SelectedRanges)); } - private IReadOnlyList CreateIndices(Func?> selector) + private IEnumerable CreateIndices(Func?> selector) { if (_changes == null) { - return Array.Empty(); + yield break; } - var result = new List(); - foreach (var i in _changes) { var ranges = selector(i); @@ -40,20 +38,18 @@ namespace Avalonia.Controls { for (var k = j.Begin; k <= j.End; ++k) { - result.Add(i.Path.CloneWithChildIndex(k)); + yield return i.Path.CloneWithChildIndex(k); } } } } - - return result; } - private IReadOnlyList CreateItems(Func?> selector) + private IEnumerable CreateItems(Func?> selector) { if (_changes == null) { - return Array.Empty(); + yield break; } var result = new List(); @@ -68,13 +64,11 @@ namespace Avalonia.Controls { for (var k = j.Begin; k <= j.End; ++k) { - result.Add(i.Items.GetAt(k)); + yield return i.Items.GetAt(k); } } } } - - return result; } } } From 3a7d9f0800d36fd6d00f1f9ee514686f00d40714 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 1 Feb 2020 00:48:57 +0100 Subject: [PATCH 14/52] Use SelectedItems for change event args. --- src/Avalonia.Controls/IndexRange.cs | 12 ++ src/Avalonia.Controls/SelectedItems.cs | 37 ++--- src/Avalonia.Controls/SelectionModel.cs | 14 +- .../SelectionModelChangeSet.cs | 149 ++++++++++++++---- ...SelectionModelSelectionChangedEventArgs.cs | 53 +------ .../SelectionNodeOperation.cs | 32 +++- 6 files changed, 193 insertions(+), 104 deletions(-) diff --git a/src/Avalonia.Controls/IndexRange.cs b/src/Avalonia.Controls/IndexRange.cs index 124f1e0500..1dc161c699 100644 --- a/src/Avalonia.Controls/IndexRange.cs +++ b/src/Avalonia.Controls/IndexRange.cs @@ -202,6 +202,18 @@ namespace Avalonia.Controls } } + public static int GetCount(IEnumerable ranges) + { + var result = 0; + + foreach (var range in ranges) + { + result += (range.End - range.Begin) + 1; + } + + return result; + } + private static void MergeRanges(IList ranges) { for (var i = ranges.Count - 2; i >= 0; --i) diff --git a/src/Avalonia.Controls/SelectedItems.cs b/src/Avalonia.Controls/SelectedItems.cs index af43742670..a3acb48765 100644 --- a/src/Avalonia.Controls/SelectedItems.cs +++ b/src/Avalonia.Controls/SelectedItems.cs @@ -6,44 +6,37 @@ using System; using System.Collections; using System.Collections.Generic; -using SelectedItemInfo = Avalonia.Controls.SelectionModel.SelectedItemInfo; #nullable enable namespace Avalonia.Controls { - internal class SelectedItems : IReadOnlyList + public interface ISelectedItemInfo { - private readonly List _infos; - private readonly Func, int, T> _getAtImpl; + public IndexPath Path { get; } + } + + internal class SelectedItems : IReadOnlyList + where Tinfo : ISelectedItemInfo + { + private readonly List _infos; + private readonly Func, int, TValue> _getAtImpl; public SelectedItems( - List infos, - Func, int, T> getAtImpl) + List infos, + int count, + Func, int, TValue> getAtImpl) { _infos = infos; _getAtImpl = getAtImpl; - - foreach (var info in infos) - { - var node = info.Node; - - if (node != null) - { - Count += node.SelectedCount; - } - else - { - throw new InvalidOperationException("Selection changed after the SelectedIndices/Items property was read."); - } - } + Count = count; } - public T this[int index] => _getAtImpl(_infos, index); + public TValue this[int index] => _getAtImpl(_infos, index); public int Count { get; } - public IEnumerator GetEnumerator() + public IEnumerator GetEnumerator() { for (var i = 0; i < Count; ++i) { diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 49f2c8d2f6..c8d2c5cc9e 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -165,6 +165,7 @@ namespace Avalonia.Controls if (_selectedItemsCached == null) { var selectedInfos = new List(); + var count = 0; if (_rootNode.Source != null) { @@ -176,6 +177,7 @@ namespace Avalonia.Controls if (currentInfo.Node.SelectedCount > 0) { selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); + count += currentInfo.Node.SelectedCount; } }); } @@ -185,8 +187,9 @@ namespace Avalonia.Controls // 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 ( + var selectedItems = new SelectedItems ( selectedInfos, + count, (infos, index) => { var currentIndex = 0; @@ -233,6 +236,8 @@ namespace Avalonia.Controls if (_selectedIndicesCached == null) { var selectedInfos = new List(); + var count = 0; + SelectionTreeHelper.Traverse( _rootNode, false, @@ -241,6 +246,7 @@ namespace Avalonia.Controls if (currentInfo.Node.SelectedCount > 0) { selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); + count += currentInfo.Node.SelectedCount; } }); @@ -249,8 +255,9 @@ namespace Avalonia.Controls // 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( + var indices = new SelectedItems( selectedInfos, + count, (infos, index) => // callback for GetAt(index) { var currentIndex = 0; @@ -720,7 +727,7 @@ namespace Avalonia.Controls OnSelectionChanged(e); } - internal class SelectedItemInfo + internal class SelectedItemInfo : ISelectedItemInfo { public SelectedItemInfo(SelectionNode node, IndexPath path) { @@ -730,6 +737,7 @@ namespace Avalonia.Controls public SelectionNode Node { get; } public IndexPath Path { get; } + public int Count => Node.SelectedCount; } private struct Operation : IDisposable diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index e927afb9b9..c0228a1cbc 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -14,61 +14,144 @@ namespace Avalonia.Controls public SelectionModelSelectionChangedEventArgs CreateEventArgs() { + var deselectedCount = 0; + var selectedCount = 0; + + foreach (var change in _changes) + { + deselectedCount += change.DeselectedCount; + selectedCount += change.SelectedCount; + } + + var deselectedIndices = new SelectedItems( + _changes, + deselectedCount, + GetDeselectedIndexAt); + var selectedIndices = new SelectedItems( + _changes, + selectedCount, + GetSelectedIndexAt); + var deselectedItems = new SelectedItems( + _changes, + deselectedCount, + GetDeselectedItemAt); + var selectedItems = new SelectedItems( + _changes, + selectedCount, + GetSelectedItemAt); + return new SelectionModelSelectionChangedEventArgs( - CreateIndices(x => x.DeselectedRanges), - CreateIndices(x => x.SelectedRanges), - CreateItems(x => x.DeselectedRanges), - CreateItems(x => x.SelectedRanges)); + deselectedIndices, + selectedIndices, + deselectedItems, + selectedItems); } - private IEnumerable CreateIndices(Func?> selector) + private IndexPath GetDeselectedIndexAt( + List infos, + int index) { - if (_changes == null) - { - yield break; - } + static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; + static List GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; + return GetIndexAt(infos, index, GetCount, GetRanges); + } - foreach (var i in _changes) + private IndexPath GetSelectedIndexAt( + List infos, + int index) + { + static int GetCount(SelectionNodeOperation info) => info.SelectedCount; + static List GetRanges(SelectionNodeOperation info) => info.SelectedRanges; + return GetIndexAt(infos, index, GetCount, GetRanges); + } + + private object GetDeselectedItemAt( + List infos, + int index) + { + static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; + static List GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; + return GetItemAt(infos, index, GetCount, GetRanges); + } + + private object GetSelectedItemAt( + List infos, + int index) + { + static int GetCount(SelectionNodeOperation info) => info.SelectedCount; + static List GetRanges(SelectionNodeOperation info) => info.SelectedRanges; + return GetItemAt(infos, index, GetCount, GetRanges); + } + + private IndexPath GetIndexAt( + List infos, + int index, + Func getCount, + Func> getRanges) + { + var currentIndex = 0; + IndexPath path = default; + + foreach (var info in infos) { - var ranges = selector(i); + var currentCount = getCount(info); - if (ranges != null) + if (index >= currentIndex && index < currentIndex + currentCount) { - foreach (var j in ranges) - { - for (var k = j.Begin; k <= j.End; ++k) - { - yield return i.Path.CloneWithChildIndex(k); - } - } + int targetIndex = GetIndexAt(getRanges(info), index - currentIndex); + path = info.Path.CloneWithChildIndex(targetIndex); + break; } + + currentIndex += currentCount; } + + return path; } - private IEnumerable CreateItems(Func?> selector) + private object GetItemAt( + List infos, + int index, + Func getCount, + Func> getRanges) { - if (_changes == null) + var currentIndex = 0; + object item = null; + + foreach (var info in infos) { - yield break; + var currentCount = getCount(info); + + if (index >= currentIndex && index < currentIndex + currentCount) + { + int targetIndex = GetIndexAt(getRanges(info), index - currentIndex); + item = info.Items.GetAt(targetIndex); + break; + } + + currentIndex += currentCount; } - var result = new List(); + return item; + } - foreach (var i in _changes) + private int GetIndexAt(List ranges, int index) + { + var currentIndex = 0; + + foreach (var range in ranges) { - var ranges = selector(i); + var currentCount = (range.End - range.Begin) + 1; - if (ranges != null && i.Items != null) + if (index >= currentIndex && index < currentIndex + currentCount) { - foreach (var j in ranges) - { - for (var k = j.Begin; k <= j.End; ++k) - { - yield return i.Items.GetAt(k); - } - } + return range.Begin + (index - currentIndex); } + + currentIndex += currentCount; } + + throw new IndexOutOfRangeException(); } } } diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index ae98f6a1ce..4e64ee6e6f 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -12,73 +12,36 @@ namespace Avalonia.Controls { public class SelectionModelSelectionChangedEventArgs : EventArgs { - private readonly IEnumerable? _deselectedIndicesSource; - private readonly IEnumerable? _selectedIndicesSource; - private readonly IEnumerable? _deselectedItemsSource; - private readonly IEnumerable? _selectedItemsSource; - private IReadOnlyList? _deselectedIndices; - private IReadOnlyList? _selectedIndices; - private IReadOnlyList? _deselectedItems; - private IReadOnlyList? _selectedItems; - public SelectionModelSelectionChangedEventArgs( IReadOnlyList? deselectedIndices, IReadOnlyList? selectedIndices, IReadOnlyList? deselectedItems, IReadOnlyList? selectedItems) { - _deselectedIndices = deselectedIndices ?? Array.Empty(); - _selectedIndices = selectedIndices ?? Array.Empty(); - _deselectedItems = deselectedItems ?? Array.Empty(); - _selectedItems= selectedItems ?? Array.Empty(); - } - - public SelectionModelSelectionChangedEventArgs( - IEnumerable? deselectedIndices, - IEnumerable? selectedIndices, - IEnumerable? deselectedItems, - IEnumerable? selectedItems) - { - static void Set(IEnumerable? source, ref IEnumerable? sourceField, ref IReadOnlyList? field) - { - if (source != null) - { - sourceField = source; - } - else - { - field = Array.Empty(); - } - } - - Set(deselectedIndices, ref _deselectedIndicesSource, ref _deselectedIndices); - Set(selectedIndices, ref _selectedIndicesSource, ref _selectedIndices); - Set(deselectedItems, ref _deselectedItemsSource, ref _deselectedItems); - Set(selectedItems, ref _selectedItemsSource, ref _selectedItems); + DeselectedIndices = deselectedIndices ?? Array.Empty(); + SelectedIndices = selectedIndices ?? Array.Empty(); + DeselectedItems = deselectedItems ?? Array.Empty(); + SelectedItems= selectedItems ?? Array.Empty(); } /// /// Gets the indices of the items that were removed from the selection. /// - public IReadOnlyList DeselectedIndices - => _deselectedIndices ??= new List(_deselectedIndicesSource); + public IReadOnlyList DeselectedIndices { get; } /// /// Gets the indices of the items that were added to the selection. /// - public IReadOnlyList SelectedIndices - => _selectedIndices ??= new List(_selectedIndicesSource); + public IReadOnlyList SelectedIndices { get; } /// /// Gets the items that were removed from the selection. /// - public IReadOnlyList DeselectedItems - => _deselectedItems ??= new List(_deselectedItemsSource); + public IReadOnlyList DeselectedItems { get; } /// /// Gets the items that were added to the selection. /// - public IReadOnlyList SelectedItems - => _selectedItems ??= new List(_selectedItemsSource); + public IReadOnlyList SelectedItems { get; } } } diff --git a/src/Avalonia.Controls/SelectionNodeOperation.cs b/src/Avalonia.Controls/SelectionNodeOperation.cs index 04b8554f7c..9622a52f00 100644 --- a/src/Avalonia.Controls/SelectionNodeOperation.cs +++ b/src/Avalonia.Controls/SelectionNodeOperation.cs @@ -6,11 +6,13 @@ using System.Linq; namespace Avalonia.Controls { - internal class SelectionNodeOperation + internal class SelectionNodeOperation : ISelectedItemInfo { private readonly SelectionNode _owner; private List? _selected; private List? _deselected; + private int _selectedCount = -1; + private int _deselectedCount = -1; public SelectionNodeOperation(SelectionNode owner) { @@ -23,9 +25,36 @@ namespace Avalonia.Controls public IndexPath Path => _owner.IndexPath; public ItemsSourceView? Items => _owner.ItemsSourceView; + public int SelectedCount + { + get + { + if (_selectedCount == -1) + { + _selectedCount = (_selected != null) ? IndexRange.GetCount(_selected) : 0; + } + + return _selectedCount; + } + } + + public int DeselectedCount + { + get + { + if (_deselectedCount == -1) + { + _deselectedCount = (_deselected != null) ? IndexRange.GetCount(_deselected) : 0; + } + + return _deselectedCount; + } + } + public void Selected(IndexRange range) { Add(range, ref _selected, _deselected); + _selectedCount = -1; } public void Selected(IEnumerable ranges) @@ -39,6 +68,7 @@ namespace Avalonia.Controls public void Deselected(IndexRange range) { Add(range, ref _deselected, _selected); + _deselectedCount = -1; } public void Deselected(IEnumerable ranges) From 3f6e982be88403f790d38a0d83e5f064c32dda0f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 1 Feb 2020 23:43:24 +0100 Subject: [PATCH 15/52] Added SelectionModel.RetainSelectionOnReset. --- .../Repeater/ItemsSourceView.cs | 2 + src/Avalonia.Controls/SelectionModel.cs | 7 +- src/Avalonia.Controls/SelectionNode.cs | 104 +++++++++++++-- .../SelectionModelTests.cs | 120 ++++++++++++++++++ 4 files changed, 224 insertions(+), 9 deletions(-) diff --git a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs index 02ead7ef36..ecf8abc13f 100644 --- a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs +++ b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs @@ -96,6 +96,8 @@ namespace Avalonia.Controls /// the item. public object GetAt(int index) => _inner[index]; + public int IndexOf(object item) => _inner.IndexOf(item); + /// /// Retrieves the index of the item that has the specified unique identifier (key). /// diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index c8d2c5cc9e..e5f79fa40f 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -73,6 +73,11 @@ namespace Avalonia.Controls } } + public bool RetainSelectionOnReset + { + get => _rootNode.RetainSelectionOnReset; + set => _rootNode.RetainSelectionOnReset = value; + } public IndexPath AnchorIndex { @@ -497,7 +502,7 @@ namespace Avalonia.Controls } public void OnSelectionInvalidatedDueToCollectionChange( - IReadOnlyList? removedItems) + IReadOnlyList? removedItems) { var e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null); OnSelectionChanged(e); diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 85db801100..a8ad634d35 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -32,6 +32,8 @@ namespace Avalonia.Controls private SelectionNodeOperation? _operation; private object? _source; private bool _selectedIndicesCacheIsValid; + private bool _retainSelectionOnReset; + private List? _selectedItems; public SelectionNode(SelectionModel manager, SelectionNode? parent) { @@ -41,6 +43,40 @@ namespace Avalonia.Controls public int AnchorIndex { get; set; } = -1; + public bool RetainSelectionOnReset + { + get => _retainSelectionOnReset; + set + { + if (_retainSelectionOnReset != value) + { + _retainSelectionOnReset = value; + + if (_retainSelectionOnReset) + { + _selectedItems = new List(); + + foreach (var i in SelectedIndices) + { + _selectedItems.Add(ItemsSourceView!.GetAt(i)); + } + } + else + { + _selectedItems = null; + } + + foreach (var child in _childrenNodes) + { + if (child != null) + { + child.RetainSelectionOnReset = value; + } + } + } + } + } + public object? Source { get => _source; @@ -414,6 +450,14 @@ namespace Avalonia.Controls { _operation?.Selected(selected); + if (_selectedItems != null) + { + for (var i = addRange.Begin; i <= addRange.End; ++i) + { + _selectedItems.Add(ItemsSourceView!.GetAt(i)); + } + } + if (raiseOnSelectionChanged) { OnSelectionChanged(); @@ -431,6 +475,14 @@ namespace Avalonia.Controls { _operation?.Deselected(removed); + if (_selectedItems != null) + { + for (var i = removeRange.Begin; i <= removeRange.End; ++i) + { + _selectedItems.Remove(ItemsSourceView!.GetAt(i)); + } + } + if (raiseOnSelectionChanged) { OnSelectionChanged(); @@ -448,6 +500,7 @@ namespace Avalonia.Controls OnSelectionChanged(); } + _selectedItems?.Clear(); SelectedCount = 0; AnchorIndex = -1; @@ -492,7 +545,7 @@ namespace Avalonia.Controls private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) { bool selectionInvalidated = false; - List? removed = null; + List? removed = null; switch (args.Action) { @@ -509,11 +562,19 @@ namespace Avalonia.Controls } case NotifyCollectionChangedAction.Reset: - { - ClearSelection(); - selectionInvalidated = true; - break; - } + { + if (_selectedItems == null) + { + ClearSelection(); + } + else + { + removed = RecreateSelectionFromSelectedItems(); + } + + selectionInvalidated = true; + break; + } case NotifyCollectionChangedAction.Replace: { @@ -606,10 +667,10 @@ namespace Avalonia.Controls return selectionInvalidated; } - private (bool, List) OnItemsRemoved(int index, IList items) + private (bool, List) OnItemsRemoved(int index, IList items) { var selectionInvalidated = false; - var removed = new List(); + var removed = new List(); var count = items.Count; // Remove the items from the selection for leaf @@ -804,6 +865,33 @@ namespace Avalonia.Controls return selectionState; } + private List RecreateSelectionFromSelectedItems() + { + var removed = new List(); + + _selected.Clear(); + SelectedCount = 0; + + for (var i = 0; i < _selectedItems!.Count; ++i) + { + var item = _selectedItems[i]; + var index = ItemsSourceView!.IndexOf(item); + + if (index != -1) + { + IndexRange.Add(_selected, new IndexRange(index, index)); + ++SelectedCount; + } + else + { + removed.Add(item); + _selectedItems.RemoveAt(i--); + } + } + + return removed; + } + public enum SelectionState { Selected, diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index b9149bf42b..b3a5e0959f 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -7,6 +7,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.Linq; using Avalonia.Collections; using Avalonia.Diagnostics; @@ -1392,6 +1393,107 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(2, raised); } + [Fact] + public void RetainSelectionOnReset_Retains_Selection_On_Reset() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + data.Reset(); + + Assert.Equal(new[] { new IndexPath(1), new IndexPath(2) }, target.SelectedIndices); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + target.Deselect(2); + data.Reset(); + + Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); + Assert.Equal(new[] { "bar", }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_No_Selection_After_Clear() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + target.ClearSelection(); + data.Reset(); + + Assert.Empty(target.SelectedIndices); + Assert.Empty(target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_Correct_Selection_After_Two_Resets() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + data.Reset(new[] { "foo", "bar" }); + data.Reset(new[] { "foo", "bar", "baz" }); + + Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); + Assert.Equal(new[] { "bar", }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Raises_Empty_SelectionChanged_On_Reset_With_No_Changes() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + var raised = 0; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Reset(); + + Assert.Equal(1, raised); + } + + [Fact] + public void RetainSelectionOnReset_Raises_SelectionChanged_On_Reset_With_Removed_Items() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + var raised = 0; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Reset(new[] { "foo", "baz" }); + + Assert.Equal(1, raised); + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; @@ -1743,6 +1845,24 @@ namespace Avalonia.Controls.UnitTests public LogWrapper(ITestOutputHelper output) => _output = output; public void Comment(string s) => _output.WriteLine(s); } + + private class ResettingList : List, INotifyCollectionChanged + { + public event NotifyCollectionChangedEventHandler CollectionChanged; + + public void Reset(IEnumerable items = null) + { + if (items != null) + { + Clear(); + AddRange(items); + } + + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } } class CustomSelectionModel : SelectionModel From fcaa250c72cbbb691911456a1be0691663e41c63 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 3 Feb 2020 13:52:43 +0100 Subject: [PATCH 16/52] Ported fix and test from WinUI. https://github.com/microsoft/microsoft-ui-xaml/pull/1922 --- .../Utils/SelectionTreeHelper.cs | 18 +++++++---- .../SelectionModelTests.cs | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs index 93102a7b5b..430ecabbb8 100644 --- a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs +++ b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs @@ -132,15 +132,21 @@ namespace Avalonia.Controls.Utils private static bool IsSubSet(IndexPath path, IndexPath subset) { - bool isSubset = true; - for (int i = 0; i < subset.GetSize(); i++) + var subsetSize = subset.GetSize(); + if (path.GetSize() < subsetSize) { - isSubset = path.GetAt(i) == subset.GetAt(i); - if (!isSubset) - break; + return false; + } + + for (int i = 0; i < subsetSize; i++) + { + if (path.GetAt(i) != subset.GetAt(i)) + { + return false; + } } - return isSubset; + return true; } private static IndexPath StartPath(IndexPath path, int length) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 6c3137c636..208d85d8fd 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -898,6 +898,37 @@ namespace Avalonia.Controls.UnitTests }); } + [Fact] + public void SelectRangeRegressionTest() + { + RunOnUIThread.Execute(() => + { + var selectionModel = new SelectionModel() + { + Source = CreateNestedData(1, 2, 3) + }; + + // length of start smaller than end used to cause an out of range error. + selectionModel.SelectRange(IndexPath.CreateFrom(0), IndexPath.CreateFrom(1, 1)); + + ValidateSelection(selectionModel, + new List() + { + Path(0, 0), + Path(0, 1), + Path(0, 2), + Path(0), + Path(1, 0), + Path(1, 1) + }, + new List() + { + Path(), + Path(1) + }, + 1 /* selectedInnerNodes */); + }); + } [Fact] public void Disposing_Unhooks_CollectionChanged_Handlers() From bc4eefcf1b0e90d7d93cb9ffd09607a3e5d78fbe Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 26 Jan 2020 09:59:48 +0100 Subject: [PATCH 17/52] Add `IndexRange` list add/remove methods. Add or remove index ranges from a list of index ranges, merging and splitting ranges as required. --- src/Avalonia.Controls/IndexRange.cs | 173 +++++++++- .../IndexRangeTests.cs | 307 ++++++++++++++++++ 2 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs diff --git a/src/Avalonia.Controls/IndexRange.cs b/src/Avalonia.Controls/IndexRange.cs index b1a112ab39..124f1e0500 100644 --- a/src/Avalonia.Controls/IndexRange.cs +++ b/src/Avalonia.Controls/IndexRange.cs @@ -3,12 +3,17 @@ // // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. +using System; +using System.Collections.Generic; + #nullable enable namespace Avalonia.Controls { - internal readonly struct IndexRange + internal readonly struct IndexRange : IEquatable { + private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue); + public IndexRange(int begin, int end) { // Accept out of order begin/end pairs, just swap them. @@ -25,11 +30,9 @@ namespace Avalonia.Controls public int Begin { get; } public int End { get; } + public int Count => (End - Begin) + 1; - public bool Contains(int index) - { - return index >= Begin && index <= End; - } + public bool Contains(int index) => index >= Begin && index <= End; public bool Split(int splitIndex, out IndexRange before, out IndexRange after) { @@ -54,6 +57,164 @@ namespace Avalonia.Controls public bool Intersects(IndexRange other) { return (Begin <= other.End) && (End >= other.Begin); - } + } + + public bool Adjacent(IndexRange other) + { + return Begin == other.End + 1 || End == other.Begin - 1; + } + + public override bool Equals(object? obj) + { + return obj is IndexRange range && Equals(range); + } + + public bool Equals(IndexRange other) + { + return Begin == other.Begin && End == other.End; + } + + public override int GetHashCode() + { + var hashCode = 1903003160; + hashCode = hashCode * -1521134295 + Begin.GetHashCode(); + hashCode = hashCode * -1521134295 + End.GetHashCode(); + return hashCode; + } + + public override string ToString() => $"[{Begin}..{End}]"; + + public static bool operator ==(IndexRange left, IndexRange right) => left.Equals(right); + public static bool operator !=(IndexRange left, IndexRange right) => !(left == right); + + public static int Add( + IList ranges, + IndexRange range, + IList? added = null) + { + var result = 0; + + for (var i = 0; i < ranges.Count && range != s_invalid; ++i) + { + var existing = ranges[i]; + + if (range.Intersects(existing) || range.Adjacent(existing)) + { + if (range.Begin < existing.Begin) + { + var add = new IndexRange(range.Begin, existing.Begin - 1); + ranges[i] = new IndexRange(range.Begin, existing.End); + added?.Add(add); + result += add.Count; + } + + range = range.End <= existing.End ? + s_invalid : + new IndexRange(existing.End + 1, range.End); + } + else if (range.End < existing.Begin) + { + ranges.Insert(i, range); + added?.Add(range); + result += range.Count; + range = s_invalid; + } + } + + if (range != s_invalid) + { + ranges.Add(range); + added?.Add(range); + result += range.Count; + } + + MergeRanges(ranges); + return result; + } + + public static int Remove( + IList ranges, + IndexRange range, + IList? removed = null) + { + var result = 0; + + for (var i = 0; i < ranges.Count; ++i) + { + var existing = ranges[i]; + + if (range.Intersects(existing)) + { + if (range.Begin <= existing.Begin && range.End >= existing.End) + { + ranges.RemoveAt(i--); + removed?.Add(existing); + result += existing.Count; + } + else if (range.Begin > existing.Begin && range.End >= existing.End) + { + ranges[i] = new IndexRange(existing.Begin, range.Begin - 1); + removed?.Add(new IndexRange(range.Begin, existing.End)); + result += existing.End - (range.Begin - 1); + } + else if (range.Begin > existing.Begin && range.End < existing.End) + { + ranges[i] = new IndexRange(existing.Begin, range.Begin - 1); + ranges.Insert(++i, new IndexRange(range.End + 1, existing.End)); + removed?.Add(range); + result += range.Count; + } + else if (range.End <= existing.End) + { + var remove = new IndexRange(existing.Begin, range.End); + ranges[i] = new IndexRange(range.End + 1, existing.End); + removed?.Add(remove); + result += remove.Count; + } + } + } + + return result; + } + + public static IEnumerable Subtract( + IndexRange lhs, + IEnumerable rhs) + { + var result = new List { lhs }; + + foreach (var range in rhs) + { + Remove(result, range); + } + + return result; + } + + public static IEnumerable EnumerateIndices(IEnumerable ranges) + { + foreach (var range in ranges) + { + for (var i = range.Begin; i <= range.End; ++i) + { + yield return i; + } + } + } + + private static void MergeRanges(IList ranges) + { + for (var i = ranges.Count - 2; i >= 0; --i) + { + var r = ranges[i]; + var r1 = ranges[i + 1]; + + if (r.Intersects(r1) || r.End == r1.Begin - 1) + { + ranges[i] = new IndexRange(r.Begin, r1.End); + ranges.RemoveAt(i + 1); + } + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs b/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs new file mode 100644 index 0000000000..e0f46d9fa9 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class IndexRangeTests + { + [Fact] + public void Add_Should_Add_Range_To_Empty_List() + { + var ranges = new List(); + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(0, 4) }, ranges); + Assert.Equal(new[] { new IndexRange(0, 4) }, selected); + } + + [Fact] + public void Add_Should_Add_Non_Intersecting_Range_At_End() + { + var ranges = new List { new IndexRange(0, 4) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, selected); + } + + [Fact] + public void Add_Should_Add_Non_Intersecting_Range_At_Beginning() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges); + Assert.Equal(new[] { new IndexRange(0, 4) }, selected); + } + + [Fact] + public void Add_Should_Add_Non_Intersecting_Range_In_Middle() + { + var ranges = new List { new IndexRange(0, 4), new IndexRange(14, 16) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10), new IndexRange(14, 16) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, selected); + } + + [Fact] + public void Add_Should_Add_Intersecting_Range_Start() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(6, 9), selected); + + Assert.Equal(2, result); + Assert.Equal(new[] { new IndexRange(6, 10) }, ranges); + Assert.Equal(new[] { new IndexRange(6, 7) }, selected); + } + + [Fact] + public void Add_Should_Add_Intersecting_Range_End() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(9, 12), selected); + + Assert.Equal(2, result); + Assert.Equal(new[] { new IndexRange(8, 12) }, ranges); + Assert.Equal(new[] { new IndexRange(11, 12) }, selected); + } + + [Fact] + public void Add_Should_Add_Intersecting_Range_Both() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(6, 12), selected); + + Assert.Equal(4, result); + Assert.Equal(new[] { new IndexRange(6, 12) }, ranges); + Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 12) }, selected); + } + + [Fact] + public void Add_Should_Join_Two_Intersecting_Ranges() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(8, 14), selected); + + Assert.Equal(1, result); + Assert.Equal(new[] { new IndexRange(8, 14) }, ranges); + Assert.Equal(new[] { new IndexRange(11, 11) }, selected); + } + + [Fact] + public void Add_Should_Join_Two_Intersecting_Ranges_And_Add_Ranges() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(6, 18), selected); + + Assert.Equal(7, result); + Assert.Equal(new[] { new IndexRange(6, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 11), new IndexRange(15, 18) }, selected); + } + + [Fact] + public void Add_Should_Not_Add_Already_Selected_Range() + { + var ranges = new List { new IndexRange(8, 10) }; + var selected = new List(); + var result = IndexRange.Add(ranges, new IndexRange(9, 10), selected); + + Assert.Equal(0, result); + Assert.Equal(new[] { new IndexRange(8, 10) }, ranges); + Assert.Empty(selected); + } + + [Fact] + public void Remove_Should_Remove_Entire_Range() + { + var ranges = new List { new IndexRange(8, 10) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected); + + Assert.Equal(3, result); + Assert.Empty(ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Start_Of_Range() + { + var ranges = new List { new IndexRange(8, 12) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(11, 12) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_End_Of_Range() + { + var ranges = new List { new IndexRange(8, 12) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(10, 12), deselected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(8, 9) }, ranges); + Assert.Equal(new[] { new IndexRange(10, 12) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Overlapping_End_Of_Range() + { + var ranges = new List { new IndexRange(8, 12) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(10, 14), deselected); + + Assert.Equal(3, result); + Assert.Equal(new[] { new IndexRange(8, 9) }, ranges); + Assert.Equal(new[] { new IndexRange(10, 12) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Middle_Of_Range() + { + var ranges = new List { new IndexRange(10, 20) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(12, 16), deselected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(10, 11), new IndexRange(17, 20) }, ranges); + Assert.Equal(new[] { new IndexRange(12, 16) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_Ranges() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(6, 15), deselected); + + Assert.Equal(6, result); + Assert.Equal(new[] { new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 14) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_And_Partial_Ranges_1() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(9, 15), deselected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 14) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_And_Partial_Ranges_2() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(8, 13), deselected); + + Assert.Equal(5, result); + Assert.Equal(new[] { new IndexRange(14, 14), new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 13) }, deselected); + } + + [Fact] + public void Remove_Should_Remove_Multiple_And_Partial_Ranges_3() + { + var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(9, 13), deselected); + + Assert.Equal(4, result); + Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(14, 14), new IndexRange(16, 18) }, ranges); + Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 13) }, deselected); + } + + [Fact] + public void Remove_Should_Do_Nothing_For_Unselected_Range() + { + var ranges = new List { new IndexRange(8, 10) }; + var deselected = new List(); + var result = IndexRange.Remove(ranges, new IndexRange(2, 4), deselected); + + Assert.Equal(0, result); + Assert.Equal(new[] { new IndexRange(8, 10) }, ranges); + Assert.Empty(deselected); + } + + [Fact] + public void Stress_Test() + { + const int iterations = 100; + var random = new Random(0); + var selection = new List(); + var expected = new List(); + + IndexRange Generate() + { + var start = random.Next(100); + return new IndexRange(start, start + random.Next(20)); + } + + for (var i = 0; i < iterations; ++i) + { + var toAdd = random.Next(5); + + for (var j = 0; j < toAdd; ++j) + { + var range = Generate(); + IndexRange.Add(selection, range); + + for (var k = range.Begin; k <= range.End; ++k) + { + if (!expected.Contains(k)) + { + expected.Add(k); + } + } + + var actual = IndexRange.EnumerateIndices(selection).ToList(); + expected.Sort(); + Assert.Equal(expected, actual); + } + + var toRemove = random.Next(5); + + for (var j = 0; j < toRemove; ++j) + { + var range = Generate(); + IndexRange.Remove(selection, range); + + for (var k = range.Begin; k <= range.End; ++k) + { + expected.Remove(k); + } + + var actual = IndexRange.EnumerateIndices(selection).ToList(); + Assert.Equal(expected, actual); + } + + selection.Clear(); + expected.Clear(); + } + } + } +} From 7548dc9c2edaf687ebe1d1bfe9371386ef1d5df9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 26 Jan 2020 11:47:51 +0100 Subject: [PATCH 18/52] Added SelectionModel changed args. `SelectionModel` as ported from WinUI has no information about what changed in a `SelectionChanged` event. This adds that information along with unit tests. --- src/Avalonia.Controls/SelectionModel.cs | 191 +++++--- .../SelectionModelChangeSet.cs | 144 ++++++ ...SelectionModelSelectionChangedEventArgs.cs | 45 ++ src/Avalonia.Controls/SelectionNode.cs | 150 +++---- .../SelectionModelTests.cs | 417 ++++++++++++++++++ 5 files changed, 804 insertions(+), 143 deletions(-) create mode 100644 src/Avalonia.Controls/SelectionModelChangeSet.cs diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 34d5f78434..5e2fb32243 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using Avalonia.Controls.Utils; #nullable enable @@ -19,7 +20,6 @@ namespace Avalonia.Controls private IReadOnlyList? _selectedIndicesCached; private IReadOnlyList? _selectedItemsCached; private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs; - private SelectionModelSelectionChangedEventArgs? _selectionChangedEventArgs; public event EventHandler? ChildrenRequested; public event PropertyChangedEventHandler? PropertyChanged; @@ -36,9 +36,12 @@ namespace Avalonia.Controls get => _rootNode?.Source; set { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + using (var operation = new Operation(this)) + { + ClearSelection(resetAnchor: true); + } + _rootNode.Source = value; - OnSelectionChanged(); RaisePropertyChanged("Source"); } } @@ -55,12 +58,13 @@ namespace Avalonia.Controls if (value && selectedIndices != null && selectedIndices.Count > 0) { + using var operation = new Operation(this); + // 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. + ClearSelection(resetAnchor: true); + SelectWithPathImpl(firstSelectionIndexPath, select: true); SelectedIndex = firstSelectionIndexPath; } @@ -131,9 +135,9 @@ namespace Avalonia.Controls if (!isSelected.HasValue || !isSelected.Value) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); - SelectWithPathImpl(value, select: true, raiseSelectionChanged: false); - OnSelectionChanged(); + using var operation = new Operation(this); + ClearSelection(resetAnchor: true); + SelectWithPathImpl(value, select: true); } } } @@ -289,7 +293,7 @@ namespace Avalonia.Controls public void Dispose() { - ClearSelection(resetAnchor: false, raiseSelectionChanged: false); + ClearSelection(resetAnchor: false); _rootNode?.Dispose(); _selectedIndicesCached = null; _selectedItemsCached = null; @@ -299,17 +303,41 @@ namespace Avalonia.Controls 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 index) + { + using var operation = new Operation(this); + SelectImpl(index, select: true); + } - public void Select(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: true); + public void Select(int groupIndex, int itemIndex) + { + using var operation = new Operation(this); + SelectWithGroupImpl(groupIndex, itemIndex, select: true); + } - public void SelectAt(IndexPath index) => SelectWithPathImpl(index, select: true, raiseSelectionChanged: true); + public void SelectAt(IndexPath index) + { + using var operation = new Operation(this); + SelectWithPathImpl(index, select: true); + } - public void Deselect(int index) => SelectImpl(index, select: false); + public void Deselect(int index) + { + using var operation = new Operation(this); + SelectImpl(index, select: false); + } - public void Deselect(int groupIndex, int itemIndex) => SelectWithGroupImpl(groupIndex, itemIndex, select: false); + public void Deselect(int groupIndex, int itemIndex) + { + using var operation = new Operation(this); + SelectWithGroupImpl(groupIndex, itemIndex, select: false); + } - public void DeselectAt(IndexPath index) => SelectWithPathImpl(index, select: false, raiseSelectionChanged: true); + public void DeselectAt(IndexPath index) + { + using var operation = new Operation(this); + SelectWithPathImpl(index, select: false); + } public bool? IsSelected(int index) { @@ -383,46 +411,56 @@ namespace Avalonia.Controls public void SelectRangeFromAnchor(int index) { + using var operation = new Operation(this); SelectRangeFromAnchorImpl(index, select: true); } public void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex) { + using var operation = new Operation(this); SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, select: true); } public void SelectRangeFromAnchorTo(IndexPath index) { + using var operation = new Operation(this); SelectRangeImpl(AnchorIndex, index, select: true); } public void DeselectRangeFromAnchor(int index) { + using var operation = new Operation(this); SelectRangeFromAnchorImpl(index, select: false); } public void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex) { + using var operation = new Operation(this); SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, false /* select */); } public void DeselectRangeFromAnchorTo(IndexPath index) { + using var operation = new Operation(this); SelectRangeImpl(AnchorIndex, index, select: false); } public void SelectRange(IndexPath start, IndexPath end) { + using var operation = new Operation(this); SelectRangeImpl(start, end, select: true); } public void DeselectRange(IndexPath start, IndexPath end) { + using var operation = new Operation(this); SelectRangeImpl(start, end, select: false); } public void SelectAll() { + using var operation = new Operation(this); + SelectionTreeHelper.Traverse( _rootNode, realizeChildren: true, @@ -433,13 +471,12 @@ namespace Avalonia.Controls info.Node.SelectAll(); } }); - - OnSelectionChanged(); } public void ClearSelection() { - ClearSelection(resetAnchor: true, raiseSelectionChanged: true); + using var operation = new Operation(this); + ClearSelection(resetAnchor: true); } protected void OnPropertyChanged(string propertyName) @@ -452,9 +489,15 @@ namespace Avalonia.Controls PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public void OnSelectionInvalidatedDueToCollectionChange() + public void OnSelectionInvalidatedDueToCollectionChange( + IEnumerable? removedItems) { - OnSelectionChanged(); + var e = new SelectionModelSelectionChangedEventArgs( + Enumerable.Empty(), + Enumerable.Empty(), + removedItems ?? Enumerable.Empty(), + Enumerable.Empty()); + OnSelectionChanged(e); } internal object? ResolvePath(object data, SelectionNode sourceNode) @@ -496,7 +539,7 @@ namespace Avalonia.Controls return resolved; } - private void ClearSelection(bool resetAnchor, bool raiseSelectionChanged) + private void ClearSelection(bool resetAnchor) { SelectionTreeHelper.Traverse( _rootNode, @@ -507,27 +550,17 @@ namespace Avalonia.Controls { AnchorIndex = default; } - - if (raiseSelectionChanged) - { - OnSelectionChanged(); - } } - private void OnSelectionChanged() + private void OnSelectionChanged(SelectionModelSelectionChangedEventArgs? e = null) { _selectedIndicesCached = null; _selectedItemsCached = null; // Raise SelectionChanged event - if (SelectionChanged != null) + if (e != null) { - if (_selectionChangedEventArgs == null) - { - _selectionChangedEventArgs = new SelectionModelSelectionChangedEventArgs(); - } - - SelectionChanged(this, _selectionChangedEventArgs); + SelectionChanged?.Invoke(this, e); } RaisePropertyChanged(nameof(SelectedIndex)); @@ -544,7 +577,7 @@ namespace Avalonia.Controls { if (_singleSelect) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + ClearSelection(resetAnchor: true); } var selected = _rootNode.Select(index, select); @@ -553,15 +586,13 @@ namespace Avalonia.Controls { AnchorIndex = new IndexPath(index); } - - OnSelectionChanged(); } private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select) { if (_singleSelect) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + ClearSelection(resetAnchor: true); } var childNode = _rootNode.GetAt(groupIndex, realizeChild: true); @@ -571,17 +602,15 @@ namespace Avalonia.Controls { AnchorIndex = new IndexPath(groupIndex, itemIndex); } - - OnSelectionChanged(); } - private void SelectWithPathImpl(IndexPath index, bool select, bool raiseSelectionChanged) + private void SelectWithPathImpl(IndexPath index, bool select) { bool selected = false; if (_singleSelect) { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + ClearSelection(resetAnchor: true); } SelectionTreeHelper.TraverseIndexPath( @@ -601,11 +630,6 @@ namespace Avalonia.Controls { AnchorIndex = index; } - - if (raiseSelectionChanged) - { - OnSelectionChanged(); - } } private void SelectRangeFromAnchorImpl(int index, bool select) @@ -618,12 +642,7 @@ namespace Avalonia.Controls anchorIndex = anchor.GetAt(0); } - bool selected = _rootNode.SelectRange(new IndexRange(anchorIndex, index), select); - - if (selected) - { - OnSelectionChanged(); - } + _rootNode.SelectRange(new IndexRange(anchorIndex, index), select); } private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select) @@ -650,18 +669,12 @@ namespace Avalonia.Controls 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(); + groupNode.SelectRange(new IndexRange(startIndex, endIndex), select); } } @@ -691,8 +704,55 @@ namespace Avalonia.Controls info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); } }); + } + + private void BeginOperation() + { + if (SelectionChanged != null) + { + _rootNode.BeginOperation(); + } + } + + private void EndOperation() + { + static IEnumerable? Concat(IEnumerable? a, IEnumerable b) + { + return a == null ? b : a.Concat(b); + } - OnSelectionChanged(); + SelectionModelSelectionChangedEventArgs? e = null; + + if (SelectionChanged != null) + { + IEnumerable? selectedIndices = null; + IEnumerable? deselectedIndices = null; + IEnumerable? selectedItems = null; + IEnumerable? deselectedItems = null; + + foreach (var changes in _rootNode.EndOperation()) + { + if (changes.HasChanges) + { + selectedIndices = Concat(selectedIndices, changes.SelectedIndices); + deselectedIndices = Concat(deselectedIndices, changes.DeselectedIndices); + selectedItems = Concat(selectedItems, changes.SelectedItems); + deselectedItems = Concat(deselectedItems, changes.DeselectedItems); + } + } + + if (selectedIndices != null || deselectedIndices != null || + selectedItems != null || deselectedItems != null) + { + e = new SelectionModelSelectionChangedEventArgs( + deselectedIndices ?? Enumerable.Empty(), + selectedIndices ?? Enumerable.Empty(), + deselectedItems ?? Enumerable.Empty(), + selectedItems ?? Enumerable.Empty()); + } + } + + OnSelectionChanged(e); } internal class SelectedItemInfo @@ -706,5 +766,12 @@ namespace Avalonia.Controls public SelectionNode Node { get; } public IndexPath Path { get; } } + + private struct Operation : IDisposable + { + private readonly SelectionModel _manager; + public Operation(SelectionModel manager) => (_manager = manager).BeginOperation(); + public void Dispose() => _manager.EndOperation(); + } } } diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs new file mode 100644 index 0000000000..989136ac8d --- /dev/null +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls +{ + internal class SelectionModelChangeSet + { + private SelectionNode _owner; + private List? _selected; + private List? _deselected; + + public SelectionModelChangeSet(SelectionNode owner) => _owner = owner; + + public bool IsTracking { get; private set; } + public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0; + public IEnumerable SelectedIndices => EnumerateIndices(_selected); + public IEnumerable DeselectedIndices => EnumerateIndices(_deselected); + public IEnumerable SelectedItems => EnumerateItems(_selected); + public IEnumerable DeselectedItems => EnumerateItems(_deselected); + + public void BeginOperation() + { + if (IsTracking) + { + throw new AvaloniaInternalException("SelectionModel change operation already in progress."); + } + + IsTracking = true; + _selected?.Clear(); + _deselected?.Clear(); + } + + public void EndOperation() => IsTracking = false; + + public void Selected(IndexRange range) + { + if (!IsTracking) + { + return; + } + + Add(range, ref _selected, _deselected); + } + + public void Selected(IEnumerable ranges) + { + if (!IsTracking) + { + return; + } + + foreach (var range in ranges) + { + Selected(range); + } + } + + public void Deselected(IndexRange range) + { + if (!IsTracking) + { + return; + } + + Add(range, ref _deselected, _selected); + } + + public void Deselected(IEnumerable ranges) + { + if (!IsTracking) + { + return; + } + + foreach (var range in ranges) + { + Deselected(range); + } + } + + private static void Add( + IndexRange range, + ref List? add, + List? remove) + { + if (remove != null) + { + var removed = new List(); + IndexRange.Remove(remove, range, removed); + var selected = IndexRange.Subtract(range, removed); + + if (selected.Any()) + { + add ??= new List(); + + foreach (var r in selected) + { + IndexRange.Add(add, r); + } + } + } + else + { + add ??= new List(); + IndexRange.Add(add, range); + } + } + + private IEnumerable EnumerateIndices(IEnumerable? ranges) + { + var path = _owner.IndexPath; + + if (ranges != null) + { + foreach (var range in ranges) + { + for (var i = range.Begin; i <= range.End; ++i) + { + yield return path.CloneWithChildIndex(i); + } + } + } + } + + private IEnumerable EnumerateItems(IEnumerable? ranges) + { + var items = _owner.ItemsSourceView; + + if (ranges != null && items != null) + { + foreach (var range in ranges) + { + for (var i = range.Begin; i <= range.End; ++i) + { + yield return items.GetAt(i); + } + } + } + } + } +} diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index c8edc1f8ae..4976bf1827 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -4,6 +4,7 @@ // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. using System; +using System.Collections.Generic; #nullable enable @@ -11,5 +12,49 @@ namespace Avalonia.Controls { public class SelectionModelSelectionChangedEventArgs : EventArgs { + private readonly IEnumerable _selectedIndicesSource; + private readonly IEnumerable _deselectedIndicesSource; + private readonly IEnumerable _selectedItemsSource; + private readonly IEnumerable _deselectedItemsSource; + private List? _selectedIndices; + private List? _deselectedIndices; + private List? _selectedItems; + private List? _deselectedItems; + + public SelectionModelSelectionChangedEventArgs( + IEnumerable deselectedIndices, + IEnumerable selectedIndices, + IEnumerable deselectedItems, + IEnumerable selectedItems) + { + _selectedIndicesSource = selectedIndices; + _deselectedIndicesSource = deselectedIndices; + _selectedItemsSource = selectedItems; + _deselectedItemsSource = deselectedItems; + } + + /// + /// Gets the indices of the items that were added to the selection. + /// + public IReadOnlyList SelectedIndices => + _selectedIndices ?? (_selectedIndices = new List(_selectedIndicesSource)); + + /// + /// Gets the indices of the items that were removed from the selection. + /// + public IReadOnlyList DeselectedIndices => + _deselectedIndices ?? (_deselectedIndices = new List(_deselectedIndicesSource)); + + /// + /// Gets the items that were added to the selection. + /// + public IReadOnlyList SelectedItems => + _selectedItems ?? (_selectedItems = new List(_selectedItemsSource)); + + /// + /// Gets the items that were removed from the selection. + /// + public IReadOnlyList DeselectedItems => + _deselectedItems ?? (_deselectedItems = new List(_deselectedItemsSource)); } } diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 363eb35b94..d462a51228 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -7,6 +7,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.Linq; #nullable enable @@ -28,6 +29,7 @@ namespace Avalonia.Controls private readonly SelectionNode? _parent; private readonly List _selected = new List(); private readonly List _selectedIndicesCached = new List(); + private SelectionModelChangeSet? _changes; private object? _source; private bool _selectedIndicesCacheIsValid; @@ -134,6 +136,11 @@ namespace Avalonia.Controls { child = new SelectionNode(_manager, parent: this); child.Source = resolvedChild; + + if (_changes?.IsTracking == true) + { + child.BeginOperation(); + } } else { @@ -276,12 +283,50 @@ namespace Avalonia.Controls } } + public IEnumerable SelectedItems + { + get => SelectedIndices.Select(x => ItemsSourceView!.GetAt(x)); + } + public void Dispose() { ItemsSourceView?.Dispose(); UnhookCollectionChangedHandler(); } + public void BeginOperation() + { + _changes ??= new SelectionModelChangeSet(this); + _changes.BeginOperation(); + + for (var i = 0; i < _childrenNodes.Count; ++i) + { + _childrenNodes[i]?.BeginOperation(); + } + } + + public IEnumerable EndOperation() + { + if (_changes != null) + { + _changes.EndOperation(); + yield return _changes; + + for (var i = 0; i < _childrenNodes.Count; ++i) + { + var child = _childrenNodes[i]; + + if (child != null) + { + foreach (var changes in child.EndOperation()) + { + yield return changes; + } + } + } + } + } + public bool Select(int index, bool select) { return Select(index, select, raiseOnSelectionChanged: true); @@ -349,21 +394,13 @@ namespace Avalonia.Controls private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged) { - // TODO: Check for duplicates (Task 14107720) - // TODO: Optimize by merging adjacent ranges (Task 14107720) - var oldCount = SelectedCount; + var selected = new List(); - for (int i = addRange.Begin; i <= addRange.End; i++) - { - if (!IsSelected(i)) - { - SelectedCount++; - } - } + SelectedCount += IndexRange.Add(_selected, addRange, selected); - if (oldCount != SelectedCount) + if (selected.Count > 0) { - _selected.Add(addRange); + _changes?.Selected(selected); if (raiseOnSelectionChanged) { @@ -374,71 +411,17 @@ namespace Avalonia.Controls private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged) { - int oldCount = SelectedCount; + var removed = new List(); - // TODO: Prevent overlap of Ranges in _selected (Task 14107720) - for (int i = removeRange.Begin; i <= removeRange.End; i++) - { - if (IsSelected(i)) - { - SelectedCount--; - } - } + SelectedCount -= IndexRange.Remove(_selected, removeRange, removed); - if (oldCount != SelectedCount) + if (removed.Count > 0) { - // 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); - } + _changes?.Deselected(removed); - // 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) + if (raiseOnSelectionChanged) { - // Remove tagged ranges - foreach (var remove in toRemove) - { - _selected.Remove(remove); - } - - // Add new ranges - _selected.AddRange(toAdd); - - if (raiseOnSelectionChanged) - { - OnSelectionChanged(); - } + OnSelectionChanged(); } } } @@ -448,6 +431,7 @@ namespace Avalonia.Controls // Deselect all items if (_selected.Count > 0) { + _changes?.Deselected(_selected); _selected.Clear(); OnSelectionChanged(); } @@ -496,6 +480,7 @@ namespace Avalonia.Controls private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) { bool selectionInvalidated = false; + IList? removed = null; switch (args.Action) { @@ -507,7 +492,7 @@ namespace Avalonia.Controls case NotifyCollectionChangedAction.Remove: { - selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); + (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems); break; } @@ -520,7 +505,7 @@ namespace Avalonia.Controls case NotifyCollectionChangedAction.Replace: { - selectionInvalidated = OnItemsRemoved(args.OldStartingIndex, args.OldItems.Count); + (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems); selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); break; } @@ -529,7 +514,7 @@ namespace Avalonia.Controls if (selectionInvalidated) { OnSelectionChanged(); - _manager.OnSelectionInvalidatedDueToCollectionChange(); + _manager.OnSelectionInvalidatedDueToCollectionChange(removed); } } @@ -609,21 +594,23 @@ namespace Avalonia.Controls return selectionInvalidated; } - private bool OnItemsRemoved(int index, int count) + private (bool, IList) OnItemsRemoved(int index, IList items) { - bool selectionInvalidated = false; + var selectionInvalidated = false; + var removed = new List(); + var count = items.Count; // Remove the items from the selection for leaf if (ItemsSourceView!.Count > 0) { bool isSelected = false; - for (int i = index; i <= index + count - 1; i++) + for (int i = 0; i <= count - 1; i++) { - if (IsSelected(i)) + if (IsSelected(index + i)) { isSelected = true; - break; + removed.Add(items[i]); } } @@ -654,6 +641,7 @@ namespace Avalonia.Controls { if (_childrenNodes[index] != null) { + removed.AddRange(_childrenNodes[index]!.SelectedItems); RealizedChildrenNodeCount--; } _childrenNodes.RemoveAt(index); @@ -696,7 +684,7 @@ namespace Avalonia.Controls } } - return selectionInvalidated; + return (selectionInvalidated, removed); } private void OnSelectionChanged() diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 208d85d8fd..1cca809c1d 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -930,6 +930,423 @@ namespace Avalonia.Controls.UnitTests }); } + [Fact] + public void Selecting_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.Select(4); + + Assert.Equal(1, raised); + } + + [Fact] + public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.SelectionChanged += (s, e) => ++raised; + target.Select(4); + + Assert.Equal(0, raised); + } + + [Fact] + public void SingleSelecting_Item_Raises_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(3); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(3) }, e.DeselectedIndices); + Assert.Equal(new object[] { 3 }, e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.Select(4); + + Assert.Equal(1, raised); + } + + [Fact] + public void SingleSelecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.SelectionChanged += (s, e) => ++raised; + target.Select(4); + + Assert.Equal(0, raised); + } + + [Fact] + public void Selecting_Item_With_Group_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 3); + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.Select(1, 1); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectAt_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 3); + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1, 1) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + target.SelectAt(new IndexPath(1, 1)); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectAll_Raises_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(0, 10); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.SelectAll(); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectAll_With_Already_Selected_Items_Raises_SelectionChanged() + { + var target = new SelectionModel { SingleSelect = true }; + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(0, 10).Except(new[] { 4 }); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.SelectAll(); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRangeFromAnchor_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(4, 3); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.AnchorIndex = new IndexPath(4); + target.SelectRangeFromAnchor(6); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRangeFromAnchor_With_Group_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 10); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(11, 6); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.AnchorIndex = new IndexPath(1, 1); + target.SelectRangeFromAnchor(1, 6); + + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRangeFromAnchorTo_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 10); + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(11, 6); + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(expected.Select(x => new IndexPath(x / 10, x % 10)), e.SelectedIndices); + Assert.Equal(expected, e.SelectedItems.Cast()); + ++raised; + }; + + target.AnchorIndex = new IndexPath(1, 1); + target.SelectRangeFromAnchorTo(new IndexPath(1, 6)); + + Assert.Equal(1, raised); + } + + [Fact] + public void ClearSelection_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(4, 2); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices); + Assert.Equal(expected, e.DeselectedItems.Cast()); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.ClearSelection(); + + Assert.Equal(1, raised); + } + + [Fact] + public void Changing_Source_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + var expected = Enumerable.Range(4, 2); + Assert.Equal(expected.Select(x => new IndexPath(x)), e.DeselectedIndices); + Assert.Equal(expected, e.DeselectedItems.Cast()); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = Enumerable.Range(20, 10).ToList(); + + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(4), new IndexPath(5) }, e.DeselectedIndices); + Assert.Equal(new object[] { 4, 5 }, e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(6) }, e.SelectedIndices); + Assert.Equal(new object[] { 6 }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = new IndexPath(6); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Selected_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var raised = 0; + + target.Source = data; + target.Select(4); + target.Select(5); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new object[] { 4 }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Remove(4); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Selected_Child_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = CreateNestedData(1, 2, 3); + var raised = 0; + + target.Source = data; + target.SelectRange(new IndexPath(0), new IndexPath(1, 1)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new object[] { 1}, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + ((AvaloniaList)data[0]).RemoveAt(1); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Selected_Item_With_Children_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = CreateNestedData(1, 2, 3); + var raised = 0; + + target.Source = data; + target.SelectRange(new IndexPath(0), new IndexPath(1, 1)); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new object[] { 0, 1, 2 }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.RemoveAt(0); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Unselected_Item_Before_Selected_Item_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var raised = 0; + + target.Source = data; + target.Select(8); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + data.Remove(6); + + Assert.Equal(1, raised); + } + + [Fact] + public void Removing_Unselected_Item_After_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = new SelectionModel(); + var data = new ObservableCollection(Enumerable.Range(0, 10)); + var raised = 0; + + target.Source = data; + target.Select(4); + + target.SelectionChanged += (s, e) => ++raised; + + data.Remove(6); + + Assert.Equal(0, raised); + } + [Fact] public void Disposing_Unhooks_CollectionChanged_Handlers() { From e2132fedf93ef1bdf446f766853b130aaeb9d21c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 29 Jan 2020 21:48:14 +0100 Subject: [PATCH 19/52] Added some failing tests. That demonstrate some problems with the `SelectionModel` change notifications found so far. --- .../SelectionModelTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 1cca809c1d..de9fa4f11f 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1392,6 +1392,38 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(3, target.SelectedItems.Count); } + [Fact] + public void Not_Enumerating_Changes_Does_Not_Prevent_Further_Operations() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data }; + + target.SelectionChanged += (s, e) => { }; + + target.SelectAll(); + target.ClearSelection(); + } + + [Fact] + public void Can_Change_Selection_From_SelectionChanged() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data }; + var raised = 0; + + target.SelectionChanged += (s, e) => + { + if (raised++ == 0) + { + target.ClearSelection(); + } + }; + + target.SelectAll(); + + Assert.Equal(2, raised); + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; From 859aba1043855105e66ff9f89f6960513744e6a0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 29 Jan 2020 21:48:43 +0100 Subject: [PATCH 20/52] Refactor of SelectionModel change notifications. To address issues found. --- src/Avalonia.Controls/SelectionModel.cs | 53 ++----- .../SelectionModelChangeSet.cs | 140 +++++------------- ...SelectionModelSelectionChangedEventArgs.cs | 80 ++++++---- src/Avalonia.Controls/SelectionNode.cs | 60 +++++--- .../SelectionNodeOperation.cs | 80 ++++++++++ 5 files changed, 215 insertions(+), 198 deletions(-) create mode 100644 src/Avalonia.Controls/SelectionNodeOperation.cs diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 5e2fb32243..49f2c8d2f6 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -490,13 +490,9 @@ namespace Avalonia.Controls } public void OnSelectionInvalidatedDueToCollectionChange( - IEnumerable? removedItems) + IReadOnlyList? removedItems) { - var e = new SelectionModelSelectionChangedEventArgs( - Enumerable.Empty(), - Enumerable.Empty(), - removedItems ?? Enumerable.Empty(), - Enumerable.Empty()); + var e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null); OnSelectionChanged(e); } @@ -706,50 +702,19 @@ namespace Avalonia.Controls }); } - private void BeginOperation() - { - if (SelectionChanged != null) - { - _rootNode.BeginOperation(); - } - } + private void BeginOperation() => _rootNode.BeginOperation(); private void EndOperation() { - static IEnumerable? Concat(IEnumerable? a, IEnumerable b) - { - return a == null ? b : a.Concat(b); - } + var changes = new List(); + _rootNode.EndOperation(changes); SelectionModelSelectionChangedEventArgs? e = null; - - if (SelectionChanged != null) + + if (changes.Count > 0) { - IEnumerable? selectedIndices = null; - IEnumerable? deselectedIndices = null; - IEnumerable? selectedItems = null; - IEnumerable? deselectedItems = null; - - foreach (var changes in _rootNode.EndOperation()) - { - if (changes.HasChanges) - { - selectedIndices = Concat(selectedIndices, changes.SelectedIndices); - deselectedIndices = Concat(deselectedIndices, changes.DeselectedIndices); - selectedItems = Concat(selectedItems, changes.SelectedItems); - deselectedItems = Concat(deselectedItems, changes.DeselectedItems); - } - } - - if (selectedIndices != null || deselectedIndices != null || - selectedItems != null || deselectedItems != null) - { - e = new SelectionModelSelectionChangedEventArgs( - deselectedIndices ?? Enumerable.Empty(), - selectedIndices ?? Enumerable.Empty(), - deselectedItems ?? Enumerable.Empty(), - selectedItems ?? Enumerable.Empty()); - } + var changeSet = new SelectionModelChangeSet(changes); + e = changeSet.CreateEventArgs(); } OnSelectionChanged(e); diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index 989136ac8d..b195117ae6 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -1,144 +1,80 @@ using System; using System.Collections.Generic; -using System.Linq; - -#nullable enable namespace Avalonia.Controls { internal class SelectionModelChangeSet { - private SelectionNode _owner; - private List? _selected; - private List? _deselected; - - public SelectionModelChangeSet(SelectionNode owner) => _owner = owner; - - public bool IsTracking { get; private set; } - public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0; - public IEnumerable SelectedIndices => EnumerateIndices(_selected); - public IEnumerable DeselectedIndices => EnumerateIndices(_deselected); - public IEnumerable SelectedItems => EnumerateItems(_selected); - public IEnumerable DeselectedItems => EnumerateItems(_deselected); + private List _changes; - public void BeginOperation() + public SelectionModelChangeSet(List changes) { - if (IsTracking) - { - throw new AvaloniaInternalException("SelectionModel change operation already in progress."); - } - - IsTracking = true; - _selected?.Clear(); - _deselected?.Clear(); + _changes = changes; } - public void EndOperation() => IsTracking = false; - - public void Selected(IndexRange range) + public SelectionModelSelectionChangedEventArgs CreateEventArgs() { - if (!IsTracking) - { - return; - } - - Add(range, ref _selected, _deselected); + return new SelectionModelSelectionChangedEventArgs( + CreateIndices(x => x.DeselectedRanges), + CreateIndices(x => x.SelectedRanges), + CreateItems(x => x.DeselectedRanges), + CreateItems(x => x.SelectedRanges)); } - public void Selected(IEnumerable ranges) + private IReadOnlyList CreateIndices(Func?> selector) { - if (!IsTracking) + if (_changes == null) { - return; + return Array.Empty(); } - foreach (var range in ranges) - { - Selected(range); - } - } - - public void Deselected(IndexRange range) - { - if (!IsTracking) - { - return; - } - - Add(range, ref _deselected, _selected); - } + var result = new List(); - public void Deselected(IEnumerable ranges) - { - if (!IsTracking) + foreach (var i in _changes) { - return; - } + var ranges = selector(i); - foreach (var range in ranges) - { - Deselected(range); - } - } - - private static void Add( - IndexRange range, - ref List? add, - List? remove) - { - if (remove != null) - { - var removed = new List(); - IndexRange.Remove(remove, range, removed); - var selected = IndexRange.Subtract(range, removed); - - if (selected.Any()) + if (ranges != null) { - add ??= new List(); - - foreach (var r in selected) + foreach (var j in ranges) { - IndexRange.Add(add, r); + for (var k = j.Begin; k <= j.End; ++k) + { + result.Add(i.Path.CloneWithChildIndex(k)); + } } } } - else - { - add ??= new List(); - IndexRange.Add(add, range); - } + + return result; } - private IEnumerable EnumerateIndices(IEnumerable? ranges) + private IReadOnlyList CreateItems(Func?> selector) { - var path = _owner.IndexPath; - - if (ranges != null) + if (_changes == null) { - foreach (var range in ranges) - { - for (var i = range.Begin; i <= range.End; ++i) - { - yield return path.CloneWithChildIndex(i); - } - } + return Array.Empty(); } - } - private IEnumerable EnumerateItems(IEnumerable? ranges) - { - var items = _owner.ItemsSourceView; + var result = new List(); - if (ranges != null && items != null) + foreach (var i in _changes) { - foreach (var range in ranges) + var ranges = selector(i); + + if (ranges != null && i.Items != null) { - for (var i = range.Begin; i <= range.End; ++i) + foreach (var j in ranges) { - yield return items.GetAt(i); + for (var k = j.Begin; k <= j.End; ++k) + { + result.Add(i.Items.GetAt(k)); + } } } } + + return result; } } } diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index 4976bf1827..ae98f6a1ce 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -12,49 +12,73 @@ namespace Avalonia.Controls { public class SelectionModelSelectionChangedEventArgs : EventArgs { - private readonly IEnumerable _selectedIndicesSource; - private readonly IEnumerable _deselectedIndicesSource; - private readonly IEnumerable _selectedItemsSource; - private readonly IEnumerable _deselectedItemsSource; - private List? _selectedIndices; - private List? _deselectedIndices; - private List? _selectedItems; - private List? _deselectedItems; + private readonly IEnumerable? _deselectedIndicesSource; + private readonly IEnumerable? _selectedIndicesSource; + private readonly IEnumerable? _deselectedItemsSource; + private readonly IEnumerable? _selectedItemsSource; + private IReadOnlyList? _deselectedIndices; + private IReadOnlyList? _selectedIndices; + private IReadOnlyList? _deselectedItems; + private IReadOnlyList? _selectedItems; public SelectionModelSelectionChangedEventArgs( - IEnumerable deselectedIndices, - IEnumerable selectedIndices, - IEnumerable deselectedItems, - IEnumerable selectedItems) + IReadOnlyList? deselectedIndices, + IReadOnlyList? selectedIndices, + IReadOnlyList? deselectedItems, + IReadOnlyList? selectedItems) { - _selectedIndicesSource = selectedIndices; - _deselectedIndicesSource = deselectedIndices; - _selectedItemsSource = selectedItems; - _deselectedItemsSource = deselectedItems; + _deselectedIndices = deselectedIndices ?? Array.Empty(); + _selectedIndices = selectedIndices ?? Array.Empty(); + _deselectedItems = deselectedItems ?? Array.Empty(); + _selectedItems= selectedItems ?? Array.Empty(); } - /// - /// Gets the indices of the items that were added to the selection. - /// - public IReadOnlyList SelectedIndices => - _selectedIndices ?? (_selectedIndices = new List(_selectedIndicesSource)); + public SelectionModelSelectionChangedEventArgs( + IEnumerable? deselectedIndices, + IEnumerable? selectedIndices, + IEnumerable? deselectedItems, + IEnumerable? selectedItems) + { + static void Set(IEnumerable? source, ref IEnumerable? sourceField, ref IReadOnlyList? field) + { + if (source != null) + { + sourceField = source; + } + else + { + field = Array.Empty(); + } + } + + Set(deselectedIndices, ref _deselectedIndicesSource, ref _deselectedIndices); + Set(selectedIndices, ref _selectedIndicesSource, ref _selectedIndices); + Set(deselectedItems, ref _deselectedItemsSource, ref _deselectedItems); + Set(selectedItems, ref _selectedItemsSource, ref _selectedItems); + } /// /// Gets the indices of the items that were removed from the selection. /// - public IReadOnlyList DeselectedIndices => - _deselectedIndices ?? (_deselectedIndices = new List(_deselectedIndicesSource)); + public IReadOnlyList DeselectedIndices + => _deselectedIndices ??= new List(_deselectedIndicesSource); /// - /// Gets the items that were added to the selection. + /// Gets the indices of the items that were added to the selection. /// - public IReadOnlyList SelectedItems => - _selectedItems ?? (_selectedItems = new List(_selectedItemsSource)); + public IReadOnlyList SelectedIndices + => _selectedIndices ??= new List(_selectedIndicesSource); /// /// Gets the items that were removed from the selection. /// - public IReadOnlyList DeselectedItems => - _deselectedItems ?? (_deselectedItems = new List(_deselectedItemsSource)); + public IReadOnlyList DeselectedItems + => _deselectedItems ??= new List(_deselectedItemsSource); + + /// + /// Gets the items that were added to the selection. + /// + public IReadOnlyList SelectedItems + => _selectedItems ??= new List(_selectedItemsSource); } } diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index d462a51228..85db801100 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -29,7 +29,7 @@ namespace Avalonia.Controls private readonly SelectionNode? _parent; private readonly List _selected = new List(); private readonly List _selectedIndicesCached = new List(); - private SelectionModelChangeSet? _changes; + private SelectionNodeOperation? _operation; private object? _source; private bool _selectedIndicesCacheIsValid; @@ -137,7 +137,7 @@ namespace Avalonia.Controls child = new SelectionNode(_manager, parent: this); child.Source = resolvedChild; - if (_changes?.IsTracking == true) + if (_operation != null) { child.BeginOperation(); } @@ -296,33 +296,45 @@ namespace Avalonia.Controls public void BeginOperation() { - _changes ??= new SelectionModelChangeSet(this); - _changes.BeginOperation(); + if (_operation != null) + { + throw new AvaloniaInternalException("Selection operation already in progress."); + } + + _operation = new SelectionNodeOperation(this); for (var i = 0; i < _childrenNodes.Count; ++i) { - _childrenNodes[i]?.BeginOperation(); + var child = _childrenNodes[i]; + + if (child != null && child != _manager.SharedLeafNode) + { + child.BeginOperation(); + } } } - public IEnumerable EndOperation() + public void EndOperation(List changes) { - if (_changes != null) + if (_operation == null) { - _changes.EndOperation(); - yield return _changes; + throw new AvaloniaInternalException("No selection operation in progress."); + } - for (var i = 0; i < _childrenNodes.Count; ++i) - { - var child = _childrenNodes[i]; + if (_operation.HasChanges) + { + changes.Add(_operation); + } - if (child != null) - { - foreach (var changes in child.EndOperation()) - { - yield return changes; - } - } + _operation = null; + + for (var i = 0; i < _childrenNodes.Count; ++i) + { + var child = _childrenNodes[i]; + + if (child != null && child != _manager.SharedLeafNode) + { + child.EndOperation(changes); } } } @@ -400,7 +412,7 @@ namespace Avalonia.Controls if (selected.Count > 0) { - _changes?.Selected(selected); + _operation?.Selected(selected); if (raiseOnSelectionChanged) { @@ -417,7 +429,7 @@ namespace Avalonia.Controls if (removed.Count > 0) { - _changes?.Deselected(removed); + _operation?.Deselected(removed); if (raiseOnSelectionChanged) { @@ -431,7 +443,7 @@ namespace Avalonia.Controls // Deselect all items if (_selected.Count > 0) { - _changes?.Deselected(_selected); + _operation?.Deselected(_selected); _selected.Clear(); OnSelectionChanged(); } @@ -480,7 +492,7 @@ namespace Avalonia.Controls private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) { bool selectionInvalidated = false; - IList? removed = null; + List? removed = null; switch (args.Action) { @@ -594,7 +606,7 @@ namespace Avalonia.Controls return selectionInvalidated; } - private (bool, IList) OnItemsRemoved(int index, IList items) + private (bool, List) OnItemsRemoved(int index, IList items) { var selectionInvalidated = false; var removed = new List(); diff --git a/src/Avalonia.Controls/SelectionNodeOperation.cs b/src/Avalonia.Controls/SelectionNodeOperation.cs new file mode 100644 index 0000000000..04b8554f7c --- /dev/null +++ b/src/Avalonia.Controls/SelectionNodeOperation.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls +{ + internal class SelectionNodeOperation + { + private readonly SelectionNode _owner; + private List? _selected; + private List? _deselected; + + public SelectionNodeOperation(SelectionNode owner) + { + _owner = owner; + } + + public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0; + public List? SelectedRanges => _selected; + public List? DeselectedRanges => _deselected; + public IndexPath Path => _owner.IndexPath; + public ItemsSourceView? Items => _owner.ItemsSourceView; + + public void Selected(IndexRange range) + { + Add(range, ref _selected, _deselected); + } + + public void Selected(IEnumerable ranges) + { + foreach (var range in ranges) + { + Selected(range); + } + } + + public void Deselected(IndexRange range) + { + Add(range, ref _deselected, _selected); + } + + public void Deselected(IEnumerable ranges) + { + foreach (var range in ranges) + { + Deselected(range); + } + } + + private static void Add( + IndexRange range, + ref List? add, + List? remove) + { + if (remove != null) + { + var removed = new List(); + IndexRange.Remove(remove, range, removed); + var selected = IndexRange.Subtract(range, removed); + + if (selected.Any()) + { + add ??= new List(); + + foreach (var r in selected) + { + IndexRange.Add(add, r); + } + } + } + else + { + add ??= new List(); + IndexRange.Add(add, range); + } + } + } +} From b120e5282e2c70c4f9100704cd9c883c5388437f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 31 Jan 2020 10:17:56 +0100 Subject: [PATCH 21/52] Lazily create the selected/deselected lists. --- .../SelectionModelChangeSet.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index b195117ae6..e927afb9b9 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -21,15 +21,13 @@ namespace Avalonia.Controls CreateItems(x => x.SelectedRanges)); } - private IReadOnlyList CreateIndices(Func?> selector) + private IEnumerable CreateIndices(Func?> selector) { if (_changes == null) { - return Array.Empty(); + yield break; } - var result = new List(); - foreach (var i in _changes) { var ranges = selector(i); @@ -40,20 +38,18 @@ namespace Avalonia.Controls { for (var k = j.Begin; k <= j.End; ++k) { - result.Add(i.Path.CloneWithChildIndex(k)); + yield return i.Path.CloneWithChildIndex(k); } } } } - - return result; } - private IReadOnlyList CreateItems(Func?> selector) + private IEnumerable CreateItems(Func?> selector) { if (_changes == null) { - return Array.Empty(); + yield break; } var result = new List(); @@ -68,13 +64,11 @@ namespace Avalonia.Controls { for (var k = j.Begin; k <= j.End; ++k) { - result.Add(i.Items.GetAt(k)); + yield return i.Items.GetAt(k); } } } } - - return result; } } } From 9073234f725cdbef28e713353fd52e430030bb92 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 1 Feb 2020 00:48:57 +0100 Subject: [PATCH 22/52] Use SelectedItems for change event args. --- src/Avalonia.Controls/IndexRange.cs | 12 ++ src/Avalonia.Controls/SelectedItems.cs | 37 ++--- src/Avalonia.Controls/SelectionModel.cs | 14 +- .../SelectionModelChangeSet.cs | 149 ++++++++++++++---- ...SelectionModelSelectionChangedEventArgs.cs | 53 +------ .../SelectionNodeOperation.cs | 32 +++- 6 files changed, 193 insertions(+), 104 deletions(-) diff --git a/src/Avalonia.Controls/IndexRange.cs b/src/Avalonia.Controls/IndexRange.cs index 124f1e0500..1dc161c699 100644 --- a/src/Avalonia.Controls/IndexRange.cs +++ b/src/Avalonia.Controls/IndexRange.cs @@ -202,6 +202,18 @@ namespace Avalonia.Controls } } + public static int GetCount(IEnumerable ranges) + { + var result = 0; + + foreach (var range in ranges) + { + result += (range.End - range.Begin) + 1; + } + + return result; + } + private static void MergeRanges(IList ranges) { for (var i = ranges.Count - 2; i >= 0; --i) diff --git a/src/Avalonia.Controls/SelectedItems.cs b/src/Avalonia.Controls/SelectedItems.cs index af43742670..a3acb48765 100644 --- a/src/Avalonia.Controls/SelectedItems.cs +++ b/src/Avalonia.Controls/SelectedItems.cs @@ -6,44 +6,37 @@ using System; using System.Collections; using System.Collections.Generic; -using SelectedItemInfo = Avalonia.Controls.SelectionModel.SelectedItemInfo; #nullable enable namespace Avalonia.Controls { - internal class SelectedItems : IReadOnlyList + public interface ISelectedItemInfo { - private readonly List _infos; - private readonly Func, int, T> _getAtImpl; + public IndexPath Path { get; } + } + + internal class SelectedItems : IReadOnlyList + where Tinfo : ISelectedItemInfo + { + private readonly List _infos; + private readonly Func, int, TValue> _getAtImpl; public SelectedItems( - List infos, - Func, int, T> getAtImpl) + List infos, + int count, + Func, int, TValue> getAtImpl) { _infos = infos; _getAtImpl = getAtImpl; - - foreach (var info in infos) - { - var node = info.Node; - - if (node != null) - { - Count += node.SelectedCount; - } - else - { - throw new InvalidOperationException("Selection changed after the SelectedIndices/Items property was read."); - } - } + Count = count; } - public T this[int index] => _getAtImpl(_infos, index); + public TValue this[int index] => _getAtImpl(_infos, index); public int Count { get; } - public IEnumerator GetEnumerator() + public IEnumerator GetEnumerator() { for (var i = 0; i < Count; ++i) { diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 49f2c8d2f6..c8d2c5cc9e 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -165,6 +165,7 @@ namespace Avalonia.Controls if (_selectedItemsCached == null) { var selectedInfos = new List(); + var count = 0; if (_rootNode.Source != null) { @@ -176,6 +177,7 @@ namespace Avalonia.Controls if (currentInfo.Node.SelectedCount > 0) { selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); + count += currentInfo.Node.SelectedCount; } }); } @@ -185,8 +187,9 @@ namespace Avalonia.Controls // 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 ( + var selectedItems = new SelectedItems ( selectedInfos, + count, (infos, index) => { var currentIndex = 0; @@ -233,6 +236,8 @@ namespace Avalonia.Controls if (_selectedIndicesCached == null) { var selectedInfos = new List(); + var count = 0; + SelectionTreeHelper.Traverse( _rootNode, false, @@ -241,6 +246,7 @@ namespace Avalonia.Controls if (currentInfo.Node.SelectedCount > 0) { selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); + count += currentInfo.Node.SelectedCount; } }); @@ -249,8 +255,9 @@ namespace Avalonia.Controls // 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( + var indices = new SelectedItems( selectedInfos, + count, (infos, index) => // callback for GetAt(index) { var currentIndex = 0; @@ -720,7 +727,7 @@ namespace Avalonia.Controls OnSelectionChanged(e); } - internal class SelectedItemInfo + internal class SelectedItemInfo : ISelectedItemInfo { public SelectedItemInfo(SelectionNode node, IndexPath path) { @@ -730,6 +737,7 @@ namespace Avalonia.Controls public SelectionNode Node { get; } public IndexPath Path { get; } + public int Count => Node.SelectedCount; } private struct Operation : IDisposable diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index e927afb9b9..c0228a1cbc 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -14,61 +14,144 @@ namespace Avalonia.Controls public SelectionModelSelectionChangedEventArgs CreateEventArgs() { + var deselectedCount = 0; + var selectedCount = 0; + + foreach (var change in _changes) + { + deselectedCount += change.DeselectedCount; + selectedCount += change.SelectedCount; + } + + var deselectedIndices = new SelectedItems( + _changes, + deselectedCount, + GetDeselectedIndexAt); + var selectedIndices = new SelectedItems( + _changes, + selectedCount, + GetSelectedIndexAt); + var deselectedItems = new SelectedItems( + _changes, + deselectedCount, + GetDeselectedItemAt); + var selectedItems = new SelectedItems( + _changes, + selectedCount, + GetSelectedItemAt); + return new SelectionModelSelectionChangedEventArgs( - CreateIndices(x => x.DeselectedRanges), - CreateIndices(x => x.SelectedRanges), - CreateItems(x => x.DeselectedRanges), - CreateItems(x => x.SelectedRanges)); + deselectedIndices, + selectedIndices, + deselectedItems, + selectedItems); } - private IEnumerable CreateIndices(Func?> selector) + private IndexPath GetDeselectedIndexAt( + List infos, + int index) { - if (_changes == null) - { - yield break; - } + static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; + static List GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; + return GetIndexAt(infos, index, GetCount, GetRanges); + } - foreach (var i in _changes) + private IndexPath GetSelectedIndexAt( + List infos, + int index) + { + static int GetCount(SelectionNodeOperation info) => info.SelectedCount; + static List GetRanges(SelectionNodeOperation info) => info.SelectedRanges; + return GetIndexAt(infos, index, GetCount, GetRanges); + } + + private object GetDeselectedItemAt( + List infos, + int index) + { + static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; + static List GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; + return GetItemAt(infos, index, GetCount, GetRanges); + } + + private object GetSelectedItemAt( + List infos, + int index) + { + static int GetCount(SelectionNodeOperation info) => info.SelectedCount; + static List GetRanges(SelectionNodeOperation info) => info.SelectedRanges; + return GetItemAt(infos, index, GetCount, GetRanges); + } + + private IndexPath GetIndexAt( + List infos, + int index, + Func getCount, + Func> getRanges) + { + var currentIndex = 0; + IndexPath path = default; + + foreach (var info in infos) { - var ranges = selector(i); + var currentCount = getCount(info); - if (ranges != null) + if (index >= currentIndex && index < currentIndex + currentCount) { - foreach (var j in ranges) - { - for (var k = j.Begin; k <= j.End; ++k) - { - yield return i.Path.CloneWithChildIndex(k); - } - } + int targetIndex = GetIndexAt(getRanges(info), index - currentIndex); + path = info.Path.CloneWithChildIndex(targetIndex); + break; } + + currentIndex += currentCount; } + + return path; } - private IEnumerable CreateItems(Func?> selector) + private object GetItemAt( + List infos, + int index, + Func getCount, + Func> getRanges) { - if (_changes == null) + var currentIndex = 0; + object item = null; + + foreach (var info in infos) { - yield break; + var currentCount = getCount(info); + + if (index >= currentIndex && index < currentIndex + currentCount) + { + int targetIndex = GetIndexAt(getRanges(info), index - currentIndex); + item = info.Items.GetAt(targetIndex); + break; + } + + currentIndex += currentCount; } - var result = new List(); + return item; + } - foreach (var i in _changes) + private int GetIndexAt(List ranges, int index) + { + var currentIndex = 0; + + foreach (var range in ranges) { - var ranges = selector(i); + var currentCount = (range.End - range.Begin) + 1; - if (ranges != null && i.Items != null) + if (index >= currentIndex && index < currentIndex + currentCount) { - foreach (var j in ranges) - { - for (var k = j.Begin; k <= j.End; ++k) - { - yield return i.Items.GetAt(k); - } - } + return range.Begin + (index - currentIndex); } + + currentIndex += currentCount; } + + throw new IndexOutOfRangeException(); } } } diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index ae98f6a1ce..4e64ee6e6f 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -12,73 +12,36 @@ namespace Avalonia.Controls { public class SelectionModelSelectionChangedEventArgs : EventArgs { - private readonly IEnumerable? _deselectedIndicesSource; - private readonly IEnumerable? _selectedIndicesSource; - private readonly IEnumerable? _deselectedItemsSource; - private readonly IEnumerable? _selectedItemsSource; - private IReadOnlyList? _deselectedIndices; - private IReadOnlyList? _selectedIndices; - private IReadOnlyList? _deselectedItems; - private IReadOnlyList? _selectedItems; - public SelectionModelSelectionChangedEventArgs( IReadOnlyList? deselectedIndices, IReadOnlyList? selectedIndices, IReadOnlyList? deselectedItems, IReadOnlyList? selectedItems) { - _deselectedIndices = deselectedIndices ?? Array.Empty(); - _selectedIndices = selectedIndices ?? Array.Empty(); - _deselectedItems = deselectedItems ?? Array.Empty(); - _selectedItems= selectedItems ?? Array.Empty(); - } - - public SelectionModelSelectionChangedEventArgs( - IEnumerable? deselectedIndices, - IEnumerable? selectedIndices, - IEnumerable? deselectedItems, - IEnumerable? selectedItems) - { - static void Set(IEnumerable? source, ref IEnumerable? sourceField, ref IReadOnlyList? field) - { - if (source != null) - { - sourceField = source; - } - else - { - field = Array.Empty(); - } - } - - Set(deselectedIndices, ref _deselectedIndicesSource, ref _deselectedIndices); - Set(selectedIndices, ref _selectedIndicesSource, ref _selectedIndices); - Set(deselectedItems, ref _deselectedItemsSource, ref _deselectedItems); - Set(selectedItems, ref _selectedItemsSource, ref _selectedItems); + DeselectedIndices = deselectedIndices ?? Array.Empty(); + SelectedIndices = selectedIndices ?? Array.Empty(); + DeselectedItems = deselectedItems ?? Array.Empty(); + SelectedItems= selectedItems ?? Array.Empty(); } /// /// Gets the indices of the items that were removed from the selection. /// - public IReadOnlyList DeselectedIndices - => _deselectedIndices ??= new List(_deselectedIndicesSource); + public IReadOnlyList DeselectedIndices { get; } /// /// Gets the indices of the items that were added to the selection. /// - public IReadOnlyList SelectedIndices - => _selectedIndices ??= new List(_selectedIndicesSource); + public IReadOnlyList SelectedIndices { get; } /// /// Gets the items that were removed from the selection. /// - public IReadOnlyList DeselectedItems - => _deselectedItems ??= new List(_deselectedItemsSource); + public IReadOnlyList DeselectedItems { get; } /// /// Gets the items that were added to the selection. /// - public IReadOnlyList SelectedItems - => _selectedItems ??= new List(_selectedItemsSource); + public IReadOnlyList SelectedItems { get; } } } diff --git a/src/Avalonia.Controls/SelectionNodeOperation.cs b/src/Avalonia.Controls/SelectionNodeOperation.cs index 04b8554f7c..9622a52f00 100644 --- a/src/Avalonia.Controls/SelectionNodeOperation.cs +++ b/src/Avalonia.Controls/SelectionNodeOperation.cs @@ -6,11 +6,13 @@ using System.Linq; namespace Avalonia.Controls { - internal class SelectionNodeOperation + internal class SelectionNodeOperation : ISelectedItemInfo { private readonly SelectionNode _owner; private List? _selected; private List? _deselected; + private int _selectedCount = -1; + private int _deselectedCount = -1; public SelectionNodeOperation(SelectionNode owner) { @@ -23,9 +25,36 @@ namespace Avalonia.Controls public IndexPath Path => _owner.IndexPath; public ItemsSourceView? Items => _owner.ItemsSourceView; + public int SelectedCount + { + get + { + if (_selectedCount == -1) + { + _selectedCount = (_selected != null) ? IndexRange.GetCount(_selected) : 0; + } + + return _selectedCount; + } + } + + public int DeselectedCount + { + get + { + if (_deselectedCount == -1) + { + _deselectedCount = (_deselected != null) ? IndexRange.GetCount(_deselected) : 0; + } + + return _deselectedCount; + } + } + public void Selected(IndexRange range) { Add(range, ref _selected, _deselected); + _selectedCount = -1; } public void Selected(IEnumerable ranges) @@ -39,6 +68,7 @@ namespace Avalonia.Controls public void Deselected(IndexRange range) { Add(range, ref _deselected, _selected); + _deselectedCount = -1; } public void Deselected(IEnumerable ranges) From 01a194520122b0f8bf688f21925f31f953ff3b8a Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 3 Feb 2020 14:11:39 +0100 Subject: [PATCH 23/52] Add nullable annotations. --- .../SelectionModelChangeSet.cs | 47 ++++++++++--------- ...SelectionModelSelectionChangedEventArgs.cs | 12 ++--- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index c0228a1cbc..57bf369585 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +#nullable enable + namespace Avalonia.Controls { internal class SelectionModelChangeSet @@ -31,11 +33,11 @@ namespace Avalonia.Controls _changes, selectedCount, GetSelectedIndexAt); - var deselectedItems = new SelectedItems( + var deselectedItems = new SelectedItems( _changes, deselectedCount, GetDeselectedItemAt); - var selectedItems = new SelectedItems( + var selectedItems = new SelectedItems( _changes, selectedCount, GetSelectedItemAt); @@ -52,7 +54,7 @@ namespace Avalonia.Controls int index) { static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; - static List GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; + static List? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; return GetIndexAt(infos, index, GetCount, GetRanges); } @@ -61,25 +63,25 @@ namespace Avalonia.Controls int index) { static int GetCount(SelectionNodeOperation info) => info.SelectedCount; - static List GetRanges(SelectionNodeOperation info) => info.SelectedRanges; + static List? GetRanges(SelectionNodeOperation info) => info.SelectedRanges; return GetIndexAt(infos, index, GetCount, GetRanges); } - private object GetDeselectedItemAt( + private object? GetDeselectedItemAt( List infos, int index) { static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; - static List GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; + static List? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; return GetItemAt(infos, index, GetCount, GetRanges); } - private object GetSelectedItemAt( + private object? GetSelectedItemAt( List infos, int index) { static int GetCount(SelectionNodeOperation info) => info.SelectedCount; - static List GetRanges(SelectionNodeOperation info) => info.SelectedRanges; + static List? GetRanges(SelectionNodeOperation info) => info.SelectedRanges; return GetItemAt(infos, index, GetCount, GetRanges); } @@ -87,7 +89,7 @@ namespace Avalonia.Controls List infos, int index, Func getCount, - Func> getRanges) + Func?> getRanges) { var currentIndex = 0; IndexPath path = default; @@ -109,14 +111,14 @@ namespace Avalonia.Controls return path; } - private object GetItemAt( + private object? GetItemAt( List infos, int index, Func getCount, - Func> getRanges) + Func?> getRanges) { var currentIndex = 0; - object item = null; + object? item = null; foreach (var info in infos) { @@ -125,7 +127,7 @@ namespace Avalonia.Controls if (index >= currentIndex && index < currentIndex + currentCount) { int targetIndex = GetIndexAt(getRanges(info), index - currentIndex); - item = info.Items.GetAt(targetIndex); + item = info.Items?.GetAt(targetIndex); break; } @@ -135,20 +137,23 @@ namespace Avalonia.Controls return item; } - private int GetIndexAt(List ranges, int index) + private int GetIndexAt(List? ranges, int index) { var currentIndex = 0; - foreach (var range in ranges) + if (ranges != null) { - var currentCount = (range.End - range.Begin) + 1; - - if (index >= currentIndex && index < currentIndex + currentCount) + foreach (var range in ranges) { - return range.Begin + (index - currentIndex); - } + var currentCount = (range.End - range.Begin) + 1; - currentIndex += currentCount; + if (index >= currentIndex && index < currentIndex + currentCount) + { + return range.Begin + (index - currentIndex); + } + + currentIndex += currentCount; + } } throw new IndexOutOfRangeException(); diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs index 4e64ee6e6f..5e2efdf331 100644 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -15,13 +15,13 @@ namespace Avalonia.Controls public SelectionModelSelectionChangedEventArgs( IReadOnlyList? deselectedIndices, IReadOnlyList? selectedIndices, - IReadOnlyList? deselectedItems, - IReadOnlyList? selectedItems) + IReadOnlyList? deselectedItems, + IReadOnlyList? selectedItems) { DeselectedIndices = deselectedIndices ?? Array.Empty(); SelectedIndices = selectedIndices ?? Array.Empty(); - DeselectedItems = deselectedItems ?? Array.Empty(); - SelectedItems= selectedItems ?? Array.Empty(); + DeselectedItems = deselectedItems ?? Array.Empty(); + SelectedItems= selectedItems ?? Array.Empty(); } /// @@ -37,11 +37,11 @@ namespace Avalonia.Controls /// /// Gets the items that were removed from the selection. /// - public IReadOnlyList DeselectedItems { get; } + public IReadOnlyList DeselectedItems { get; } /// /// Gets the items that were added to the selection. /// - public IReadOnlyList SelectedItems { get; } + public IReadOnlyList SelectedItems { get; } } } From 04f8516c32e4e2c70b8a0f4af5f0543d58bb56a6 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 3 Feb 2020 14:36:24 +0100 Subject: [PATCH 24/52] Handle null SelectionModel.Source. --- src/Avalonia.Controls/SelectionModel.cs | 20 ++++++++- .../SelectionModelChangeSet.cs | 28 ++++++++----- src/Avalonia.Controls/SelectionNode.cs | 7 +++- .../SelectionModelTests.cs | 42 +++++++++++++++++++ 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index c8d2c5cc9e..796d108f60 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -36,13 +36,29 @@ namespace Avalonia.Controls get => _rootNode?.Source; set { - using (var operation = new Operation(this)) + var wasNull = _rootNode.Source == null; + + if (_rootNode.Source != null) { - ClearSelection(resetAnchor: true); + using (var operation = new Operation(this)) + { + ClearSelection(resetAnchor: true); + } } _rootNode.Source = value; + RaisePropertyChanged("Source"); + + if (wasNull) + { + var e = new SelectionModelSelectionChangedEventArgs( + null, + SelectedIndices, + null, + SelectedItems); + OnSelectionChanged(e); + } } } diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index 57bf369585..bff84eca92 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -16,30 +16,38 @@ namespace Avalonia.Controls public SelectionModelSelectionChangedEventArgs CreateEventArgs() { - var deselectedCount = 0; - var selectedCount = 0; + var deselectedIndexCount = 0; + var selectedIndexCount = 0; + var deselectedItemCount = 0; + var selectedItemCount = 0; foreach (var change in _changes) { - deselectedCount += change.DeselectedCount; - selectedCount += change.SelectedCount; + deselectedIndexCount += change.DeselectedCount; + selectedIndexCount += change.SelectedCount; + + if (change.Items != null) + { + deselectedItemCount += change.DeselectedCount; + selectedItemCount += change.SelectedCount; + } } var deselectedIndices = new SelectedItems( _changes, - deselectedCount, + deselectedIndexCount, GetDeselectedIndexAt); var selectedIndices = new SelectedItems( _changes, - selectedCount, + selectedIndexCount, GetSelectedIndexAt); var deselectedItems = new SelectedItems( _changes, - deselectedCount, + deselectedItemCount, GetDeselectedItemAt); var selectedItems = new SelectedItems( _changes, - selectedCount, + selectedItemCount, GetSelectedItemAt); return new SelectionModelSelectionChangedEventArgs( @@ -71,7 +79,7 @@ namespace Avalonia.Controls List infos, int index) { - static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; + static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.DeselectedCount : 0; static List? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; return GetItemAt(infos, index, GetCount, GetRanges); } @@ -80,7 +88,7 @@ namespace Avalonia.Controls List infos, int index) { - static int GetCount(SelectionNodeOperation info) => info.SelectedCount; + static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.SelectedCount : 0; static List? GetRanges(SelectionNodeOperation info) => info.SelectedRanges; return GetItemAt(infos, index, GetCount, GetRanges); } diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 85db801100..04144e1ed0 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -48,8 +48,11 @@ namespace Avalonia.Controls { if (_source != value) { - ClearSelection(); - UnhookCollectionChangedHandler(); + if (_source != null) + { + ClearSelection(); + UnhookCollectionChangedHandler(); + } _source = value; diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index de9fa4f11f..1950da2818 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1424,6 +1424,48 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(2, raised); } + [Fact] + public void Raises_SelectionChanged_With_No_Source() + { + var target = new SelectionModel(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Select(1); + + Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); + Assert.Empty(target.SelectedItems); + } + + [Fact] + public void Raises_SelectionChanged_With_Items_After_Source_Is_Set() + { + var target = new SelectionModel(); + var raised = 0; + + target.Select(1); + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, raised); + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; From 4de9fac5c107f75f6a8dc51ff0325a0fa18b6ce1 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 4 Feb 2020 10:23:36 +0100 Subject: [PATCH 25/52] Don't reset selection if source hasn't changed. Also remove some `?.` operators that aren't needed. --- src/Avalonia.Controls/SelectionModel.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 34d5f78434..a501946365 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -33,13 +33,16 @@ namespace Avalonia.Controls public object? Source { - get => _rootNode?.Source; + get => _rootNode.Source; set { - ClearSelection(resetAnchor: true, raiseSelectionChanged: false); - _rootNode.Source = value; - OnSelectionChanged(); - RaisePropertyChanged("Source"); + if (_rootNode.Source != value) + { + ClearSelection(resetAnchor: true, raiseSelectionChanged: false); + _rootNode.Source = value; + OnSelectionChanged(); + RaisePropertyChanged("Source"); + } } } @@ -76,7 +79,7 @@ namespace Avalonia.Controls { IndexPath anchor = default; - if (_rootNode?.AnchorIndex >= 0) + if (_rootNode.AnchorIndex >= 0) { var path = new List(); SelectionNode? current = _rootNode; @@ -290,7 +293,7 @@ namespace Avalonia.Controls public void Dispose() { ClearSelection(resetAnchor: false, raiseSelectionChanged: false); - _rootNode?.Dispose(); + _rootNode.Dispose(); _selectedIndicesCached = null; _selectedItemsCached = null; } From c0f34694a62e5d957156d8cd3d2f8d1c21152dbd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 4 Feb 2020 22:55:49 +0100 Subject: [PATCH 26/52] Fixed out of bounds in SelectionNode. --- src/Avalonia.Controls/SelectionNode.cs | 11 ++++- .../SelectionModelTests.cs | 41 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index ea1a1ca664..e108d0450c 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -692,8 +692,17 @@ namespace Avalonia.Controls if (isSelected) { - RemoveRange(new IndexRange(index, index + count - 1), raiseOnSelectionChanged: false); + var removeRange = new IndexRange(index, index + count - 1); + SelectedCount -= IndexRange.Remove(_selected, removeRange); selectionInvalidated = true; + + if (_selectedItems != null) + { + foreach (var i in items) + { + _selectedItems.Remove(i); + } + } } for (int i = 0; i < _selected.Count; i++) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 9ce0816f7d..81b6127eb6 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1481,7 +1481,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove() + public void RetainSelectionOnReset_Retains_Correct_Selection_After_Deselect() { var data = new ResettingList { "foo", "bar", "baz" }; var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; @@ -1491,7 +1491,35 @@ namespace Avalonia.Controls.UnitTests data.Reset(); Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - Assert.Equal(new[] { "bar", }, target.SelectedItems); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove_1() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + data.RemoveAt(2); + data.Reset(new[] { "foo", "bar", "baz" }); + + Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + } + + [Fact] + public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove_2() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; + + target.SelectRange(new IndexPath(1), new IndexPath(2)); + data.RemoveAt(0); + data.Reset(new[] { "foo", "bar", "baz" }); + + Assert.Equal(new[] { new IndexPath(1), new IndexPath(2) }, target.SelectedIndices); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); } [Fact] @@ -1925,6 +1953,15 @@ namespace Avalonia.Controls.UnitTests { public event NotifyCollectionChangedEventHandler CollectionChanged; + public new void RemoveAt(int index) + { + var item = this[index]; + base.RemoveAt(index); + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { item }, index)); + } + public void Reset(IEnumerable items = null) { if (items != null) From 4dd0ec6e4ee1947b2463a0b142c4717c7d887303 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Feb 2020 10:38:25 +0100 Subject: [PATCH 27/52] Fix bad merge. --- src/Avalonia.Controls/SelectionModel.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 5a2b1cfbde..2fc449de87 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -60,10 +60,6 @@ namespace Avalonia.Controls null, SelectedItems); OnSelectionChanged(e); - ClearSelection(resetAnchor: true); - _rootNode.Source = value; - OnSelectionChanged(); - RaisePropertyChanged("Source"); } } } From ee459635a885f4a0094b357b528a459c9ff17b29 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Feb 2020 10:43:12 +0100 Subject: [PATCH 28/52] Handle RetainSelectionOnReset w/ null source. --- src/Avalonia.Controls/SelectionNode.cs | 24 +++++++--- .../SelectionModelTests.cs | 45 +++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index e108d0450c..fea6707b43 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -55,11 +55,7 @@ namespace Avalonia.Controls if (_retainSelectionOnReset) { _selectedItems = new List(); - - foreach (var i in SelectedIndices) - { - _selectedItems.Add(ItemsSourceView!.GetAt(i)); - } + PopulateSelectedItemsFromSelectedIndices(); } else { @@ -94,7 +90,7 @@ namespace Avalonia.Controls // Setup ItemsSourceView var newDataSource = value as ItemsSourceView; - + if (value != null && newDataSource == null) { newDataSource = new ItemsSourceView((IEnumerable)value); @@ -102,6 +98,7 @@ namespace Avalonia.Controls ItemsSourceView = newDataSource; + PopulateSelectedItemsFromSelectedIndices(); HookupCollectionChangedHandler(); OnSelectionChanged(); } @@ -453,7 +450,7 @@ namespace Avalonia.Controls { _operation?.Selected(selected); - if (_selectedItems != null) + if (_selectedItems != null && ItemsSourceView != null) { for (var i = addRange.Begin; i <= addRange.End; ++i) { @@ -877,6 +874,19 @@ namespace Avalonia.Controls return selectionState; } + private void PopulateSelectedItemsFromSelectedIndices() + { + if (_selectedItems != null) + { + _selectedItems.Clear(); + + foreach (var i in SelectedIndices) + { + _selectedItems.Add(ItemsSourceView!.GetAt(i)); + } + } + } + private List RecreateSelectionFromSelectedItems() { var removed = new List(); diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 81b6127eb6..9574843f99 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1596,6 +1596,51 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, raised); } + [Fact] + public void RetainSelectionOnReset_Handles_Null_Source() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = new SelectionModel { RetainSelectionOnReset = true }; + var raised = 0; + + target.SelectionChanged += (s, e) => + { + if (raised == 0) + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); + Assert.Empty(e.SelectedItems); + } + else if (raised == 1) + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + } + else if (raised == 3) + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + } + + ++raised; + }; + + target.Select(1); + Assert.Equal(1, raised); + + target.Source = data; + Assert.Equal(2, raised); + Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); + + data.Reset(new[] { "qux", "foo", "bar", "baz" }); + Assert.Equal(3, raised); + Assert.Equal(new[] { new IndexPath(2) }, target.SelectedIndices); + } private int GetSubscriberCount(AvaloniaList list) { From 0a608d47dc2bc79b3b9ab70b1a9ad6e1ca509130 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 30 Jan 2020 21:12:07 +0100 Subject: [PATCH 29/52] Added SelectionMode.AutoSelect. --- src/Avalonia.Controls/SelectionModel.cs | 87 ++++++-- src/Avalonia.Controls/SelectionNode.cs | 3 +- .../SelectionModelTests.cs | 203 +++++++++++++++++- 3 files changed, 276 insertions(+), 17 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 2fc449de87..e0ca3e8827 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -17,6 +17,8 @@ namespace Avalonia.Controls { private readonly SelectionNode _rootNode; private bool _singleSelect; + private bool _autoSelect; + private int _operationCount; private IReadOnlyList? _selectedIndicesCached; private IReadOnlyList? _selectedItemsCached; private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs; @@ -38,21 +40,25 @@ namespace Avalonia.Controls { if (_rootNode.Source != value) { - var wasNull = _rootNode.Source == null; + var raiseChanged = _rootNode.Source == null && SelectedIndices.Count > 0; if (_rootNode.Source != null) { - using (var operation = new Operation(this)) + if (_rootNode.Source != null) { - ClearSelection(resetAnchor: true); + using (var operation = new Operation(this)) + { + ClearSelection(resetAnchor: true); + } } } _rootNode.Source = value; + ApplyAutoSelect(); RaisePropertyChanged("Source"); - if (wasNull) + if (raiseChanged) { var e = new SelectionModelSelectionChangedEventArgs( null, @@ -92,12 +98,25 @@ namespace Avalonia.Controls } } - public bool RetainSelectionOnReset + public bool RetainSelectionOnReset { get => _rootNode.RetainSelectionOnReset; set => _rootNode.RetainSelectionOnReset = value; } + public bool AutoSelect + { + get => _autoSelect; + set + { + if (_autoSelect != value) + { + _autoSelect = value; + ApplyAutoSelect(); + } + } + } + public IndexPath AnchorIndex { get @@ -356,18 +375,21 @@ namespace Avalonia.Controls { using var operation = new Operation(this); SelectImpl(index, select: false); + ApplyAutoSelect(); } public void Deselect(int groupIndex, int itemIndex) { using var operation = new Operation(this); SelectWithGroupImpl(groupIndex, itemIndex, select: false); + ApplyAutoSelect(); } public void DeselectAt(IndexPath index) { using var operation = new Operation(this); SelectWithPathImpl(index, select: false); + ApplyAutoSelect(); } public bool? IsSelected(int index) @@ -508,6 +530,7 @@ namespace Avalonia.Controls { using var operation = new Operation(this); ClearSelection(resetAnchor: true); + ApplyAutoSelect(); } protected void OnPropertyChanged(string propertyName) @@ -521,10 +544,18 @@ namespace Avalonia.Controls } public void OnSelectionInvalidatedDueToCollectionChange( - IReadOnlyList? removedItems) + bool selectionInvalidated, + IReadOnlyList? removedItems) { - var e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null); + SelectionModelSelectionChangedEventArgs? e = null; + + if (selectionInvalidated) + { + e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null); + } + OnSelectionChanged(e); + ApplyAutoSelect(); } internal object? ResolvePath(object data, SelectionNode sourceNode) @@ -733,24 +764,52 @@ namespace Avalonia.Controls }); } - private void BeginOperation() => _rootNode.BeginOperation(); + private void BeginOperation() + { + if (_operationCount++ == 0) + { + _rootNode.BeginOperation(); + } + } private void EndOperation() { - var changes = new List(); - _rootNode.EndOperation(changes); + if (_operationCount == 0) + { + throw new AvaloniaInternalException("No selection operation in progress."); + } SelectionModelSelectionChangedEventArgs? e = null; - - if (changes.Count > 0) + + if (--_operationCount == 0) { - var changeSet = new SelectionModelChangeSet(changes); - e = changeSet.CreateEventArgs(); + var changes = new List(); + _rootNode.EndOperation(changes); + + if (changes.Count > 0) + { + var changeSet = new SelectionModelChangeSet(changes); + e = changeSet.CreateEventArgs(); + } } OnSelectionChanged(e); } + private void ApplyAutoSelect() + { + if (AutoSelect) + { + _selectedIndicesCached = null; + + if (SelectedIndex == default && _rootNode.ItemsSourceView?.Count > 0) + { + using var operation = new Operation(this); + SelectImpl(0, true); + } + } + } + internal class SelectedItemInfo : ISelectedItemInfo { public SelectedItemInfo(SelectionNode node, IndexPath path) diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index fea6707b43..4b4a12a7e6 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -587,8 +587,9 @@ namespace Avalonia.Controls if (selectionInvalidated) { OnSelectionChanged(); - _manager.OnSelectionInvalidatedDueToCollectionChange(removed); } + + _manager.OnSelectionInvalidatedDueToCollectionChange(selectionInvalidated, removed); } private bool OnItemsAdded(int index, int count) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 9574843f99..1d2cb9b9ef 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1569,8 +1569,6 @@ namespace Avalonia.Controls.UnitTests }; data.Reset(); - - Assert.Equal(1, raised); } [Fact] @@ -1642,6 +1640,207 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new[] { new IndexPath(2) }, target.SelectedIndices); } + [Fact] + public void AutoSelect_Selects_When_Enabled() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { Source = data }; + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.AutoSelect = true; + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void AutoSelect_Selects_When_Source_Assigned() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { AutoSelect = true }; + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.Source = data; + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void AutoSelect_Selects_When_New_Source_Assigned_And_Old_Source_Has_Selection() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { AutoSelect = true, Source = data }; + var raised = 0; + + target.SelectionChanged += (s, e) => + { + if (raised == 0) + { + Assert.Equal(new[] { new IndexPath(0) }, e.DeselectedIndices); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + } + else + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices); + Assert.Equal(new[] { "newfoo" }, e.SelectedItems); + } + ++raised; + }; + + target.Source = new[] { "newfoo" }; + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(2, raised); + } + + [Fact] + public void AutoSelect_Selects_When_First_Item_Added() + { + var data = new ObservableCollection(); + var target = new SelectionModel { AutoSelect = true , Source = data }; + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + data.Add("foo"); + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void AutoSelect_Selects_When_Selected_Item_Removed() + { + var data = new ObservableCollection { "foo", "bar", "baz" }; + var target = new SelectionModel { AutoSelect = true, Source = data }; + var raised = 0; + + target.SelectedIndex = new IndexPath(2); + + target.SelectionChanged += (s, e) => + { + if (raised == 0) + { + Assert.Empty(e.DeselectedIndices); + Assert.Equal(new[] { "baz" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + } + else + { + Assert.Empty(e.DeselectedIndices); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + } + + ++raised; + }; + + data.RemoveAt(2); + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(2, raised); + } + + [Fact] + public void AutoSelect_Selects_On_Deselection() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { AutoSelect = true, Source = data }; + var raised = 0; + + target.SelectedIndex = new IndexPath(2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(2) }, e.DeselectedIndices); + Assert.Equal(new[] { "baz" }, e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.Deselect(2); + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void AutoSelect_Selects_On_ClearSelection() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { AutoSelect = true, Source = data }; + var raised = 0; + + target.SelectedIndex = new IndexPath(2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(2) }, e.DeselectedIndices); + Assert.Equal(new[] { "baz" }, e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(0) }, e.SelectedIndices); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.ClearSelection(); + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void AutoSelect_Overrides_Deselecting_First_Item() + { + var data = new[] { "foo", "bar", "baz" }; + var target = new SelectionModel { AutoSelect = true, Source = data }; + var raised = 0; + + target.Select(0); + + target.SelectionChanged += (s, e) => + { + ++raised; + }; + + target.Deselect(0); + + Assert.Equal(new IndexPath(0), target.SelectedIndex); + Assert.Equal(0, raised); + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; From 44cf7f24db15c9f94c33f3ce0bf00d7516f59e55 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Feb 2020 12:44:42 +0100 Subject: [PATCH 30/52] Expose API for batch updates. --- src/Avalonia.Controls/SelectionModel.cs | 31 ++++++++++++++----- .../SelectionModelTests.cs | 27 ++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index c0ae67ff91..bc650907d9 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -17,6 +17,7 @@ namespace Avalonia.Controls { private readonly SelectionNode _rootNode; private bool _singleSelect; + private int _operationCount; private IReadOnlyList? _selectedIndicesCached; private IReadOnlyList? _selectedItemsCached; private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs; @@ -509,6 +510,8 @@ namespace Avalonia.Controls ClearSelection(resetAnchor: true); } + public IDisposable Update() => new Operation(this); + protected void OnPropertyChanged(string propertyName) { RaisePropertyChanged(propertyName); @@ -732,19 +735,33 @@ namespace Avalonia.Controls }); } - private void BeginOperation() => _rootNode.BeginOperation(); + private void BeginOperation() + { + if (_operationCount++ == 0) + { + _rootNode.BeginOperation(); + } + } private void EndOperation() { - var changes = new List(); - _rootNode.EndOperation(changes); + if (_operationCount == 0) + { + throw new AvaloniaInternalException("No selection operation in progress."); + } SelectionModelSelectionChangedEventArgs? e = null; - - if (changes.Count > 0) + + if (--_operationCount == 0) { - var changeSet = new SelectionModelChangeSet(changes); - e = changeSet.CreateEventArgs(); + var changes = new List(); + _rootNode.EndOperation(changes); + + if (changes.Count > 0) + { + var changeSet = new SelectionModelChangeSet(changes); + e = changeSet.CreateEventArgs(); + } } OnSelectionChanged(e); diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 1950da2818..c76dc890a9 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1466,6 +1466,33 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, raised); } + [Fact] + public void Can_Batch_Update() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = Enumerable.Range(0, 10).ToList(); + target.Select(1); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(1) }, e.DeselectedIndices); + Assert.Equal(new object[] { 1 }, e.DeselectedItems); + Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices); + Assert.Equal(new object[] { 4 }, e.SelectedItems); + ++raised; + }; + + using (target.Update()) + { + target.Deselect(1); + target.Select(4); + } + + Assert.Equal(1, raised); + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; From c8f0bec40d0d98c1e4b0036ef76e644b9d1877ff Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Feb 2020 14:02:27 +0100 Subject: [PATCH 31/52] Added ISelectionModel. --- src/Avalonia.Controls/ISelectionModel.cs | 47 ++++++++++++++++++++++++ src/Avalonia.Controls/SelectionModel.cs | 21 +++++------ 2 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 src/Avalonia.Controls/ISelectionModel.cs diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs new file mode 100644 index 0000000000..aed21315bb --- /dev/null +++ b/src/Avalonia.Controls/ISelectionModel.cs @@ -0,0 +1,47 @@ +// 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; + +namespace Avalonia.Controls +{ + public interface ISelectionModel + { + IndexPath AnchorIndex { get; set; } + IndexPath SelectedIndex { get; set; } + IReadOnlyList SelectedIndices { get; } + object SelectedItem { get; } + IReadOnlyList SelectedItems { get; } + bool SingleSelect { get; set; } + object Source { get; set; } + + event EventHandler ChildrenRequested; + event EventHandler SelectionChanged; + + void ClearSelection(); + void Deselect(int index); + void Deselect(int groupIndex, int itemIndex); + void DeselectAt(IndexPath index); + void DeselectRange(IndexPath start, IndexPath end); + void DeselectRangeFromAnchor(int index); + void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex); + void DeselectRangeFromAnchorTo(IndexPath index); + void Dispose(); + bool? IsSelected(int index); + bool? IsSelected(int groupIndex, int itemIndex); + bool? IsSelectedAt(IndexPath index); + void Select(int index); + void Select(int groupIndex, int itemIndex); + void SelectAll(); + void SelectAt(IndexPath index); + void SelectRange(IndexPath start, IndexPath end); + void SelectRangeFromAnchor(int index); + void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex); + void SelectRangeFromAnchorTo(IndexPath index); + void SetAnchorIndex(int index); + void SetAnchorIndex(int groupIndex, int index); + } +} diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index a501946365..ce8e53c994 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -12,7 +12,7 @@ using Avalonia.Controls.Utils; namespace Avalonia.Controls { - public class SelectionModel : INotifyPropertyChanged, IDisposable + public class SelectionModel : ISelectionModel, INotifyPropertyChanged, IDisposable { private readonly SelectionNode _rootNode; private bool _singleSelect; @@ -72,7 +72,6 @@ namespace Avalonia.Controls } } - public IndexPath AnchorIndex { get @@ -184,7 +183,7 @@ namespace Avalonia.Controls // 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 ( + var selectedItems = new SelectedItems( selectedInfos, (infos, index) => { @@ -284,7 +283,7 @@ namespace Avalonia.Controls _selectedIndicesCached = indices; } - return _selectedIndicesCached; + return _selectedIndicesCached; } } @@ -455,7 +454,7 @@ namespace Avalonia.Controls PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public void OnSelectionInvalidatedDueToCollectionChange() + internal void OnSelectionInvalidatedDueToCollectionChange() { OnSelectionChanged(); } @@ -535,7 +534,7 @@ namespace Avalonia.Controls RaisePropertyChanged(nameof(SelectedIndex)); RaisePropertyChanged(nameof(SelectedIndices)); - + if (_rootNode.Source != null) { RaisePropertyChanged(nameof(SelectedItem)); @@ -551,7 +550,7 @@ namespace Avalonia.Controls } var selected = _rootNode.Select(index, select); - + if (selected) { AnchorIndex = new IndexPath(index); @@ -569,7 +568,7 @@ namespace Avalonia.Controls var childNode = _rootNode.GetAt(groupIndex, realizeChild: true); var selected = childNode!.Select(itemIndex, select); - + if (selected) { AnchorIndex = new IndexPath(groupIndex, itemIndex); @@ -581,7 +580,7 @@ namespace Avalonia.Controls private void SelectWithPathImpl(IndexPath index, bool select, bool raiseSelectionChanged) { bool selected = false; - + if (_singleSelect) { ClearSelection(resetAnchor: true, raiseSelectionChanged: false); @@ -615,7 +614,7 @@ namespace Avalonia.Controls { int anchorIndex = 0; var anchor = AnchorIndex; - + if (anchor != null) { anchorIndex = anchor.GetAt(0); @@ -634,7 +633,7 @@ namespace Avalonia.Controls var startGroupIndex = 0; var startItemIndex = 0; var anchorIndex = AnchorIndex; - + if (anchorIndex != null) { startGroupIndex = anchorIndex.GetAt(0); From 426c67088236386edeb98f5c28e532ebe7958500 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Feb 2020 14:10:35 +0100 Subject: [PATCH 32/52] Added AutoSelect to ISelectionModel. --- src/Avalonia.Controls/ISelectionModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs index aed21315bb..2f72095dda 100644 --- a/src/Avalonia.Controls/ISelectionModel.cs +++ b/src/Avalonia.Controls/ISelectionModel.cs @@ -16,6 +16,7 @@ namespace Avalonia.Controls object SelectedItem { get; } IReadOnlyList SelectedItems { get; } bool SingleSelect { get; set; } + bool AutoSelect { get; set; } object Source { get; set; } event EventHandler ChildrenRequested; From 6dadd96b7bfe16c7aeef340607984cff1ee664a1 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 6 Feb 2020 11:33:48 +0100 Subject: [PATCH 33/52] Added ISelectionModel.Update(). --- src/Avalonia.Controls/ISelectionModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs index aed21315bb..7db05d849f 100644 --- a/src/Avalonia.Controls/ISelectionModel.cs +++ b/src/Avalonia.Controls/ISelectionModel.cs @@ -43,5 +43,6 @@ namespace Avalonia.Controls void SelectRangeFromAnchorTo(IndexPath index); void SetAnchorIndex(int index); void SetAnchorIndex(int groupIndex, int index); + IDisposable Update(); } } From 520dc16c2a879f4806e1ce8e7580914d011035ab Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 6 Feb 2020 16:12:13 +0100 Subject: [PATCH 34/52] Added failing test. Clearing a nested selection doesn't raise `SelectionChanged`. --- .../SelectionModelTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index c76dc890a9..040448d6fd 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -1184,6 +1184,29 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, raised); } + [Fact] + public void Clearing_Nested_Selection_Raises_SelectionChanged() + { + var target = new SelectionModel(); + var raised = 0; + + target.Source = CreateNestedData(1, 2, 3); + target.Select(1, 1); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { new IndexPath(1, 1) }, e.DeselectedIndices); + Assert.Equal(new object[] { 4 }, e.DeselectedItems); + Assert.Empty(e.SelectedIndices); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.ClearSelection(); + + Assert.Equal(1, raised); + } + [Fact] public void Changing_Source_Raises_SelectionChanged() { From e3d11a82888ba8eef2f928a12e857c9683f0ec3e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 6 Feb 2020 16:12:43 +0100 Subject: [PATCH 35/52] Fix clearing nested selection not raising SelectionChanged. --- src/Avalonia.Controls/SelectionModel.cs | 2 ++ src/Avalonia.Controls/SelectionNode.cs | 23 +++++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index c8ebf59032..5d787fb1ca 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -324,6 +324,7 @@ namespace Avalonia.Controls public void Dispose() { ClearSelection(resetAnchor: false); + _rootNode.Cleanup(); _rootNode.Dispose(); _selectedIndicesCached = null; _selectedItemsCached = null; @@ -764,6 +765,7 @@ namespace Avalonia.Controls } OnSelectionChanged(e); + _rootNode.Cleanup(); } internal class SelectedItemInfo : ISelectedItemInfo diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 04144e1ed0..81177f06ca 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -342,6 +342,19 @@ namespace Avalonia.Controls } } + public void Cleanup() + { + foreach (var child in _childrenNodes) + { + child?.Cleanup(); + + if (child?.SelectedCount == 0) + { + child.Dispose(); + } + } + } + public bool Select(int index, bool select) { return Select(index, select, raiseOnSelectionChanged: true); @@ -453,16 +466,6 @@ namespace Avalonia.Controls 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) From bd022cca397a9c5a83a4f5f3392117a95930e6ac Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 6 Feb 2020 23:57:37 +0100 Subject: [PATCH 36/52] Clean up when node removed. --- src/Avalonia.Controls/SelectionNode.cs | 1 + .../SelectionModelTests.cs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 363eb35b94..5bfa76f83f 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -655,6 +655,7 @@ namespace Avalonia.Controls if (_childrenNodes[index] != null) { RealizedChildrenNodeCount--; + _childrenNodes[index]!.Dispose(); } _childrenNodes.RemoveAt(index); } diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 208d85d8fd..e1a22dd790 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -964,6 +964,20 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Removing_Item_Unhooks_CollectionChanged_Handlers() + { + var data = CreateNestedData(2, 2, 2); + var target = new SelectionModel { Source = data }; + + target.SelectAll(); + + var toRemove = (AvaloniaList)data[1]; + data.Remove(toRemove); + + Assert.Equal(0, GetSubscriberCount(toRemove)); + } + [Fact] public void Should_Not_Treat_Strings_As_Nested_Selections() { From efab1c8266d5c21633fa23d1fa29c681024e7122 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 7 Feb 2020 09:12:15 +0100 Subject: [PATCH 37/52] Fix SelectionNode.Cleanup. - Removed disposed child nodes - Don't dispose child node with descendent selection --- src/Avalonia.Controls/SelectionNode.cs | 22 ++++++++++++++----- .../SelectionModelTests.cs | 15 +++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 024a7aa8e7..d93a54d458 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -342,17 +342,29 @@ namespace Avalonia.Controls } } - public void Cleanup() + public bool Cleanup() { - foreach (var child in _childrenNodes) + var result = SelectedCount == 0; + + for (var i = 0; i < _childrenNodes.Count; ++i) { - child?.Cleanup(); + var child = _childrenNodes[i]; - if (child?.SelectedCount == 0) + if (child != null) { - child.Dispose(); + if (child.Cleanup()) + { + child.Dispose(); + _childrenNodes[i] = null; + } + else + { + result = false; + } } } + + return result; } public bool Select(int index, bool select) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index a024105a55..9f29dce783 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -930,6 +930,21 @@ namespace Avalonia.Controls.UnitTests }); } + [Fact] + public void Should_Listen_For_Changes_After_Deselect() + { + var target = new SelectionModel(); + var data = CreateNestedData(1, 2, 3); + + target.Source = data; + target.Select(1, 0); + target.Deselect(1, 0); + target.Select(1, 0); + ((AvaloniaList)data[1]).Insert(0, "foo"); + + Assert.Equal(new IndexPath(1, 1), target.SelectedIndex); + } + [Fact] public void Selecting_Item_Raises_SelectionChanged() { From e4d45fc46d10a558d7193fe48e0a8362e6ad6d17 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 12 Feb 2020 08:33:00 +0100 Subject: [PATCH 38/52] ISelectionModel implements INotifyPropertyChanged. This will be needed for monitoring the `AnchorIndex` in order to auto-scroll. --- src/Avalonia.Controls/ISelectionModel.cs | 3 ++- src/Avalonia.Controls/SelectionModel.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs index aed21315bb..a939bfdc8c 100644 --- a/src/Avalonia.Controls/ISelectionModel.cs +++ b/src/Avalonia.Controls/ISelectionModel.cs @@ -5,10 +5,11 @@ using System; using System.Collections.Generic; +using System.ComponentModel; namespace Avalonia.Controls { - public interface ISelectionModel + public interface ISelectionModel : INotifyPropertyChanged { IndexPath AnchorIndex { get; set; } IndexPath SelectedIndex { get; set; } diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index ce8e53c994..325e2e8848 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -12,7 +12,7 @@ using Avalonia.Controls.Utils; namespace Avalonia.Controls { - public class SelectionModel : ISelectionModel, INotifyPropertyChanged, IDisposable + public class SelectionModel : ISelectionModel, IDisposable { private readonly SelectionNode _rootNode; private bool _singleSelect; From 12abeab47e99efe667a94548d748eca1858c7d4b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 15 Feb 2020 09:47:54 +0100 Subject: [PATCH 39/52] Remove UWP stubs in unit tests. --- .../SelectionModelTests.cs | 1525 ++++++++--------- 1 file changed, 725 insertions(+), 800 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index e1a22dd790..31e07d834a 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -17,248 +17,224 @@ namespace Avalonia.Controls.UnitTests { public class SelectionModelTests { - private LogWrapper Log { get; } + private readonly ITestOutputHelper _output; public SelectionModelTests(ITestOutputHelper output) { - Log = new LogWrapper(output); + _output = 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() { }); - }); + SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; + _output.WriteLine("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() { }); - }); + SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; + _output.WriteLine("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() }); - }; + SelectionModel selectionModel = new SelectionModel(); + selectionModel.Source = Enumerable.Range(0, 10).ToList(); - Select(selectionModel, 4, true); + int selectionChangedFiredCount = 0; + selectionModel.SelectionChanged += delegate (object sender, SelectionModelSelectionChangedEventArgs args) + { + selectionChangedFiredCount++; ValidateSelection(selectionModel, new List() { Path(4) }, new List() { Path() }); - Assert.Equal(1, selectionChangedFiredCount); - }); + }; + + 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)); - }); + 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() }); - }); + 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() { }); - }); + SelectionModel selectionModel = new SelectionModel(); + _output.WriteLine("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() { }); - }); + SelectionModel selectionModel = new SelectionModel(); + _output.WriteLine("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() { }); - }); + SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; + _output.WriteLine("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] @@ -266,497 +242,470 @@ namespace Avalonia.Controls.UnitTests [InlineData(false)] public void ValidateNestedMultipleSelection(bool handleChildrenRequested) { - RunOnUIThread.Execute(() => + SelectionModel selectionModel = new SelectionModel(); + List sourcePaths = new List(); + + _output.WriteLine("Setting the source"); + selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 4 /* countAtLeaf */); + if (handleChildrenRequested) { - SelectionModel selectionModel = new SelectionModel(); - List sourcePaths = new List(); + selectionModel.ChildrenRequested += (object sender, SelectionModelChildrenRequestedEventArgs args) => + { + _output.WriteLine("ChildrenRequestedIndexPath:" + args.SourceIndex); + sourcePaths.Add(args.SourceIndex); + args.Children = args.Source is IEnumerable ? args.Source : null; + }; + } - Log.Comment("Setting the source"); - selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 4 /* countAtLeaf */); - if (handleChildrenRequested) + var startPath = Path(1, 0, 1, 0); + Select(selectionModel, startPath, true); + ValidateSelection(selectionModel, + new List() { startPath }, + new List() { - selectionModel.ChildrenRequested += (object sender, SelectionModelChildrenRequestedEventArgs args) => - { - Log.Comment("ChildrenRequestedIndexPath:" + args.SourceIndex); - sourcePaths.Add(args.SourceIndex); - args.Children = args.Source is IEnumerable ? args.Source : null; - }; - } + Path(), + Path(1), + Path(1, 0), + Path(1, 0, 1) + }); - 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 */); - 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) + }; - if (handleChildrenRequested) + Assert.Equal(expectedSourceIndices.Count, sourcePaths.Count); + for (int i = 0; i < expectedSourceIndices.Count; i++) { - // 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])); - } + 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() { }); - }); + 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() - }); - }); + 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() + }); + + _output.WriteLine("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() + }); + + _output.WriteLine("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() + }); + + _output.WriteLine("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; + 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), - }); - }); + selectionModel.Select(1, 1); + ValidateSelection(selectionModel, + new List() + { + Path(1, 1), + }, + new List() + { + Path(), + Path(1), + }); + + _output.WriteLine("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), + }); + + _output.WriteLine("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() - }); + 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() - }); + _output.WriteLine("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() + }); + + _output.WriteLine("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() - }); - }); + _output.WriteLine("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), - }); + 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), + }); + + _output.WriteLine("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), + }); + + _output.WriteLine("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()); - }); + _output.WriteLine("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() - }); + 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() - }); - }); + 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; + 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) - }); + 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()); - }); + 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() - }); + 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()); - }); + 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; + 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) - }); + 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()); - }); + (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 @@ -767,167 +716,155 @@ namespace Avalonia.Controls.UnitTests [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 */); - }); + 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 */); + + _output.WriteLine("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), + }); + + _output.WriteLine("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(); + _output.WriteLine("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) => { - 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(); - } - }; + 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); + Select(selectionModel, 3, true); - Assert.True(selectedIndexChanged); - Assert.True(selectedIndicesChanged); - Assert.True(SelectedItemChanged); - Assert.True(SelectedItemsChanged); - Assert.True(AnchorIndexChanged); - }); + 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) => { - var selectionModel = new CustomSelectionModel(); - bool intPropertyChanged = false; - selectionModel.PropertyChanged += (sender, args) => + if (args.PropertyName == "IntProperty") { - if (args.PropertyName == "IntProperty") - { - intPropertyChanged = true; - } - }; + intPropertyChanged = true; + } + }; - selectionModel.IntProperty = 5; - Assert.True(intPropertyChanged); - }); + selectionModel.IntProperty = 5; + Assert.True(intPropertyChanged); } [Fact] public void SelectRangeRegressionTest() { - RunOnUIThread.Execute(() => + var selectionModel = new SelectionModel() { - var selectionModel = new SelectionModel() - { - Source = CreateNestedData(1, 2, 3) - }; + Source = CreateNestedData(1, 2, 3) + }; - // length of start smaller than end used to cause an out of range error. - selectionModel.SelectRange(IndexPath.CreateFrom(0), IndexPath.CreateFrom(1, 1)); - - ValidateSelection(selectionModel, - new List() - { - Path(0, 0), - Path(0, 1), - Path(0, 2), - Path(0), - Path(1, 0), - Path(1, 1) - }, - new List() - { - Path(), - Path(1) - }, - 1 /* selectedInnerNodes */); - }); + // length of start smaller than end used to cause an out of range error. + selectionModel.SelectRange(IndexPath.CreateFrom(0), IndexPath.CreateFrom(1, 1)); + + ValidateSelection(selectionModel, + new List() + { + Path(0, 0), + Path(0, 1), + Path(0, 2), + Path(0), + Path(1, 0), + Path(1, 1) + }, + new List() + { + Path(), + Path(1) + }, + 1 /* selectedInnerNodes */); } [Fact] @@ -1011,7 +948,7 @@ namespace Avalonia.Controls.UnitTests private void Select(SelectionModel manager, int index, bool select) { - Log.Comment((select ? "Selecting " : "DeSelecting ") + index); + _output.WriteLine((select ? "Selecting " : "DeSelecting ") + index); if (select) { manager.Select(index); @@ -1024,7 +961,7 @@ namespace Avalonia.Controls.UnitTests private void Select(SelectionModel manager, int groupIndex, int itemIndex, bool select) { - Log.Comment((select ? "Selecting " : "DeSelecting ") + groupIndex + "." + itemIndex); + _output.WriteLine((select ? "Selecting " : "DeSelecting ") + groupIndex + "." + itemIndex); if (select) { manager.Select(groupIndex, itemIndex); @@ -1037,7 +974,7 @@ namespace Avalonia.Controls.UnitTests private void Select(SelectionModel manager, IndexPath index, bool select) { - Log.Comment((select ? "Selecting " : "DeSelecting ") + index); + _output.WriteLine((select ? "Selecting " : "DeSelecting ") + index); if (select) { manager.SelectAt(index); @@ -1050,7 +987,7 @@ namespace Avalonia.Controls.UnitTests private void SelectRangeFromAnchor(SelectionModel manager, int index, bool select) { - Log.Comment("SelectRangeFromAnchor " + index + " select: " + select.ToString()); + _output.WriteLine("SelectRangeFromAnchor " + index + " select: " + select.ToString()); if (select) { manager.SelectRangeFromAnchor(index); @@ -1063,7 +1000,7 @@ namespace Avalonia.Controls.UnitTests private void SelectRangeFromAnchor(SelectionModel manager, int groupIndex, int itemIndex, bool select) { - Log.Comment("SelectRangeFromAnchor " + groupIndex + "." + itemIndex + " select:" + select.ToString()); + _output.WriteLine("SelectRangeFromAnchor " + groupIndex + "." + itemIndex + " select:" + select.ToString()); if (select) { manager.SelectRangeFromAnchor(groupIndex, itemIndex); @@ -1076,7 +1013,7 @@ namespace Avalonia.Controls.UnitTests private void SelectRangeFromAnchor(SelectionModel manager, IndexPath index, bool select) { - Log.Comment("SelectRangeFromAnchor " + index + " select: " + select.ToString()); + _output.WriteLine("SelectRangeFromAnchor " + index + " select: " + select.ToString()); if (select) { manager.SelectRangeFromAnchorTo(index); @@ -1089,25 +1026,25 @@ namespace Avalonia.Controls.UnitTests private void ClearSelection(SelectionModel manager) { - Log.Comment("ClearSelection"); + _output.WriteLine("ClearSelection"); manager.ClearSelection(); } private void SetAnchorIndex(SelectionModel manager, int index) { - Log.Comment("SetAnchorIndex " + index); + _output.WriteLine("SetAnchorIndex " + index); manager.SetAnchorIndex(index); } private void SetAnchorIndex(SelectionModel manager, int groupIndex, int itemIndex) { - Log.Comment("SetAnchor " + groupIndex + "." + itemIndex); + _output.WriteLine("SetAnchor " + groupIndex + "." + itemIndex); manager.SetAnchorIndex(groupIndex, itemIndex); } private void SetAnchorIndex(SelectionModel manager, IndexPath index) { - Log.Comment("SetAnchor " + index); + _output.WriteLine("SetAnchor " + index); manager.AnchorIndex = index; } @@ -1117,18 +1054,18 @@ namespace Avalonia.Controls.UnitTests List expectedPartialSelected = null, int selectedInnerNodes = 0) { - Log.Comment("Validating Selection..."); + _output.WriteLine("Validating Selection..."); - Log.Comment("Selection contains indices:"); + _output.WriteLine("Selection contains indices:"); foreach (var index in selectionModel.SelectedIndices) { - Log.Comment(" " + index.ToString()); + _output.WriteLine(" " + index.ToString()); } - Log.Comment("Selection contains items:"); + _output.WriteLine("Selection contains items:"); foreach (var item in selectionModel.SelectedItems) { - Log.Comment(" " + item.ToString()); + _output.WriteLine(" " + item.ToString()); } if (selectionModel.Source != null) @@ -1149,7 +1086,7 @@ namespace Avalonia.Controls.UnitTests { if (isSelected == null) { - Log.Comment("*************" + index + " is null"); + _output.WriteLine("*************" + index + " is null"); Assert.True(false, "Expected false but got null");; } else @@ -1168,7 +1105,7 @@ namespace Avalonia.Controls.UnitTests } if (expectedSelected.Count > 0) { - Log.Comment("SelectedIndex is " + selectionModel.SelectedIndex); + _output.WriteLine("SelectedIndex is " + selectionModel.SelectedIndex); Assert.Equal(0, selectionModel.SelectedIndex.CompareTo(expectedSelected[0])); if (selectionModel.Source != null) { @@ -1181,7 +1118,7 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(expectedSelected.Count - selectedInnerNodes, indicesCount); } - Log.Comment("Validating Selection... done"); + _output.WriteLine("Validating Selection... done"); } private object GetData(SelectionModel selectionModel, IndexPath indexPath) @@ -1225,12 +1162,12 @@ namespace Avalonia.Controls.UnitTests } }); - Log.Comment("All Paths in source.."); + _output.WriteLine("All Paths in source.."); foreach (var path in paths) { - Log.Comment(path.ToString()); + _output.WriteLine(path.ToString()); } - Log.Comment("done."); + _output.WriteLine("done."); return paths; } @@ -1328,18 +1265,6 @@ namespace Avalonia.Controls.UnitTests 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 From 95190e168b49f2eaf2ad5471e1931f8c0b45420f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 15 Feb 2020 10:02:15 +0100 Subject: [PATCH 40/52] Fixed SelectionModelChildrenRequestedEventArgs returning incorrect so... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …urce index fixed sourceindex childrenrequested args Port of https://github.com/microsoft/microsoft-ui-xaml/commit/ee03bace144e3a37650df0536955f5f658dd2aad --- src/Avalonia.Controls/SelectionModel.cs | 8 ++--- ...electionModelChildrenRequestedEventArgs.cs | 35 +++++++++++++------ src/Avalonia.Controls/SelectionNode.cs | 3 +- .../SelectionModelTests.cs | 20 +++++------ 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 325e2e8848..d55faf53f3 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -459,7 +459,7 @@ namespace Avalonia.Controls OnSelectionChanged(); } - internal object? ResolvePath(object data, SelectionNode sourceNode) + internal object? ResolvePath(object data, IndexPath dataIndexPath) { object? resolved = null; @@ -468,18 +468,18 @@ namespace Avalonia.Controls { if (_childrenRequestedEventArgs == null) { - _childrenRequestedEventArgs = new SelectionModelChildrenRequestedEventArgs(data, sourceNode); + _childrenRequestedEventArgs = new SelectionModelChildrenRequestedEventArgs(data, dataIndexPath, false); } else { - _childrenRequestedEventArgs.Initialize(data, sourceNode); + _childrenRequestedEventArgs.Initialize(data, dataIndexPath, false); } 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); + _childrenRequestedEventArgs.Initialize(null, default, true); } else { diff --git a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs index aa5a9b5cad..823e7b9447 100644 --- a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs @@ -12,12 +12,16 @@ namespace Avalonia.Controls public class SelectionModelChildrenRequestedEventArgs : EventArgs { private object? _source; - private SelectionNode? _sourceNode; - - internal SelectionModelChildrenRequestedEventArgs(object source, SelectionNode sourceNode) + private IndexPath _sourceIndexPath; + private bool _throwOnAccess; + + internal SelectionModelChildrenRequestedEventArgs( + object source, + IndexPath sourceIndexPath, + bool throwOnAccess) { - _source = source; - _sourceNode = sourceNode; + source = source ?? throw new ArgumentNullException(nameof(source)); + Initialize(source, sourceIndexPath, throwOnAccess); } public object? Children { get; set; } @@ -26,12 +30,12 @@ namespace Avalonia.Controls { get { - if (_source == null) + if (_throwOnAccess) { throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs)); } - return _source; + return _source!; } } @@ -39,19 +43,28 @@ namespace Avalonia.Controls { get { - if (_sourceNode == null) + if (_throwOnAccess) { throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs)); } - return _sourceNode.IndexPath; + return _sourceIndexPath; } } - internal void Initialize(object? source, SelectionNode? sourceNode) + internal void Initialize( + object? source, + IndexPath sourceIndexPath, + bool throwOnAccess) { + if (!throwOnAccess && source == null) + { + throw new ArgumentNullException(nameof(source)); + } + _source = source; - _sourceNode = sourceNode; + _sourceIndexPath = sourceIndexPath; + _throwOnAccess = throwOnAccess; } } } diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 5bfa76f83f..bf21a5f2d1 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -128,7 +128,8 @@ namespace Avalonia.Controls if (childData != null) { - var resolvedChild = _manager.ResolvePath(childData, this); + var childDataIndexPath = IndexPath.CloneWithChildIndex(index); + var resolvedChild = _manager.ResolvePath(childData, childDataIndexPath); if (resolvedChild != null) { diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 31e07d834a..3233059718 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -277,21 +277,21 @@ namespace Avalonia.Controls.UnitTests // 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, 0, 1, 3), + Path(1, 0, 1, 2), + Path(1, 0, 1, 1), + Path(1, 0, 1, 0), + Path(1, 1, 1), Path(1, 1, 0), - Path(1, 1, 1) + Path(1, 1, 0, 3), + Path(1, 1, 0, 2), + Path(1, 1, 0, 1), + Path(1, 1, 0, 0), + Path(1, 1, 1, 0) }; Assert.Equal(expectedSourceIndices.Count, sourcePaths.Count); From 96fbd6c531313dc0ab9fc3eeb7265a693b58e75f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 15 Feb 2020 23:55:21 +0100 Subject: [PATCH 41/52] Don't only select leaf nodes from SelectRange. Related issues: https://github.com/microsoft/microsoft-ui-xaml/issues/1969 https://github.com/microsoft/microsoft-ui-xaml/issues/1984 --- src/Avalonia.Controls/SelectionModel.cs | 6 +- .../SelectionModelTests.cs | 81 ++++++++++++++----- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index a2b080efc4..d41593b4eb 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -759,11 +759,7 @@ namespace Avalonia.Controls winrtEnd, info => { - if (info.Node.DataCount == 0) - { - // Select only leaf nodes - info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); - } + info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); }); } diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index bbba360a44..0769df5b48 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -305,16 +305,19 @@ namespace Avalonia.Controls.UnitTests ValidateSelection(selectionModel, new List() { + Path(1, 0), + Path(1, 1), + Path(1, 0, 1), 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), + Path(1, 1, 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() @@ -324,8 +327,7 @@ namespace Avalonia.Controls.UnitTests Path(1, 0), Path(1, 1), Path(1, 1, 1), - }, - 2 /* selectedInnerNodes */); + }); ClearSelection(selectionModel); ValidateSelection(selectionModel, new List() { }); @@ -337,16 +339,20 @@ namespace Avalonia.Controls.UnitTests 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), + Path(0, 0), + Path(0, 1), + Path(0, 0, 0), + Path(0, 0, 1), + 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, 1, 0), + Path(0, 1, 0, 0), + Path(0, 1, 0, 1), + Path(0, 1, 0, 2), }, new List() { @@ -356,8 +362,7 @@ namespace Avalonia.Controls.UnitTests Path(0, 0, 0), Path(0, 1), Path(0, 1, 0), - }, - 1 /* selectedInnerNodes */); + }); startPath = Path(0, 1, 0, 2); SetAnchorIndex(selectionModel, startPath); @@ -853,10 +858,11 @@ namespace Avalonia.Controls.UnitTests ValidateSelection(selectionModel, new List() { + Path(0), + Path(1), Path(0, 0), Path(0, 1), Path(0, 2), - Path(0), Path(1, 0), Path(1, 1) }, @@ -864,8 +870,7 @@ namespace Avalonia.Controls.UnitTests { Path(), Path(1) - }, - 1 /* selectedInnerNodes */); + }); } [Fact] @@ -1271,7 +1276,7 @@ namespace Avalonia.Controls.UnitTests target.SelectionChanged += (s, e) => { Assert.Empty(e.DeselectedIndices); - Assert.Equal(new object[] { 0, 1, 2 }, e.DeselectedItems); + Assert.Equal(new object[] { new AvaloniaList { 0, 1, 2 }, 0, 1, 2 }, e.DeselectedItems); Assert.Empty(e.SelectedIndices); Assert.Empty(e.SelectedItems); ++raised; @@ -1371,6 +1376,38 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(0, GetSubscriberCount(toRemove)); } + [Fact] + public void SelectRange_Behaves_The_Same_As_Multiple_Selects() + { + var data = new[] { 1, 2, 3 }; + var target = new SelectionModel { Source = data }; + + target.Select(1); + + Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); + + target.ClearSelection(); + target.SelectRange(new IndexPath(1), new IndexPath(1)); + + Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); + } + + [Fact] + public void SelectRange_Behaves_The_Same_As_Multiple_Selects_Nested() + { + var data = CreateNestedData(3, 2, 2); + var target = new SelectionModel { Source = data }; + + target.Select(1); + + Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); + + target.ClearSelection(); + target.SelectRange(new IndexPath(1), new IndexPath(1)); + + Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); + } + [Fact] public void Should_Not_Treat_Strings_As_Nested_Selections() { @@ -2005,7 +2042,7 @@ namespace Avalonia.Controls.UnitTests foreach (var index in allIndices) { bool? isSelected = selectionModel.IsSelectedAt(index); - if (Contains(expectedSelected, index)) + if (Contains(expectedSelected, index) && !Contains(expectedPartialSelected, index)) { Assert.True(isSelected.Value, index + " is Selected"); } @@ -2037,7 +2074,7 @@ namespace Avalonia.Controls.UnitTests if (expectedSelected.Count > 0) { _output.WriteLine("SelectedIndex is " + selectionModel.SelectedIndex); - Assert.Equal(0, selectionModel.SelectedIndex.CompareTo(expectedSelected[0])); + Assert.Equal(expectedSelected[0], selectionModel.SelectedIndex); if (selectionModel.Source != null) { Assert.Equal(selectionModel.SelectedItem, GetData(selectionModel, expectedSelected[0])); From f103df6a9a8dba4ae5880b1abee937a76b30b37c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 16 Feb 2020 00:46:18 +0100 Subject: [PATCH 42/52] Changed IsSelected semantics. Move old `IsSelected` semantics to `IsSelectedWithPartial` and add a new `IsSelected` method which checks for direct selection. --- src/Avalonia.Controls/ISelectionModel.cs | 9 +++-- src/Avalonia.Controls/SelectionModel.cs | 34 ++++++++++++++++--- .../SelectionModelTests.cs | 4 +-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs index f7283d9d79..34fe626696 100644 --- a/src/Avalonia.Controls/ISelectionModel.cs +++ b/src/Avalonia.Controls/ISelectionModel.cs @@ -32,9 +32,12 @@ namespace Avalonia.Controls void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex); void DeselectRangeFromAnchorTo(IndexPath index); void Dispose(); - bool? IsSelected(int index); - bool? IsSelected(int groupIndex, int itemIndex); - bool? IsSelectedAt(IndexPath index); + bool IsSelected(int index); + bool IsSelected(int grouIndex, int itemIndex); + public bool IsSelectedAt(IndexPath index); + bool? IsSelectedWithPartial(int index); + bool? IsSelectedWithPartial(int groupIndex, int itemIndex); + bool? IsSelectedWithPartialAt(IndexPath index); void Select(int index); void Select(int groupIndex, int itemIndex); void SelectAll(); diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index d41593b4eb..0531174454 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -174,7 +174,7 @@ namespace Avalonia.Controls } set { - var isSelected = IsSelectedAt(value); + var isSelected = IsSelectedWithPartialAt(value); if (!isSelected.HasValue || !isSelected.Value) { @@ -393,7 +393,33 @@ namespace Avalonia.Controls ApplyAutoSelect(); } - public bool? IsSelected(int index) + public bool IsSelected(int index) => _rootNode.IsSelected(index); + + public bool IsSelected(int grouIndex, int itemIndex) + { + return IsSelectedAt(new IndexPath(grouIndex, itemIndex)); + } + + public bool IsSelectedAt(IndexPath index) + { + var path = index; + SelectionNode? 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) + { + return false; + } + } + + return node.IsSelected(index.GetAt(index.GetSize() - 1)); + } + + public bool? IsSelectedWithPartial(int index) { if (index < 0) { @@ -404,7 +430,7 @@ namespace Avalonia.Controls return isSelected; } - public bool? IsSelected(int groupIndex, int itemIndex) + public bool? IsSelectedWithPartial(int groupIndex, int itemIndex) { if (groupIndex < 0) { @@ -427,7 +453,7 @@ namespace Avalonia.Controls return isSelected; } - public bool? IsSelectedAt(IndexPath index) + public bool? IsSelectedWithPartialAt(IndexPath index) { var path = index; var isRealized = true; diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 0769df5b48..9c3b0c17b6 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -2041,7 +2041,7 @@ namespace Avalonia.Controls.UnitTests List allIndices = GetIndexPathsInSource(selectionModel.Source); foreach (var index in allIndices) { - bool? isSelected = selectionModel.IsSelectedAt(index); + bool? isSelected = selectionModel.IsSelectedWithPartialAt(index); if (Contains(expectedSelected, index) && !Contains(expectedPartialSelected, index)) { Assert.True(isSelected.Value, index + " is Selected"); @@ -2068,7 +2068,7 @@ namespace Avalonia.Controls.UnitTests { foreach (var index in expectedSelected) { - Assert.True(selectionModel.IsSelectedAt(index).Value, index + " is Selected"); + Assert.True(selectionModel.IsSelectedWithPartialAt(index), index + " is Selected"); } } if (expectedSelected.Count > 0) From 0c894e20d3ccba4dac511195a51b36e00612cd69 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 11:05:02 +0100 Subject: [PATCH 43/52] Added SelectedItemsSync. To sync between an `ISelectionModel` and a `SelectedItems` collection. --- .../Utils/SelectedItemsSync.cs | 226 ++++++++++++++++++ .../Utils/SelectedItemsSyncTests.cs | 210 ++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 src/Avalonia.Controls/Utils/SelectedItemsSync.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs new file mode 100644 index 0000000000..3d6c88cd99 --- /dev/null +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections; +using System.Collections.Specialized; +using System.Linq; +using Avalonia.Collections; + +#nullable enable + +namespace Avalonia.Controls.Utils +{ + /// + /// Synchronizes an with a list of SelectedItems. + /// + internal class SelectedItemsSync + { + private IList? _items; + private bool _updatingItems; + private bool _updatingModel; + + public SelectedItemsSync(ISelectionModel model) + { + Model = model; + } + + public ISelectionModel Model { get; private set; } + + public IList GetOrCreateItems() + { + if (_items == null) + { + var items = new AvaloniaList(Model.SelectedItems); + items.CollectionChanged += ItemsCollectionChanged; + Model.SelectionChanged += SelectionModelSelectionChanged; + _items = items; + } + + return _items; + } + + public void SetItems(IList items) + { + items = items ?? throw new ArgumentNullException(nameof(items)); + + if (items.IsFixedSize) + { + throw new NotSupportedException( + "Cannot assign fixed size selection to SelectedItems."); + } + + if (_items is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= ItemsCollectionChanged; + } + + if (_items == null) + { + Model.SelectionChanged += SelectionModelSelectionChanged; + } + + try + { + _updatingModel = true; + _items = items; + + using (Model.Update()) + { + Model.ClearSelection(); + Add(items); + } + + if (_items is INotifyCollectionChanged incc2) + { + incc2.CollectionChanged += ItemsCollectionChanged; + } + } + finally + { + _updatingModel = false; + } + } + + public void SetModel(ISelectionModel model) + { + model = model ?? throw new ArgumentNullException(nameof(model)); + + if (_items != null) + { + Model.SelectionChanged -= SelectionModelSelectionChanged; + Model = model; + Model.SelectionChanged += SelectionModelSelectionChanged; + + try + { + _updatingItems = true; + _items.Clear(); + + foreach (var i in model.SelectedItems) + { + _items.Add(i); + } + } + finally + { + _updatingItems = false; + } + } + } + + private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_updatingItems) + { + return; + } + + if (_items == null) + { + throw new AvaloniaInternalException("CollectionChanged raised but we don't have items."); + } + + void Remove() + { + foreach (var i in e.OldItems) + { + var index = IndexOf(Model.Source, i); + + if (index != -1) + { + Model.Deselect(index); + } + } + } + + try + { + using var operation = Model.Update(); + + _updatingModel = true; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Add(e.NewItems); + break; + case NotifyCollectionChangedAction.Remove: + Remove(); + break; + case NotifyCollectionChangedAction.Replace: + Remove(); + Add(e.NewItems); + break; + case NotifyCollectionChangedAction.Reset: + Model.ClearSelection(); + Add(_items); + break; + } + } + finally + { + _updatingModel = false; + } + } + + private void Add(IList newItems) + { + foreach (var i in newItems) + { + var index = IndexOf(Model.Source, i); + + if (index != -1) + { + Model.Select(index); + } + } + } + + private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + if (_updatingModel) + { + return; + } + + if (_items == null) + { + throw new AvaloniaInternalException("SelectionModelChanged raised but we don't have items."); + } + + try + { + var deselected = e.DeselectedItems.ToList(); + var selected = e.SelectedItems.ToList(); + + _updatingItems = true; + + foreach (var i in deselected) + { + _items.Remove(i); + } + + foreach (var i in selected) + { + _items.Add(i); + } + } + finally + { + _updatingItems = false; + } + } + + private static int IndexOf(object source, object item) + { + if (source is IList l) + { + return l.IndexOf(item); + } + else if (source is ItemsSourceView v) + { + return v.IndexOf(item); + } + + return -1; + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs new file mode 100644 index 0000000000..917f422557 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Avalonia.Collections; +using Avalonia.Controls.Utils; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Utils +{ + public class SelectedItemsSyncTests + { + [Fact] + public void Initial_Items_Are_From_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + Assert.Equal(new[] { "bar", "baz" }, items); + } + + [Fact] + public void Selecting_On_Model_Adds_Item() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + target.Model.Select(0); + + Assert.Equal(new[] { "bar", "baz", "foo" }, items); + } + + [Fact] + public void Selecting_Duplicate_On_Model_Adds_Item() + { + var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + var items = target.GetOrCreateItems(); + + target.Model.Select(4); + + Assert.Equal(new[] { "bar", "baz", "bar" }, items); + } + + [Fact] + public void Deselecting_On_Model_Removes_Item() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + target.Model.Deselect(1); + + Assert.Equal(new[] { "baz" }, items); + } + + [Fact] + public void Deselecting_Duplicate_On_Model_Removes_Item() + { + var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + var items = target.GetOrCreateItems(); + + target.Model.Select(4); + target.Model.Deselect(4); + + Assert.Equal(new[] { "baz", "bar" }, items); + } + + [Fact] + public void Reassigning_Model_Resets_Items() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + var newModel = new SelectionModel { Source = target.Model.Source }; + newModel.Select(0); + newModel.Select(1); + + target.SetModel(newModel); + + Assert.Equal(new[] { "foo", "bar" }, items); + } + + [Fact] + public void Reassigning_Model_Tracks_New_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + var newModel = new SelectionModel { Source = target.Model.Source }; + target.SetModel(newModel); + + newModel.Select(0); + newModel.Select(1); + + Assert.Equal(new[] { "foo", "bar" }, items); + } + + [Fact] + public void Adding_To_Items_Selects_On_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Add("foo"); + + Assert.Equal( + new[] { new IndexPath(0), new IndexPath(1), new IndexPath(2) }, + target.Model.SelectedIndices); + Assert.Equal(new[] { "bar", "baz", "foo" }, items); + } + + [Fact] + public void Removing_From_Items_Deselects_On_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Remove("baz"); + + Assert.Equal(new[] { new IndexPath(1) }, target.Model.SelectedIndices); + Assert.Equal(new[] { "bar" }, items); + } + + [Fact] + public void Replacing_Item_Updates_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items[0] = "foo"; + + Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices); + Assert.Equal(new[] { "foo", "baz" }, items); + } + + [Fact] + public void Clearing_Items_Updates_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Clear(); + + Assert.Empty(target.Model.SelectedIndices); + } + + [Fact] + public void Setting_Items_Updates_Model() + { + var target = CreateTarget(); + var oldItems = target.GetOrCreateItems(); + + var newItems = new AvaloniaList { "foo", "baz" }; + target.SetItems(newItems); + + Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices); + Assert.Same(newItems, target.GetOrCreateItems()); + Assert.NotSame(oldItems, target.GetOrCreateItems()); + Assert.Equal(new[] { "foo", "baz" }, newItems); + } + + [Fact] + public void Setting_Items_Subscribes_To_Model() + { + var target = CreateTarget(); + var items = new AvaloniaList { "foo", "baz" }; + + target.SetItems(items); + target.Model.Select(1); + + Assert.Equal(new[] { "foo", "baz", "bar" }, items); + } + + [Fact] + public void Handles_Null_Model_Source() + { + var model = new SelectionModel(); + model.Select(1); + + var target = new SelectedItemsSync(model); + var items = target.GetOrCreateItems(); + + Assert.Empty(items); + + model.Select(2); + model.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(new[] { "bar", "baz" }, items); + } + + [Fact] + public void Does_Not_Accept_Fixed_Size_Items() + { + var target = CreateTarget(); + + Assert.Throws(() => + target.SetItems(new[] { "foo", "bar", "baz" })); + } + + private static SelectedItemsSync CreateTarget( + IEnumerable items = null) + { + items ??= new[] { "foo", "bar", "baz" }; + + var model = new SelectionModel { Source = items }; + model.SelectRange(new IndexPath(1), new IndexPath(2)); + + var target = new SelectedItemsSync(model); + return target; + } + } +} From 60011155734cabd13d9aaf2736b39f805ea4f262 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 12:01:37 +0100 Subject: [PATCH 44/52] Use SelectionModel in SelectingItemsControl. --- samples/BindingDemo/MainWindow.xaml | 4 +- .../ViewModels/MainWindowViewModel.cs | 5 +- samples/ControlCatalog/Pages/ListBoxPage.xaml | 2 +- samples/VirtualizationDemo/MainWindow.xaml | 2 +- .../ViewModels/MainWindowViewModel.cs | 14 +- src/Avalonia.Controls/ComboBox.cs | 4 +- src/Avalonia.Controls/ListBox.cs | 19 +- .../Primitives/SelectingItemsControl.cs | 834 +++++------------- src/Avalonia.Controls/SelectionModel.cs | 3 +- .../Utils/SelectedItemsSync.cs | 5 +- .../CarouselTests.cs | 6 +- .../Primitives/SelectingItemsControlTests.cs | 31 +- .../SelectingItemsControlTests_AutoSelect.cs | 4 +- .../SelectingItemsControlTests_Multiple.cs | 211 ++++- .../Primitives/TabStripTests.cs | 9 +- .../TabControlTests.cs | 6 +- .../Utils/SelectedItemsSyncTests.cs | 13 + 17 files changed, 509 insertions(+), 663 deletions(-) diff --git a/samples/BindingDemo/MainWindow.xaml b/samples/BindingDemo/MainWindow.xaml index b57a9a0a9e..26a62ebca6 100644 --- a/samples/BindingDemo/MainWindow.xaml +++ b/samples/BindingDemo/MainWindow.xaml @@ -74,11 +74,11 @@ - + - + diff --git a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs index 22d01e0765..a66038ff3e 100644 --- a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,7 @@ using System.Reactive.Linq; using System.Threading.Tasks; using System.Threading; using ReactiveUI; +using Avalonia.Controls; namespace BindingDemo.ViewModels { @@ -27,7 +28,7 @@ namespace BindingDemo.ViewModels Detail = "Item " + x + " details", })); - SelectedItems = new ObservableCollection(); + Selection = new SelectionModel(); ShuffleItems = ReactiveCommand.Create(() => { @@ -56,7 +57,7 @@ namespace BindingDemo.ViewModels } public ObservableCollection Items { get; } - public ObservableCollection SelectedItems { get; } + public SelectionModel Selection { get; } public ReactiveCommand ShuffleItems { get; } public string BooleanString diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index b1b3112e60..47b4ce7151 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -10,7 +10,7 @@ HorizontalAlignment="Center" Spacing="16"> - + diff --git a/samples/VirtualizationDemo/MainWindow.xaml b/samples/VirtualizationDemo/MainWindow.xaml index 12137cd03d..4bd657bf93 100644 --- a/samples/VirtualizationDemo/MainWindow.xaml +++ b/samples/VirtualizationDemo/MainWindow.xaml @@ -45,7 +45,7 @@ SelectedItems { get; } - = new AvaloniaList(); + public SelectionModel Selection { get; } = new SelectionModel(); public AvaloniaList Items { @@ -141,9 +140,9 @@ namespace VirtualizationDemo.ViewModels { var index = Items.Count; - if (SelectedItems.Count > 0) + if (Selection.SelectedIndices.Count > 0) { - index = Items.IndexOf(SelectedItems[0]); + index = Selection.SelectedIndex.GetAt(0); } Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString)); @@ -151,9 +150,9 @@ namespace VirtualizationDemo.ViewModels private void Remove() { - if (SelectedItems.Count > 0) + if (Selection.SelectedItems.Count > 0) { - Items.RemoveAll(SelectedItems); + Items.RemoveAll(Selection.SelectedItems.Cast().ToList()); } } @@ -167,8 +166,7 @@ namespace VirtualizationDemo.ViewModels private void SelectItem(int index) { - SelectedItems.Clear(); - SelectedItems.Add(Items[index]); + Selection.SelectedIndex = new IndexPath(index); } } } diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index c2cf20b32d..0722802962 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -289,9 +289,9 @@ namespace Avalonia.Controls { var container = ItemContainerGenerator.ContainerFromIndex(selectedIndex); - if (container == null && SelectedItems.Count > 0) + if (container == null && SelectedIndex != -1) { - ScrollIntoView(SelectedItems[0]); + ScrollIntoView(Selection.SelectedItem); container = ItemContainerGenerator.ContainerFromIndex(selectedIndex); } diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 4966e669ed..a15aedd621 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -34,6 +34,12 @@ namespace Avalonia.Controls public static readonly new DirectProperty SelectedItemsProperty = SelectingItemsControl.SelectedItemsProperty; + /// + /// Defines the property. + /// + public static readonly new DirectProperty SelectionProperty = + SelectingItemsControl.SelectionProperty; + /// /// Defines the property. /// @@ -73,6 +79,15 @@ namespace Avalonia.Controls set => base.SelectedItems = value; } + /// + /// Gets or sets a model holding the current selection. + /// + public new ISelectionModel Selection + { + get => base.Selection; + set => base.Selection = value; + } + /// /// Gets or sets the selection mode. /// @@ -98,12 +113,12 @@ namespace Avalonia.Controls /// /// Selects all items in the . /// - public new void SelectAll() => base.SelectAll(); + public void SelectAll() => Selection.SelectAll(); /// /// Deselects all items in the . /// - public new void UnselectAll() => base.UnselectAll(); + public void UnselectAll() => Selection.ClearSelection(); /// protected override IItemContainerGenerator CreateItemContainerGenerator() diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 6bc4e71508..b1a4379cae 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -5,15 +5,15 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.ComponentModel; using System.Diagnostics; using System.Linq; -using Avalonia.Collections; using Avalonia.Controls.Generators; +using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; -using Avalonia.Logging; using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives @@ -26,9 +26,9 @@ namespace Avalonia.Controls.Primitives /// provides a base class for s /// that maintain a selection (single or multiple). By default only its /// and properties are visible; the - /// current multiple selection together with the - /// properties are protected, however a derived class can expose - /// these if it wishes to support multiple selection. + /// current multiple and together with the + /// and properties are protected, however a derived class can + /// expose these if it wishes to support multiple selection. /// /// /// maintains a selection respecting the current @@ -77,6 +77,15 @@ namespace Avalonia.Controls.Primitives o => o.SelectedItems, (o, v) => o.SelectedItems = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty SelectionProperty = + AvaloniaProperty.RegisterDirect( + nameof(Selection), + o => o.Selection, + (o, v) => o.Selection = v); + /// /// Defines the property. /// @@ -103,17 +112,22 @@ namespace Avalonia.Controls.Primitives RoutingStrategies.Bubble); private static readonly IList Empty = Array.Empty(); - - private readonly Selection _selection = new Selection(); + private readonly SelectedItemsSync _selectedItems; + private ISelectionModel _selection; private int _selectedIndex = -1; private object _selectedItem; - private IList _selectedItems; private bool _ignoreContainerSelectionChanged; - private bool _syncingSelectedItems; private int _updateCount; private int _updateSelectedIndex; private object _updateSelectedItem; + public SelectingItemsControl() + { + // Setting Selection to null causes a default SelectionModel to be created. + Selection = null; + _selectedItems = new SelectedItemsSync(Selection); + } + /// /// Initializes static members of the class. /// @@ -145,17 +159,15 @@ namespace Avalonia.Controls.Primitives /// public int SelectedIndex { - get - { - return _selectedIndex; - } - + get => Selection.SelectedIndex != default ? Selection.SelectedIndex.GetAt(0) : -1; set { if (_updateCount == 0) { - var effective = (value >= 0 && value < ItemCount) ? value : -1; - UpdateSelectedItem(effective); + if (value != SelectedIndex) + { + Selection.SelectedIndex = new IndexPath(value); + } } else { @@ -170,16 +182,12 @@ namespace Avalonia.Controls.Primitives /// public object SelectedItem { - get - { - return _selectedItem; - } - + get => Selection.SelectedItem; set { if (_updateCount == 0) { - UpdateSelectedItem(IndexOf(Items, value)); + SelectedIndex = IndexOf(Items, value); } else { @@ -190,32 +198,110 @@ namespace Avalonia.Controls.Primitives } /// - /// Gets the selected items. + /// Gets or sets the selected items. /// protected IList SelectedItems { - get - { - if (_selectedItems == null) - { - _selectedItems = new AvaloniaList(); - SubscribeToSelectedItems(); - } - - return _selectedItems; - } + get => _selectedItems.GetOrCreateItems(); + set => _selectedItems.SetItems(value); + } + /// + /// Gets or sets a model holding the current selection. + /// + protected ISelectionModel Selection + { + get => _selection; set { - if (value?.IsFixedSize == true || value?.IsReadOnly == true) + value ??= new SelectionModel { - throw new NotSupportedException( - "Cannot use a fixed size or read-only collection as SelectedItems."); - } + SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), + AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected), + RetainSelectionOnReset = true, + }; + + if (_selection != value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value), "Cannot set Selection to null."); + } + else if (value.Source != null && value.Source != Items) + { + throw new ArgumentException("Selection has invalid Source."); + } + + List oldSelection = null; + + if (_selection != null) + { + oldSelection = Selection.SelectedItems.ToList(); + _selection.PropertyChanged -= OnSelectionModelPropertyChanged; + _selection.SelectionChanged -= OnSelectionModelSelectionChanged; + MarkContainersUnselected(); + } + + _selection = value; - UnsubscribeFromSelectedItems(); - _selectedItems = value ?? new AvaloniaList(); - SubscribeToSelectedItems(); + if (oldSelection?.Count > 0) + { + RaiseEvent(new SelectionChangedEventArgs( + SelectionChangedEvent, + oldSelection, + Array.Empty())); + } + + if (_selection != null) + { + _selection.Source = Items; + _selection.PropertyChanged += OnSelectionModelPropertyChanged; + _selection.SelectionChanged += OnSelectionModelSelectionChanged; + + if (_selection.SingleSelect) + { + SelectionMode &= ~SelectionMode.Multiple; + } + else + { + SelectionMode |= SelectionMode.Multiple; + } + + if (_selection.AutoSelect) + { + SelectionMode |= SelectionMode.AlwaysSelected; + } + else + { + SelectionMode &= ~SelectionMode.AlwaysSelected; + } + + UpdateContainerSelection(); + + var selectedIndex = SelectedIndex; + var selectedItem = SelectedItem; + + if (_selectedIndex != selectedIndex) + { + RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, selectedIndex); + _selectedIndex = selectedIndex; + } + + if (_selectedItem != selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem); + _selectedItem = selectedItem; + } + + if (selectedIndex != -1) + { + RaiseEvent(new SelectionChangedEventArgs( + SelectionChangedEvent, + Array.Empty(), + Selection.SelectedItems.ToList())); + } + } + } } } @@ -285,81 +371,18 @@ namespace Avalonia.Controls.Primitives /// protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) { - base.ItemsChanged(e); - if (_updateCount == 0) { - var newIndex = -1; - - if (SelectedIndex != -1) - { - newIndex = IndexOf((IEnumerable)e.NewValue, SelectedItem); - } - - if (AlwaysSelected && Items != null && Items.Cast().Any()) - { - newIndex = 0; - } - - SelectedIndex = newIndex; + Selection.Source = e.NewValue; } + + base.ItemsChanged(e); } /// protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { - if (_updateCount > 0) - { - base.ItemsCollectionChanged(sender, e); - return; - } - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - _selection.ItemsInserted(e.NewStartingIndex, e.NewItems.Count); - break; - case NotifyCollectionChangedAction.Remove: - _selection.ItemsRemoved(e.OldStartingIndex, e.OldItems.Count); - break; - } - base.ItemsCollectionChanged(sender, e); - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - if (AlwaysSelected && SelectedIndex == -1) - { - SelectedIndex = 0; - } - else - { - UpdateSelectedItem(_selection.First(), false); - } - - break; - - case NotifyCollectionChangedAction.Remove: - UpdateSelectedItem(_selection.First(), false); - ResetSelectedItems(); - break; - - case NotifyCollectionChangedAction.Replace: - UpdateSelectedItem(SelectedIndex, false); - ResetSelectedItems(); - break; - - case NotifyCollectionChangedAction.Move: - case NotifyCollectionChangedAction.Reset: - SelectedIndex = IndexOf(Items, SelectedItem); - - if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0) - { - SelectedIndex = 0; - } - break; - } } /// @@ -367,36 +390,18 @@ namespace Avalonia.Controls.Primitives { base.OnContainersMaterialized(e); - var resetSelectedItems = false; - foreach (var container in e.Containers) { if ((container.ContainerControl as ISelectable)?.IsSelected == true) { - if (SelectionMode.HasFlag(SelectionMode.Multiple)) - { - if (_selection.Add(container.Index)) - { - resetSelectedItems = true; - } - } - else - { - SelectedIndex = container.Index; - } - + Selection.Select(container.Index); MarkContainerSelected(container.ContainerControl, true); } - else if (_selection.Contains(container.Index)) + else if (Selection.IsSelected(container.Index) == true) { MarkContainerSelected(container.ContainerControl, true); } } - - if (resetSelectedItems) - { - ResetSelectedItems(); - } } /// @@ -425,7 +430,7 @@ namespace Avalonia.Controls.Primitives { if (i.ContainerControl != null && i.Item != null) { - bool selected = _selection.Contains(i.Index); + bool selected = Selection.IsSelected(i.Index) == true; MarkContainerSelected(i.ContainerControl, selected); } } @@ -447,6 +452,18 @@ namespace Avalonia.Controls.Primitives InternalEndInit(); } + protected override void OnPropertyChanged(AvaloniaProperty property, Optional oldValue, BindingValue newValue, BindingPriority priority) + { + base.OnPropertyChanged(property, oldValue, newValue, priority); + + if (property == SelectionModeProperty) + { + var mode = newValue.GetValueOrDefault(); + Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple); + Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected); + } + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); @@ -461,7 +478,7 @@ namespace Avalonia.Controls.Primitives (((SelectionMode & SelectionMode.Multiple) != 0) || (SelectionMode & SelectionMode.Toggle) != 0)) { - SelectAll(); + Selection.SelectAll(); e.Handled = true; } } @@ -503,36 +520,6 @@ namespace Avalonia.Controls.Primitives return false; } - /// - /// Selects all items in the control. - /// - protected void SelectAll() - { - UpdateSelectedItems(() => - { - _selection.Clear(); - - for (var i = 0; i < ItemCount; ++i) - { - _selection.Add(i); - } - - UpdateSelectedItem(0, false); - - foreach (var container in ItemContainerGenerator.Containers) - { - MarkItemSelected(container.Index, true); - } - - ResetSelectedItems(); - }); - } - - /// - /// Deselects all items in the control. - /// - protected void UnselectAll() => UpdateSelectedItem(-1); - /// /// Updates the selection for an item based on user interaction. /// @@ -559,63 +546,35 @@ namespace Avalonia.Controls.Primitives if (rightButton) { - if (!_selection.Contains(index)) + if (Selection.IsSelected(index) == false) { - UpdateSelectedItem(index); + SelectedIndex = index; } } else if (range) { - UpdateSelectedItems(() => - { - var start = SelectedIndex != -1 ? SelectedIndex : 0; - var step = start < index ? 1 : -1; - - _selection.Clear(); + using var operation = Selection.Update(); + var anchor = Selection.AnchorIndex; - for (var i = start; i != index; i += step) - { - _selection.Add(i); - } - - _selection.Add(index); - - var first = Math.Min(start, index); - var last = Math.Max(start, index); - - foreach (var container in ItemContainerGenerator.Containers) - { - MarkItemSelected( - container.Index, - container.Index >= first && container.Index <= last); - } + if (anchor.GetSize() == 0) + { + anchor = new IndexPath(0); + } - ResetSelectedItems(); - }); + Selection.ClearSelection(); + Selection.AnchorIndex = anchor; + Selection.SelectRangeFromAnchor(index); } else if (multi && toggle) { - UpdateSelectedItems(() => + if (Selection.IsSelected(index) == true) { - if (!_selection.Contains(index)) - { - _selection.Add(index); - MarkItemSelected(index, true); - SelectedItems.Add(ElementAt(Items, index)); - } - else - { - _selection.Remove(index); - MarkItemSelected(index, false); - - if (index == _selectedIndex) - { - UpdateSelectedItem(_selection.First(), false); - } - - SelectedItems.Remove(ElementAt(Items, index)); - } - }); + Selection.Deselect(index); + } + else + { + Selection.Select(index); + } } else if (toggle) { @@ -623,7 +582,9 @@ namespace Avalonia.Controls.Primitives } else { - UpdateSelectedItem(index); + using var operation = Selection.Update(); + Selection.ClearSelection(); + Selection.Select(index); } if (Presenter?.Panel != null) @@ -696,25 +657,71 @@ namespace Avalonia.Controls.Primitives } /// - /// Gets a range of items from an IEnumerable. + /// Called when is raised. /// - /// The items. - /// The index of the first item. - /// The index of the last item. - /// The items. - private static List GetRange(IEnumerable items, int first, int last) + /// The sender. + /// The event args. + private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) { - var list = (items as IList) ?? items.Cast().ToList(); - var step = first > last ? -1 : 1; - var result = new List(); + if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) + { + var index = Selection.AnchorIndex.GetSize() > 0 ? Selection.AnchorIndex.GetAt(0) : -1; + var item = index != -1 ? ElementAt(Items, index) : null; + + if (item != null) + { + ScrollIntoView(item); + } + } + } + + /// + /// Called when is raised. + /// + /// The sender. + /// The event args. + private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + void Mark(int index, bool selected) + { + var container = ItemContainerGenerator.ContainerFromIndex(index); + + if (container != null) + { + MarkContainerSelected(container, selected); + } + } + + foreach (var i in e.SelectedIndices) + { + Mark(i.GetAt(0), true); + } - for (int i = first; i != last; i += step) + foreach (var i in e.DeselectedIndices) { - result.Add(list[i]); + Mark(i.GetAt(0), false); } - result.Add(list[last]); - return result; + var newSelectedIndex = SelectedIndex; + var newSelectedItem = SelectedItem; + + if (newSelectedIndex != _selectedIndex) + { + RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, newSelectedIndex); + _selectedIndex = newSelectedIndex; + } + + if (newSelectedItem != _selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem); + _selectedItem = newSelectedItem; + } + + var ev = new SelectionChangedEventArgs( + SelectionChangedEvent, + e.DeselectedItems.ToList(), + e.SelectedItems.ToList()); + RaiseEvent(ev); } /// @@ -794,301 +801,43 @@ namespace Avalonia.Controls.Primitives } } - /// - /// Sets an item container's 'selected' class or . - /// - /// The index of the item. - /// Whether the item should be selected or deselected. - private void MarkItemSelected(int index, bool selected) + private void MarkContainersUnselected() { - var container = ItemContainerGenerator?.ContainerFromIndex(index); - - if (container != null) + foreach (var container in ItemContainerGenerator.Containers) { - MarkContainerSelected(container, selected); + MarkContainerSelected(container.ContainerControl, false); } } - /// - /// Sets an item container's 'selected' class or . - /// - /// The item. - /// Whether the item should be selected or deselected. - private int MarkItemSelected(object item, bool selected) + private void UpdateContainerSelection() { - var index = IndexOf(Items, item); - - if (index != -1) - { - MarkItemSelected(index, selected); - } - - return index; - } - - private void ResetSelectedItems() - { - UpdateSelectedItems(() => - { - SelectedItems.Clear(); - - foreach (var i in _selection) - { - SelectedItems.Add(ElementAt(Items, i)); - } - }); - } - - /// - /// Called when the CollectionChanged event is raised. - /// - /// The event sender. - /// The event args. - private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - if (_syncingSelectedItems) - { - return; - } - - void Add(IList newItems, IList addedItems = null) - { - foreach (var item in newItems) - { - var index = MarkItemSelected(item, true); - - if (index != -1 && _selection.Add(index) && addedItems != null) - { - addedItems.Add(item); - } - } - } - - void UpdateSelection() - { - if ((SelectedIndex != -1 && !_selection.Contains(SelectedIndex)) || - (SelectedIndex == -1 && _selection.HasItems)) - { - _selectedIndex = _selection.First(); - _selectedItem = ElementAt(Items, _selectedIndex); - RaisePropertyChanged(SelectedIndexProperty, -1, _selectedIndex, BindingPriority.LocalValue); - RaisePropertyChanged(SelectedItemProperty, null, _selectedItem, BindingPriority.LocalValue); - } - } - - IList added = null; - IList removed = null; - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - { - Add(e.NewItems); - UpdateSelection(); - added = e.NewItems; - } - - break; - - case NotifyCollectionChangedAction.Remove: - if (SelectedItems.Count == 0) - { - SelectedIndex = -1; - } - - foreach (var item in e.OldItems) - { - var index = MarkItemSelected(item, false); - _selection.Remove(index); - } - - removed = e.OldItems; - break; - - case NotifyCollectionChangedAction.Replace: - throw new NotSupportedException("Replacing items in a SelectedItems collection is not supported."); - - case NotifyCollectionChangedAction.Move: - throw new NotSupportedException("Moving items in a SelectedItems collection is not supported."); - - case NotifyCollectionChangedAction.Reset: - { - removed = new List(); - added = new List(); - - foreach (var index in _selection.ToList()) - { - var item = ElementAt(Items, index); - - if (!SelectedItems.Contains(item)) - { - MarkItemSelected(index, false); - removed.Add(item); - _selection.Remove(index); - } - } - - Add(SelectedItems, added); - UpdateSelection(); - } - - break; - } - - if (added?.Count > 0 || removed?.Count > 0) - { - var changed = new SelectionChangedEventArgs( - SelectionChangedEvent, - removed ?? Empty, - added ?? Empty); - RaiseEvent(changed); - } - } - - /// - /// Subscribes to the CollectionChanged event, if any. - /// - private void SubscribeToSelectedItems() - { - var incc = _selectedItems as INotifyCollectionChanged; - - if (incc != null) - { - incc.CollectionChanged += SelectedItemsCollectionChanged; - } - - SelectedItemsCollectionChanged( - _selectedItems, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - /// - /// Unsubscribes from the CollectionChanged event, if any. - /// - private void UnsubscribeFromSelectedItems() - { - var incc = _selectedItems as INotifyCollectionChanged; - - if (incc != null) + foreach (var container in ItemContainerGenerator.Containers) { - incc.CollectionChanged -= SelectedItemsCollectionChanged; + MarkContainerSelected( + container.ContainerControl, + Selection.IsSelected(container.Index) != false); } } /// - /// Updates the selection due to a change to or - /// . + /// Sets an item container's 'selected' class or . /// - /// The new selected index. - /// Whether to clear existing selection. - private void UpdateSelectedItem(int index, bool clear = true) + /// The index of the item. + /// Whether the item should be selected or deselected. + private void MarkItemSelected(int index, bool selected) { - var oldIndex = _selectedIndex; - var oldItem = _selectedItem; - - if (index == -1 && AlwaysSelected) - { - index = Math.Min(SelectedIndex, ItemCount - 1); - } - - var item = ElementAt(Items, index); - var itemChanged = !Equals(item, oldItem); - var added = -1; - HashSet removed = null; - - _selectedIndex = index; - _selectedItem = item; - - if (oldIndex != index || itemChanged || _selection.HasMultiple) - { - if (clear) - { - removed = _selection.Clear(); - } - - if (index != -1) - { - if (_selection.Add(index)) - { - added = index; - } - - if (removed?.Contains(index) == true) - { - removed.Remove(index); - added = -1; - } - } - - if (removed != null) - { - foreach (var i in removed) - { - MarkItemSelected(i, false); - } - } - - MarkItemSelected(index, true); - - RaisePropertyChanged( - SelectedIndexProperty, - oldIndex, - index); - } - - if (itemChanged) - { - RaisePropertyChanged( - SelectedItemProperty, - oldItem, - item); - } - - if (removed != null && index != -1) - { - removed.Remove(index); - } - - if (added != -1 || removed?.Count > 0) - { - ResetSelectedItems(); - - var e = new SelectionChangedEventArgs( - SelectionChangedEvent, - removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty(), - added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty()); - RaiseEvent(e); - } - - if (AutoScrollToSelectedItem && _selectedIndex != -1) - { - ScrollIntoView(_selectedItem); - } - } + var container = ItemContainerGenerator?.ContainerFromIndex(index); - private void UpdateSelectedItems(Action action) - { - try - { - _syncingSelectedItems = true; - action(); - } - catch (Exception ex) - { - Logger.TryGet(LogEventLevel.Error)?.Log( - LogArea.Property, - this, - "Error thrown updating SelectedItems: {Error}", - ex); - } - finally + if (container != null) { - _syncingSelectedItems = false; + MarkContainerSelected(container, selected); } } private void UpdateFinished() { + Selection.Source = Items; + if (_updateSelectedItem != null) { SelectedItem = _updateSelectedItem; @@ -1133,104 +882,5 @@ namespace Avalonia.Controls.Primitives UpdateFinished(); } } - - private class Selection : IEnumerable - { - private readonly List _list = new List(); - private HashSet _set = new HashSet(); - - public bool HasItems => _set.Count > 0; - public bool HasMultiple => _set.Count > 1; - - public bool Add(int index) - { - if (index == -1) - { - throw new ArgumentException("Invalid index", "index"); - } - - if (_set.Add(index)) - { - _list.Add(index); - return true; - } - - return false; - } - - public bool Remove(int index) - { - if (_set.Remove(index)) - { - _list.RemoveAll(x => x == index); - return true; - } - - return false; - } - - public HashSet Clear() - { - var result = _set; - _list.Clear(); - _set = new HashSet(); - return result; - } - - public void ItemsInserted(int index, int count) - { - _set = new HashSet(); - - for (var i = 0; i < _list.Count; ++i) - { - var ix = _list[i]; - - if (ix >= index) - { - var newIndex = ix + count; - _list[i] = newIndex; - _set.Add(newIndex); - } - else - { - _set.Add(ix); - } - } - } - - public void ItemsRemoved(int index, int count) - { - var last = (index + count) - 1; - - _set = new HashSet(); - - for (var i = 0; i < _list.Count; ++i) - { - var ix = _list[i]; - - if (ix >= index && ix <= last) - { - _list.RemoveAt(i--); - } - else if (ix > last) - { - var newIndex = ix - count; - _list[i] = newIndex; - _set.Add(newIndex); - } - else - { - _set.Add(ix); - } - } - } - - public bool Contains(int index) => _set.Contains(index); - - public int First() => HasItems ? _list[0] : -1; - - public IEnumerator GetEnumerator() => _set.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } } } diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 0531174454..5eb2a2da0a 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -176,11 +176,12 @@ namespace Avalonia.Controls { var isSelected = IsSelectedWithPartialAt(value); - if (!isSelected.HasValue || !isSelected.Value) + if (!IsSelectedAt(value) || SelectedItems.Count > 1) { using var operation = new Operation(this); ClearSelection(resetAnchor: true); SelectWithPathImpl(value, select: true); + ApplyAutoSelect(); } } } diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs index 3d6c88cd99..c127771990 100644 --- a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -19,6 +19,7 @@ namespace Avalonia.Controls.Utils public SelectedItemsSync(ISelectionModel model) { + model = model ?? throw new ArgumentNullException(nameof(model)); Model = model; } @@ -37,9 +38,9 @@ namespace Avalonia.Controls.Utils return _items; } - public void SetItems(IList items) + public void SetItems(IList? items) { - items = items ?? throw new ArgumentNullException(nameof(items)); + items ??= new AvaloniaList(); if (items.IsFixedSize) { diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs index b16ac6bb8e..8de762a99b 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs @@ -275,7 +275,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Selected_Item_Changes_To_NextAvailable_Item_If_SelectedItem_Is_Removed_From_Middle() + public void Selected_Item_Changes_To_First_Item_If_SelectedItem_Is_Removed_From_Middle() { var items = new ObservableCollection { @@ -298,8 +298,8 @@ namespace Avalonia.Controls.UnitTests items.RemoveAt(1); - Assert.Equal(1, target.SelectedIndex); - Assert.Equal("FooBar", target.SelectedItem); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Foo", target.SelectedItem); } private Control CreateTemplate(Carousel control, INameScope scope) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 8c16dd0f70..f384fcc128 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -554,33 +554,6 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { removed }, receivedArgs.RemovedItems); } - [Fact] - public void Moving_Selected_Item_Should_Update_Selection() - { - var items = new AvaloniaList - { - new Item(), - new Item(), - }; - - var target = new SelectingItemsControl - { - Items = items, - Template = Template(), - }; - - target.ApplyTemplate(); - target.SelectedIndex = 0; - - Assert.Equal(items[0], target.SelectedItem); - Assert.Equal(0, target.SelectedIndex); - - items.Move(0, 1); - - Assert.Equal(items[1], target.SelectedItem); - Assert.Equal(1, target.SelectedIndex); - } - [Fact] public void Resetting_Items_Collection_Should_Clear_Selection() { @@ -1101,8 +1074,8 @@ namespace Avalonia.Controls.UnitTests.Primitives items[1] = "Qux"; - Assert.Equal(1, target.SelectedIndex); - Assert.Equal("Qux", target.SelectedItem); + Assert.Equal(-1, target.SelectedIndex); + Assert.Null(target.SelectedItem); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs index a7010c521b..eb6b10fb44 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs @@ -78,8 +78,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedIndex = 2; items.RemoveAt(2); - Assert.Equal(2, target.SelectedIndex); - Assert.Equal("qux", target.SelectedItem); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("foo", target.SelectedItem); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 3a8c98983f..ed5c94517a 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -73,8 +73,6 @@ namespace Avalonia.Controls.UnitTests.Primitives [Fact] public void Assigning_Multiple_SelectedItems_Should_Set_SelectedIndex() { - // Note that we don't need SelectionMode = Multiple here. Multiple selections can always - // be made in code. var target = new TestSelector { Items = new[] { "foo", "bar", "baz" }, @@ -340,7 +338,6 @@ namespace Avalonia.Controls.UnitTests.Primitives "qiz", "lol", }, - SelectionMode = SelectionMode.Multiple, Template = Template(), }; @@ -373,7 +370,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedIndex = 3; target.SelectRange(1); - Assert.Equal(new[] { "qux", "baz", "bar" }, target.SelectedItems.Cast().ToList()); + Assert.Equal(new[] { "bar", "baz", "qux" }, target.SelectedItems.Cast().ToList()); } [Fact] @@ -1117,7 +1114,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectAll(); items[1] = "Qux"; - Assert.Equal(new[] { "Foo", "Qux", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { "Foo", "Baz" }, target.SelectedItems); } [Fact] @@ -1255,6 +1252,195 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(1, target.SelectedItems.Count); } + [Fact] + public void Adding_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Selection.Select(1); + + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Assigning_Null_To_Selection_Should_Create_New_SelectionModel() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var oldSelection = target.Selection; + + target.Selection = null; + + Assert.NotNull(target.Selection); + Assert.NotSame(oldSelection, target.Selection); + } + + [Fact] + public void Assigning_SelectionModel_With_Different_Source_To_Selection_Should_Fail() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var selection = new SelectionModel { Source = new[] { "baz" } }; + Assert.Throws(() => target.Selection = selection); + } + + [Fact] + public void Assigning_SelectionModel_With_Null_Source_To_Selection_Should_Set_Source() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var selection = new SelectionModel(); + target.Selection = selection; + + Assert.Same(target.Items, selection.Source); + } + + [Fact] + public void Assigning_Single_Selected_Item_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = target.Items }; + selection.Select(1); + target.Selection = selection; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { "bar" }, target.Selection.SelectedItems); + Assert.Equal(new[] { 1 }, SelectedContainers(target)); + } + + [Fact] + public void Assigning_Multiple_Selected_Items_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar", "baz" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = target.Items }; + selection.SelectRange(new IndexPath(0), new IndexPath(2)); + target.Selection = selection; + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { "foo", "bar", "baz" }, target.Selection.SelectedItems); + Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); + } + + [Fact] + public void Reassigning_Selection_Should_Clear_Selection() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Selection.Select(1); + target.Selection = new SelectionModel(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Null(target.SelectedItem); + } + + [Fact] + public void Assigning_Selection_Should_Set_Item_IsSelected() + { + var items = new[] + { + new ListBoxItem(), + new ListBoxItem(), + new ListBoxItem(), + }; + + var target = new TestSelector + { + Items = items, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = items }; + selection.SelectRange(new IndexPath(0), new IndexPath(1)); + target.Selection = selection; + + Assert.True(items[0].IsSelected); + Assert.True(items[1].IsSelected); + Assert.False(items[2].IsSelected); + } + + [Fact] + public void Assigning_Selection_Should_Raise_SelectionChanged() + { + var items = new[] { "foo", "bar", "baz" }; + + var target = new TestSelector + { + Items = items, + Template = Template(), + SelectedItem = "bar", + }; + + var raised = 0; + + target.SelectionChanged += (s, e) => + { + if (raised == 0) + { + Assert.Empty(e.AddedItems.Cast()); + Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast()); + } + else + { + Assert.Equal(new[] { "foo", "baz" }, e.AddedItems.Cast()); + Assert.Empty(e.RemovedItems.Cast()); + } + + ++raised; + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + + var selection = new SelectionModel { Source = items }; + selection.Select(0); + selection.Select(2); + target.Selection = selection; + + Assert.Equal(2, raised); + } + private IEnumerable SelectedContainers(SelectingItemsControl target) { return target.Presenter.Panel.Children @@ -1278,20 +1464,31 @@ namespace Avalonia.Controls.UnitTests.Primitives public static readonly new AvaloniaProperty SelectedItemsProperty = SelectingItemsControl.SelectedItemsProperty; + public TestSelector() + { + SelectionMode = SelectionMode.Multiple; + } + public new IList SelectedItems { get { return base.SelectedItems; } set { base.SelectedItems = value; } } + public new ISelectionModel Selection + { + get => base.Selection; + set => base.Selection = value; + } + public new SelectionMode SelectionMode { get { return base.SelectionMode; } set { base.SelectionMode = value; } } - public new void SelectAll() => base.SelectAll(); - public new void UnselectAll() => base.UnselectAll(); + public void SelectAll() => Selection.SelectAll(); + public void UnselectAll() => Selection.ClearSelection(); public void SelectRange(int index) => UpdateSelection(index, true, true); public void Toggle(int index) => UpdateSelection(index, true, false, true); } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs index b4570ec229..707723f809 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs @@ -70,7 +70,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void Removing_Selected_Should_Select_Next() + public void Removing_Selected_Should_Select_First() { var items = new ObservableCollection() { @@ -99,10 +99,9 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Same(items[1], target.SelectedItem); items.RemoveAt(1); - // Assert for former element [2] now [1] == "3rd" - Assert.Equal(1, target.SelectedIndex); - Assert.Same(items[1], target.SelectedItem); - Assert.Same("3rd", ((TabItem)target.SelectedItem).Name); + Assert.Equal(0, target.SelectedIndex); + Assert.Same(items[0], target.SelectedItem); + Assert.Same("first", ((TabItem)target.SelectedItem).Name); } private Control CreateTabStripTemplate(TabStrip parent, INameScope scope) diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index a9e86d71ee..d6d5428434 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -95,7 +95,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Removal_Should_Set_Next_Tab() + public void Removal_Should_Set_First_Tab() { var collection = new ObservableCollection() { @@ -126,11 +126,9 @@ namespace Avalonia.Controls.UnitTests target.SelectedItem = collection[1]; collection.RemoveAt(1); - // compare with former [2] now [1] == "3rd" - Assert.Same(collection[1], target.SelectedItem); + Assert.Same(collection[0], target.SelectedItem); } - [Fact] public void TabItem_Templates_Should_Be_Set_Before_TabItem_ApplyTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs index 917f422557..3ab5950974 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -169,6 +169,19 @@ namespace Avalonia.Controls.UnitTests.Utils Assert.Equal(new[] { "foo", "baz", "bar" }, items); } + [Fact] + public void Setting_Items_To_Null_Creates_Empty_Items() + { + var target = CreateTarget(); + var oldItems = target.GetOrCreateItems(); + + target.SetItems(null); + + var newItems = Assert.IsType>(target.GetOrCreateItems()); + + Assert.NotSame(oldItems, newItems); + } + [Fact] public void Handles_Null_Model_Source() { From c4afd15d900f639994d479f20bebd18f8f83c0b5 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 12:29:43 +0100 Subject: [PATCH 45/52] Use SelectionModel in TreeView. --- .../ControlCatalog/Pages/TreeViewPage.xaml | 2 +- .../ControlCatalog/Pages/TreeViewPage.xaml.cs | 17 +- src/Avalonia.Controls/TreeView.cs | 663 ++++++++---------- .../TreeViewTests.cs | 17 +- 4 files changed, 315 insertions(+), 384 deletions(-) diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml b/samples/ControlCatalog/Pages/TreeViewPage.xaml index 3a81e2ed02..c9e3fafb6d 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml @@ -10,7 +10,7 @@ HorizontalAlignment="Center" Spacing="16"> - + diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs b/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs index 1f35f05f1d..5893796b8b 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs @@ -28,21 +28,22 @@ namespace ControlCatalog.Pages { Node root = new Node(); Items = root.Children; - SelectedItems = new ObservableCollection(); + Selection = new SelectionModel(); AddItemCommand = ReactiveCommand.Create(() => { - Node parentItem = SelectedItems.Count > 0 ? SelectedItems[0] : root; + Node parentItem = Selection.SelectedItems.Count > 0 ? + (Node)Selection.SelectedItems[0] : root; parentItem.AddNewItem(); }); RemoveItemCommand = ReactiveCommand.Create(() => { - while (SelectedItems.Count > 0) + while (Selection.SelectedItems.Count > 0) { - Node lastItem = SelectedItems[0]; + Node lastItem = (Node)Selection.SelectedItems[0]; RecursiveRemove(Items, lastItem); - SelectedItems.Remove(lastItem); + Selection.DeselectAt(Selection.SelectedIndices[0]); } bool RecursiveRemove(ObservableCollection items, Node selectedItem) @@ -67,7 +68,7 @@ namespace ControlCatalog.Pages public ObservableCollection Items { get; } - public ObservableCollection SelectedItems { get; } + public SelectionModel Selection { get; } public ReactiveCommand AddItemCommand { get; } @@ -78,7 +79,7 @@ namespace ControlCatalog.Pages get => _selectionMode; set { - SelectedItems.Clear(); + Selection.ClearSelection(); this.RaiseAndSetIfChanged(ref _selectionMode, value); } } @@ -109,7 +110,7 @@ namespace ControlCatalog.Pages public override string ToString() => Header; - private Node CreateNewNode() => new Node {Header = $"Item {_counter++}"}; + private Node CreateNewNode() => new Node { Header = $"Item {_counter++}" }; } } } diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 3a7ad97763..19d378404e 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -5,11 +5,12 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; -using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Utils; +using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; @@ -45,15 +46,29 @@ namespace Avalonia.Controls o => o.SelectedItems, (o, v) => o.SelectedItems = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty SelectionProperty = + SelectingItemsControl.SelectionProperty.AddOwner( + o => o.Selection, + (o, v) => o.Selection = v); + /// /// Defines the property. /// public static readonly StyledProperty SelectionModeProperty = ListBox.SelectionModeProperty.AddOwner(); - private static readonly IList Empty = Array.Empty(); + /// + /// Defines the property. + /// + public static RoutedEvent SelectionChangedEvent = + SelectingItemsControl.SelectionChangedEvent; + private object _selectedItem; - private IList _selectedItems; + private ISelectionModel _selection; + private readonly SelectedItemsSync _selectedItems; /// /// Initializes static members of the class. @@ -63,6 +78,13 @@ namespace Avalonia.Controls // HACK: Needed or SelectedItem property will not be found in Release build. } + public TreeView() + { + // Setting Selection to null causes a default SelectionModel to be created. + Selection = null; + _selectedItems = new SelectedItemsSync(Selection); + } + /// /// Occurs when the control's selection changes. /// @@ -87,8 +109,6 @@ namespace Avalonia.Controls set => SetValue(AutoScrollToSelectedItemProperty, value); } - private bool _syncingSelectedItems; - /// /// Gets or sets the selection mode. /// @@ -98,61 +118,102 @@ namespace Avalonia.Controls set => SetValue(SelectionModeProperty, value); } + /// + /// Gets or sets the selected item. + /// /// /// Gets or sets the selected item. /// public object SelectedItem { - get => _selectedItem; - set - { - var selectedItems = SelectedItems; - - SetAndRaise(SelectedItemProperty, ref _selectedItem, value); + get => Selection.SelectedItem; + set => Selection.SelectedIndex = IndexFromItem(value); + } - if (value != null) - { - if (selectedItems.Count != 1 || selectedItems[0] != value) - { - _syncingSelectedItems = true; - SelectSingleItem(value); - _syncingSelectedItems = false; - } - } - else if (SelectedItems.Count > 0) - { - SelectedItems.Clear(); - } - } + /// + /// Gets or sets the selected items. + /// + protected IList SelectedItems + { + get => _selectedItems.GetOrCreateItems(); + set => _selectedItems.SetItems(value); } /// - /// Gets the selected items. + /// Gets or sets a model holding the current selection. /// - public IList SelectedItems + public ISelectionModel Selection { - get + get => _selection; + set { - if (_selectedItems == null) + value ??= new SelectionModel { - _selectedItems = new AvaloniaList(); - SubscribeToSelectedItems(); - } - - return _selectedItems; - } + SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), + AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected), + RetainSelectionOnReset = true, + }; - set - { - if (value?.IsFixedSize == true || value?.IsReadOnly == true) + if (_selection != value) { - throw new NotSupportedException( - "Cannot use a fixed size or read-only collection as SelectedItems."); - } + if (value == null) + { + throw new ArgumentNullException(nameof(value), "Cannot set Selection to null."); + } + else if (value.Source != null && value.Source != Items) + { + throw new ArgumentException("Selection has invalid Source."); + } + + List oldSelection = null; + + if (_selection != null) + { + oldSelection = Selection.SelectedItems.ToList(); + _selection.PropertyChanged -= OnSelectionModelPropertyChanged; + _selection.SelectionChanged -= OnSelectionModelSelectionChanged; + _selection.ChildrenRequested -= OnSelectionModelChildrenRequested; + MarkContainersUnselected(); + } + + _selection = value; + + if (_selection != null) + { + _selection.Source = Items; + _selection.PropertyChanged += OnSelectionModelPropertyChanged; + _selection.SelectionChanged += OnSelectionModelSelectionChanged; + _selection.ChildrenRequested += OnSelectionModelChildrenRequested; - UnsubscribeFromSelectedItems(); - _selectedItems = value ?? new AvaloniaList(); - SubscribeToSelectedItems(); + if (_selection.SingleSelect) + { + SelectionMode &= ~SelectionMode.Multiple; + } + else + { + SelectionMode |= SelectionMode.Multiple; + } + + if (_selection.AutoSelect) + { + SelectionMode |= SelectionMode.AlwaysSelected; + } + else + { + SelectionMode &= ~SelectionMode.AlwaysSelected; + } + + UpdateContainerSelection(); + + var selectedItem = SelectedItem; + + if (_selectedItem != selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem); + _selectedItem = selectedItem; + } + } + } } } @@ -185,186 +246,12 @@ namespace Avalonia.Controls /// Note that this method only selects nodes currently visible due to their parent nodes /// being expanded: it does not expand nodes. /// - public void SelectAll() - { - SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items); - } + public void SelectAll() => Selection.SelectAll(); /// /// Deselects all items in the . /// - public void UnselectAll() - { - SelectedItems.Clear(); - } - - /// - /// Subscribes to the CollectionChanged event, if any. - /// - private void SubscribeToSelectedItems() - { - if (_selectedItems is INotifyCollectionChanged incc) - { - incc.CollectionChanged += SelectedItemsCollectionChanged; - } - - SelectedItemsCollectionChanged( - _selectedItems, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - private void SelectSingleItem(object item) - { - SelectedItems.Clear(); - SelectedItems.Add(item); - } - - /// - /// Called when the CollectionChanged event is raised. - /// - /// The event sender. - /// The event args. - private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - IList added = null; - IList removed = null; - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - - SelectedItemsAdded(e.NewItems.Cast().ToArray()); - - if (AutoScrollToSelectedItem) - { - var container = (TreeViewItem)ItemContainerGenerator.Index.ContainerFromItem(e.NewItems[0]); - - container?.BringIntoView(); - } - - added = e.NewItems; - - break; - case NotifyCollectionChangedAction.Remove: - - if (!_syncingSelectedItems) - { - if (SelectedItems.Count == 0) - { - SelectedItem = null; - } - else - { - var selectedIndex = SelectedItems.IndexOf(_selectedItem); - - if (selectedIndex == -1) - { - var old = _selectedItem; - _selectedItem = SelectedItems[0]; - - RaisePropertyChanged(SelectedItemProperty, old, _selectedItem); - } - } - } - - foreach (var item in e.OldItems) - { - MarkItemSelected(item, false); - } - - removed = e.OldItems; - - break; - case NotifyCollectionChangedAction.Reset: - - foreach (IControl container in ItemContainerGenerator.Index.Containers) - { - MarkContainerSelected(container, false); - } - - if (SelectedItems.Count > 0) - { - SelectedItemsAdded(SelectedItems); - - added = SelectedItems; - } - else if (!_syncingSelectedItems) - { - SelectedItem = null; - } - - break; - case NotifyCollectionChangedAction.Replace: - - foreach (var item in e.OldItems) - { - MarkItemSelected(item, false); - } - - foreach (var item in e.NewItems) - { - MarkItemSelected(item, true); - } - - if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems) - { - var oldItem = SelectedItem; - var item = SelectedItems[0]; - _selectedItem = item; - RaisePropertyChanged(SelectedItemProperty, oldItem, item); - } - - added = e.NewItems; - removed = e.OldItems; - - break; - } - - if (added?.Count > 0 || removed?.Count > 0) - { - var changed = new SelectionChangedEventArgs( - SelectingItemsControl.SelectionChangedEvent, - removed ?? Empty, - added ?? Empty); - RaiseEvent(changed); - } - } - - private void MarkItemSelected(object item, bool selected) - { - var container = ItemContainerGenerator.Index.ContainerFromItem(item); - - MarkContainerSelected(container, selected); - } - - private void SelectedItemsAdded(IList items) - { - if (items.Count == 0) - { - return; - } - - foreach (object item in items) - { - MarkItemSelected(item, true); - } - - if (SelectedItem == null && !_syncingSelectedItems) - { - SetAndRaise(SelectedItemProperty, ref _selectedItem, items[0]); - } - } - - /// - /// Unsubscribes from the CollectionChanged event, if any. - /// - private void UnsubscribeFromSelectedItems() - { - if (_selectedItems is INotifyCollectionChanged incc) - { - incc.CollectionChanged -= SelectedItemsCollectionChanged; - } - } + public void UnselectAll() => Selection.ClearSelection(); (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction) @@ -448,6 +335,72 @@ namespace Avalonia.Controls } } + /// + /// Called when is raised. + /// + /// The sender. + /// The event args. + private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) + { + var container = ContainerFromIndex(Selection.AnchorIndex); + + if (container != null) + { + container.BringIntoView(); + } + } + } + + /// + /// Called when is raised. + /// + /// The sender. + /// The event args. + private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + void Mark(IndexPath index, bool selected) + { + var container = ContainerFromIndex(index); + + if (container != null) + { + MarkContainerSelected(container, selected); + } + } + + foreach (var i in e.SelectedIndices) + { + Mark(i, true); + } + + foreach (var i in e.DeselectedIndices) + { + Mark(i, false); + } + + var newSelectedItem = SelectedItem; + + if (newSelectedItem != _selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem); + _selectedItem = newSelectedItem; + } + + var ev = new SelectionChangedEventArgs( + SelectionChangedEvent, + e.DeselectedItems.ToList(), + e.SelectedItems.ToList()); + RaiseEvent(ev); + } + + private void OnSelectionModelChildrenRequested(object sender, SelectionModelChildrenRequestedEventArgs e) + { + var container = ItemContainerGenerator.Index.ContainerFromItem(e.Source) as ItemsControl; + e.Children = container?.Items as IEnumerable; + } + private TreeViewItem GetContainerInDirection( TreeViewItem from, NavigationDirection direction, @@ -501,6 +454,12 @@ namespace Avalonia.Controls return result; } + protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) + { + Selection.Source = Items; + base.ItemsChanged(e); + } + /// protected override void OnPointerPressed(PointerPressedEventArgs e) { @@ -522,6 +481,18 @@ namespace Avalonia.Controls } } + protected override void OnPropertyChanged(AvaloniaProperty property, Optional oldValue, BindingValue newValue, BindingPriority priority) + { + base.OnPropertyChanged(property, oldValue, newValue, priority); + + if (property == SelectionModeProperty) + { + var mode = newValue.GetValueOrDefault(); + Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple); + Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected); + } + } + /// /// Updates the selection for an item based on user interaction. /// @@ -537,9 +508,9 @@ namespace Avalonia.Controls bool toggleModifier = false, bool rightButton = false) { - var item = ItemContainerGenerator.Index.ItemFromContainer(container); + var index = IndexFromContainer((TreeViewItem)container); - if (item == null) + if (index.GetSize() == 0) { return; } @@ -556,41 +527,48 @@ namespace Avalonia.Controls var multi = (mode & SelectionMode.Multiple) != 0; var range = multi && selectedContainer != null && rangeModifier; - if (rightButton) + if (!select) + { + Selection.DeselectAt(index); + } + else if (rightButton) { - if (!SelectedItems.Contains(item)) + if (!Selection.IsSelectedAt(index)) { - SelectSingleItem(item); + Selection.SelectedIndex = index; } } else if (!toggle && !range) { - SelectSingleItem(item); + Selection.SelectedIndex = index; } else if (multi && range) { - SynchronizeItems( - SelectedItems, - GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem)); + using var operation = Selection.Update(); + var anchor = Selection.AnchorIndex; + + if (anchor.GetSize() == 0) + { + anchor = new IndexPath(0); + } + + Selection.ClearSelection(); + Selection.AnchorIndex = anchor; + Selection.SelectRangeFromAnchorTo(index); } else { - var i = SelectedItems.IndexOf(item); - - if (i != -1) + if (Selection.IsSelectedAt(index)) { - SelectedItems.Remove(item); + Selection.DeselectAt(index); + } + else if (multi) + { + Selection.SelectAt(index); } else { - if (multi) - { - SelectedItems.Add(item); - } - else - { - SelectedItem = item; - } + Selection.SelectedIndex = index; } } } @@ -613,117 +591,6 @@ namespace Avalonia.Controls } } - /// - /// Find which node is first in hierarchy. - /// - /// Search root. - /// Nodes to find. - /// Node to find. - /// Found first node. - private static TreeViewItem FindFirstNode(TreeView treeView, TreeViewItem nodeA, TreeViewItem nodeB) - { - return FindInContainers(treeView.ItemContainerGenerator, nodeA, nodeB); - } - - private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator, - TreeViewItem nodeA, - TreeViewItem nodeB) - { - IEnumerable containers = containerGenerator.Containers; - - foreach (ItemContainerInfo container in containers) - { - TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, nodeA, nodeB); - - if (node != null) - { - return node; - } - } - - return null; - } - - private static TreeViewItem FindFirstNode(TreeViewItem node, TreeViewItem nodeA, TreeViewItem nodeB) - { - if (node == null) - { - return null; - } - - TreeViewItem match = node == nodeA ? nodeA : node == nodeB ? nodeB : null; - - if (match != null) - { - return match; - } - - return FindInContainers(node.ItemContainerGenerator, nodeA, nodeB); - } - - /// - /// Returns all items that belong to containers between and . - /// The range is inclusive. - /// - /// From container. - /// To container. - private List GetItemsInRange(TreeViewItem from, TreeViewItem to) - { - var items = new List(); - - if (from == null || to == null) - { - return items; - } - - TreeViewItem firstItem = FindFirstNode(this, from, to); - - if (firstItem == null) - { - return items; - } - - bool wasReversed = false; - - if (firstItem == to) - { - var temp = from; - - from = to; - to = temp; - - wasReversed = true; - } - - TreeViewItem node = from; - - while (node != to) - { - var item = ItemContainerGenerator.Index.ItemFromContainer(node); - - if (item != null) - { - items.Add(item); - } - - node = GetContainerInDirection(node, NavigationDirection.Down, true); - } - - var toItem = ItemContainerGenerator.Index.ItemFromContainer(to); - - if (toItem != null) - { - items.Add(toItem); - } - - if (wasReversed) - { - items.Reverse(); - } - - return items; - } - /// /// Updates the selection based on an event that may have originated in a container that /// belongs to the control. @@ -829,26 +696,90 @@ namespace Avalonia.Controls } } - /// - /// Makes a list of objects equal another (though doesn't preserve order). - /// - /// The items collection. - /// The desired items. - private static void SynchronizeItems(IList items, IEnumerable desired) + private void MarkContainersUnselected() { - var list = items.Cast().ToList(); - var toRemove = list.Except(desired).ToList(); - var toAdd = desired.Except(list).ToList(); + foreach (var container in ItemContainerGenerator.Index.Containers) + { + MarkContainerSelected(container, false); + } + } + + private void UpdateContainerSelection() + { + var index = ItemContainerGenerator.Index; + + foreach (var container in index.Containers) + { + var i = IndexFromContainer((TreeViewItem)container); + + MarkContainerSelected( + container, + Selection.IsSelectedAt(i) != false); + } + } + + private static IndexPath IndexFromContainer(TreeViewItem container) + { + var result = new List(); + + while (true) + { + if (container.Level == 0) + { + var treeView = container.FindAncestorOfType(); + + if (treeView == null) + { + return default; + } + + result.Add(treeView.ItemContainerGenerator.IndexFromContainer(container)); + result.Reverse(); + return new IndexPath(result); + } + else + { + var parent = container.FindAncestorOfType(); + + if (parent == null) + { + return default; + } + + result.Add(parent.ItemContainerGenerator.IndexFromContainer(container)); + container = parent; + } + } + } + + private IndexPath IndexFromItem(object item) + { + var container = ItemContainerGenerator.Index.ContainerFromItem(item) as TreeViewItem; - foreach (var i in toRemove) + if (container != null) { - items.Remove(i); + return IndexFromContainer(container); } - foreach (var i in toAdd) + return default; + } + + private TreeViewItem ContainerFromIndex(IndexPath index) + { + TreeViewItem treeViewItem = null; + + for (var i = 0; i < index.GetSize(); ++i) { - items.Add(i); + var generator = treeViewItem?.ItemContainerGenerator ?? ItemContainerGenerator; + treeViewItem = generator.ContainerFromIndex(index.GetAt(i)) as TreeViewItem; + + if (treeViewItem == null) + { + return null; + } } + + return treeViewItem; } } } diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index a2892be5c2..1ce1f8cc4c 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -10,7 +10,6 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Data.Core; -using Avalonia.Diagnostics; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; @@ -244,12 +243,12 @@ namespace Avalonia.Controls.UnitTests ClickContainer(item2Container, InputModifiers.Control); Assert.True(item2Container.IsSelected); - Assert.Equal(new[] {item1, item2}, target.SelectedItems.OfType()); + Assert.Equal(new[] {item1, item2}, target.Selection.SelectedItems.OfType()); ClickContainer(item1Container, InputModifiers.Control); Assert.False(item1Container.IsSelected); - Assert.DoesNotContain(item1, target.SelectedItems.OfType()); + Assert.DoesNotContain(item1, target.Selection.SelectedItems.OfType()); } [Fact] @@ -749,11 +748,11 @@ namespace Avalonia.Controls.UnitTests target.SelectAll(); AssertChildrenSelected(target, tree[0]); - Assert.Equal(5, target.SelectedItems.Count); + Assert.Equal(5, target.Selection.SelectedItems.Count); _mouse.Click((Interactive)target.Presenter.Panel.Children[0], MouseButton.Right); - Assert.Equal(5, target.SelectedItems.Count); + Assert.Equal(5, target.Selection.SelectedItems.Count); } [Fact] @@ -785,11 +784,11 @@ namespace Avalonia.Controls.UnitTests ClickContainer(fromContainer, InputModifiers.None); ClickContainer(toContainer, InputModifiers.Shift); - Assert.Equal(2, target.SelectedItems.Count); + Assert.Equal(2, target.Selection.SelectedItems.Count); _mouse.Click(thenContainer, MouseButton.Right); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] @@ -819,7 +818,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Shift); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] @@ -849,7 +848,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Control); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] From 0f9ceb25b740b05a84d7e9660973607aedf90373 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 12:52:08 +0100 Subject: [PATCH 46/52] Make ScrollIntoView accept an index, not an item. Fixes #2569. --- src/Avalonia.Controls/ComboBox.cs | 2 +- .../Presenters/IItemsPresenter.cs | 2 +- .../Presenters/ItemVirtualizer.cs | 4 ++-- .../Presenters/ItemVirtualizerNone.cs | 15 +++++---------- .../Presenters/ItemVirtualizerSimple.cs | 15 +++++---------- .../Presenters/ItemsPresenter.cs | 4 ++-- .../Presenters/ItemsPresenterBase.cs | 2 +- .../Primitives/SelectingItemsControl.cs | 15 +++++++++------ src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs | 2 +- .../ItemsPresenterTests_Virtualization_Simple.cs | 8 ++++---- 10 files changed, 31 insertions(+), 38 deletions(-) diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 0722802962..724c155efd 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -291,7 +291,7 @@ namespace Avalonia.Controls if (container == null && SelectedIndex != -1) { - ScrollIntoView(Selection.SelectedItem); + ScrollIntoView(Selection.SelectedIndex); container = ItemContainerGenerator.ContainerFromIndex(selectedIndex); } diff --git a/src/Avalonia.Controls/Presenters/IItemsPresenter.cs b/src/Avalonia.Controls/Presenters/IItemsPresenter.cs index c4acf1ebef..a487ee390b 100644 --- a/src/Avalonia.Controls/Presenters/IItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/IItemsPresenter.cs @@ -14,6 +14,6 @@ namespace Avalonia.Controls.Presenters void ItemsChanged(NotifyCollectionChangedEventArgs e); - void ScrollIntoView(object item); + void ScrollIntoView(int index); } } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs index ae52e733b7..a25855ae49 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs @@ -257,8 +257,8 @@ namespace Avalonia.Controls.Presenters /// /// Scrolls the specified item into view. /// - /// The item. - public virtual void ScrollIntoView(object item) + /// The index of the item. + public virtual void ScrollIntoView(int index) { } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs index 56f64779f6..f0b7fb41ff 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs @@ -67,18 +67,13 @@ namespace Avalonia.Controls.Presenters /// /// Scrolls the specified item into view. /// - /// The item. - public override void ScrollIntoView(object item) + /// The index of the item. + public override void ScrollIntoView(int index) { - if (Items != null) + if (index != -1) { - var index = Items.IndexOf(item); - - if (index != -1) - { - var container = Owner.ItemContainerGenerator.ContainerFromIndex(index); - container?.BringIntoView(); - } + var container = Owner.ItemContainerGenerator.ContainerFromIndex(index); + container?.BringIntoView(); } } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs index d27de7a80d..2139c85f31 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs @@ -289,20 +289,15 @@ namespace Avalonia.Controls.Presenters break; } - return ScrollIntoView(newItemIndex); + return ScrollIntoViewCore(newItemIndex); } /// - public override void ScrollIntoView(object item) + public override void ScrollIntoView(int index) { - if (Items != null) + if (index != -1) { - var index = Items.IndexOf(item); - - if (index != -1) - { - ScrollIntoView(index); - } + ScrollIntoViewCore(index); } } @@ -514,7 +509,7 @@ namespace Avalonia.Controls.Presenters /// /// The item index. /// The container that was brought into view. - private IControl ScrollIntoView(int index) + private IControl ScrollIntoViewCore(int index) { var panel = VirtualizingPanel; var generator = Owner.ItemContainerGenerator; diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index ab40fbd53b..51e6b80d60 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -120,9 +120,9 @@ namespace Avalonia.Controls.Presenters return Virtualizer?.GetControlInDirection(direction, from); } - public override void ScrollIntoView(object item) + public override void ScrollIntoView(int index) { - Virtualizer?.ScrollIntoView(item); + Virtualizer?.ScrollIntoView(index); } /// diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index ef1f277162..f120d74b9a 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -142,7 +142,7 @@ namespace Avalonia.Controls.Presenters } /// - public virtual void ScrollIntoView(object item) + public virtual void ScrollIntoView(int index) { } diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index b1a4379cae..dc08448cd1 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -339,11 +339,17 @@ namespace Avalonia.Controls.Primitives base.EndInit(); } + /// + /// Scrolls the specified item into view. + /// + /// The index of the item. + public void ScrollIntoView(int index) => Presenter?.ScrollIntoView(index); + /// /// Scrolls the specified item into view. /// /// The item. - public void ScrollIntoView(object item) => Presenter?.ScrollIntoView(item); + public void ScrollIntoView(object item) => ScrollIntoView(IndexOf(Items, item)); /// /// Tries to get the container that was the source of an event. @@ -665,12 +671,9 @@ namespace Avalonia.Controls.Primitives { if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) { - var index = Selection.AnchorIndex.GetSize() > 0 ? Selection.AnchorIndex.GetAt(0) : -1; - var item = index != -1 ? ElementAt(Items, index) : null; - - if (item != null) + if (Selection.AnchorIndex.GetSize() > 0) { - ScrollIntoView(item); + ScrollIntoView(Selection.AnchorIndex.GetAt(0)); } } } diff --git a/src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs b/src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs index b967b40c0d..bf29381eab 100644 --- a/src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs +++ b/src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs @@ -81,7 +81,7 @@ namespace Avalonia.Dialogs if (indexOfPreselected > 1) { - _filesView.ScrollIntoView(model.Items[indexOfPreselected - 1]); + _filesView.ScrollIntoView(indexOfPreselected - 1); } } } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs index 7a6cf0fba7..05124a282c 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs @@ -575,7 +575,7 @@ namespace Avalonia.Controls.UnitTests.Presenters target.Arrange(Rect.Empty); // Check for issue #591: this should not throw. - target.ScrollIntoView(items[0]); + target.ScrollIntoView(0); } } @@ -729,7 +729,7 @@ namespace Avalonia.Controls.UnitTests.Presenters var last = (target.Items as IList)[10]; - target.ScrollIntoView(last); + target.ScrollIntoView(10); Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); Assert.Same(target.Panel.Children[9].DataContext, last); @@ -746,12 +746,12 @@ namespace Avalonia.Controls.UnitTests.Presenters var last = (target.Items as IList)[10]; - target.ScrollIntoView(last); + target.ScrollIntoView(10); Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); Assert.Same(target.Panel.Children[9].DataContext, last); - target.ScrollIntoView(last); + target.ScrollIntoView(10); Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); Assert.Same(target.Panel.Children[9].DataContext, last); From e80e52c3036fc76b3e2cd8d5be37096e2198d951 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 17 Feb 2020 15:27:35 +0100 Subject: [PATCH 47/52] TryGetValue doesn't allow null as the value. --- .../Generators/TreeContainerIndex.cs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Controls/Generators/TreeContainerIndex.cs b/src/Avalonia.Controls/Generators/TreeContainerIndex.cs index 0da84008f6..ca417e0d45 100644 --- a/src/Avalonia.Controls/Generators/TreeContainerIndex.cs +++ b/src/Avalonia.Controls/Generators/TreeContainerIndex.cs @@ -97,9 +97,13 @@ namespace Avalonia.Controls.Generators /// The container, or null of not found. public IControl ContainerFromItem(object item) { - IControl result; - _itemToContainer.TryGetValue(item, out result); - return result; + if (item != null) + { + _itemToContainer.TryGetValue(item, out var result); + return result; + } + + return null; } /// @@ -109,9 +113,13 @@ namespace Avalonia.Controls.Generators /// The item, or null of not found. public object ItemFromContainer(IControl container) { - object result; - _containerToItem.TryGetValue(container, out result); - return result; + if (container != null) + { + _containerToItem.TryGetValue(container, out var result); + return result; + } + + return null; } } } From 6c0265b6e87924b89780d9f5c994e951b991a179 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 22 Feb 2020 10:53:20 +0100 Subject: [PATCH 48/52] Fix comment. Fix spelling, and `VectorView` is `IReadOnlyList` in C#. --- src/Avalonia.Controls/SelectionModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 5eb2a2da0a..023c4ee65e 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -227,7 +227,7 @@ namespace Avalonia.Controls } // 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 + // we create a custom IReadOnlyList implementation 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. From 1cbed83405318aeab89b99d833154a8dcf25eaa1 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 22 Feb 2020 10:54:01 +0100 Subject: [PATCH 49/52] Tweak SelectionModelChangeSet. - Make field readonly - Use lambda syntax so C# caches delegates --- src/Avalonia.Controls/SelectionModelChangeSet.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModelChangeSet.cs b/src/Avalonia.Controls/SelectionModelChangeSet.cs index bff84eca92..6e77dc5755 100644 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -7,7 +7,7 @@ namespace Avalonia.Controls { internal class SelectionModelChangeSet { - private List _changes; + private readonly List _changes; public SelectionModelChangeSet(List changes) { @@ -63,7 +63,7 @@ namespace Avalonia.Controls { static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; static List? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; - return GetIndexAt(infos, index, GetCount, GetRanges); + return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x)); } private IndexPath GetSelectedIndexAt( @@ -72,7 +72,7 @@ namespace Avalonia.Controls { static int GetCount(SelectionNodeOperation info) => info.SelectedCount; static List? GetRanges(SelectionNodeOperation info) => info.SelectedRanges; - return GetIndexAt(infos, index, GetCount, GetRanges); + return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x)); } private object? GetDeselectedItemAt( @@ -81,7 +81,7 @@ namespace Avalonia.Controls { static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.DeselectedCount : 0; static List? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; - return GetItemAt(infos, index, GetCount, GetRanges); + return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x)); } private object? GetSelectedItemAt( @@ -90,7 +90,7 @@ namespace Avalonia.Controls { static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.SelectedCount : 0; static List? GetRanges(SelectionNodeOperation info) => info.SelectedRanges; - return GetItemAt(infos, index, GetCount, GetRanges); + return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x)); } private IndexPath GetIndexAt( From cdb6e92c01924cd3584657807f04520a2cc9797b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 15 Apr 2020 12:43:47 +0200 Subject: [PATCH 50/52] Added some docs for ISelectionModel. --- src/Avalonia.Controls/ISelectionModel.cs | 200 ++++++++++++++++++++++- 1 file changed, 198 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs index 34fe626696..6570921c03 100644 --- a/src/Avalonia.Controls/ISelectionModel.cs +++ b/src/Avalonia.Controls/ISelectionModel.cs @@ -9,45 +9,241 @@ using System.ComponentModel; namespace Avalonia.Controls { + /// + /// Holds the selected items for a control. + /// public interface ISelectionModel : INotifyPropertyChanged { + /// + /// Gets or sets the anchor index. + /// IndexPath AnchorIndex { get; set; } + + /// + /// Gets or set the index of the first selected item. + /// IndexPath SelectedIndex { get; set; } + + /// + /// Gets or set the indexes of the selected items. + /// IReadOnlyList SelectedIndices { get; } + + /// + /// Gets the first selected item. + /// object SelectedItem { get; } + + /// + /// Gets the selected items. + /// IReadOnlyList SelectedItems { get; } + + /// + /// Gets a value indicating whether the model represents a single or multiple selection. + /// bool SingleSelect { get; set; } + + /// + /// Gets a value indicating whether to always keep an item selected where possible. + /// bool AutoSelect { get; set; } + + /// + /// Gets or sets the collection that contains the items that can be selected. + /// object Source { get; set; } + /// + /// Raised when the children of a selection are required. + /// event EventHandler ChildrenRequested; + + /// + /// Raised when the selection has changed. + /// event EventHandler SelectionChanged; + /// + /// Clears the selection. + /// void ClearSelection(); + + /// + /// Deselects an item. + /// + /// The index of the item. void Deselect(int index); + + /// + /// Deselects an item. + /// + /// The index of the item group. + /// The index of the item in the group. void Deselect(int groupIndex, int itemIndex); + + /// + /// Deselects an item. + /// + /// The index of the item. void DeselectAt(IndexPath index); + + /// + /// Deselects a range of items. + /// + /// The start index of the range. + /// The end index of the range. void DeselectRange(IndexPath start, IndexPath end); + + /// + /// Deselects a range of items, starting at . + /// + /// The end index of the range. void DeselectRangeFromAnchor(int index); + + /// + /// Deselects a range of items, starting at . + /// + /// + /// The index of the item group that represents the end of the selection. + /// + /// + /// The index of the item in the group that represents the end of the selection. + /// void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex); + + /// + /// Deselects a range of items, starting at . + /// + /// The end index of the range. void DeselectRangeFromAnchorTo(IndexPath index); + + /// + /// Disposes the object and clears the selection. + /// void Dispose(); + + /// + /// Checks whether an item is selected. + /// + /// The index of the item bool IsSelected(int index); - bool IsSelected(int grouIndex, int itemIndex); + + /// + /// Checks whether an item is selected. + /// + /// The index of the item group. + /// The index of the item in the group. + bool IsSelected(int groupIndex, int itemIndex); + + /// + /// Checks whether an item is selected. + /// + /// The index of the item public bool IsSelectedAt(IndexPath index); + + /// + /// Checks whether an item or its descendents are selected. + /// + /// The index of the item + /// + /// True if the item and all its descendents are selected, false if the item and all its + /// descendents are deselected, or null if a combination of selected and deselected. + /// bool? IsSelectedWithPartial(int index); + + /// + /// Checks whether an item or its descendents are selected. + /// + /// The index of the item group. + /// The index of the item in the group. + /// + /// True if the item and all its descendents are selected, false if the item and all its + /// descendents are deselected, or null if a combination of selected and deselected. + /// bool? IsSelectedWithPartial(int groupIndex, int itemIndex); + + /// + /// Checks whether an item or its descendents are selected. + /// + /// The index of the item + /// + /// True if the item and all its descendents are selected, false if the item and all its + /// descendents are deselected, or null if a combination of selected and deselected. + /// bool? IsSelectedWithPartialAt(IndexPath index); + + /// + /// Selects an item. + /// + /// The index of the item void Select(int index); + + /// + /// Selects an item. + /// + /// The index of the item group. + /// The index of the item in the group. void Select(int groupIndex, int itemIndex); - void SelectAll(); + + /// + /// Selects an item. + /// + /// The index of the item void SelectAt(IndexPath index); + + /// + /// Selects all items. + /// + void SelectAll(); + + /// + /// Selects a range of items. + /// + /// The start index of the range. + /// The end index of the range. void SelectRange(IndexPath start, IndexPath end); + + /// + /// Selects a range of items, starting at . + /// + /// The end index of the range. void SelectRangeFromAnchor(int index); + + /// + /// Selects a range of items, starting at . + /// + /// + /// The index of the item group that represents the end of the selection. + /// + /// + /// The index of the item in the group that represents the end of the selection. + /// void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex); + + /// + /// Selects a range of items, starting at . + /// + /// The end index of the range. void SelectRangeFromAnchorTo(IndexPath index); + + /// + /// Sets the . + /// + /// The anchor index. void SetAnchorIndex(int index); + + /// + /// Sets the . + /// + /// The index of the item group. + /// The index of the item in the group. void SetAnchorIndex(int groupIndex, int index); + + /// + /// Begins a batch update of the selection. + /// + /// An that finishes the batch update. IDisposable Update(); } } From c9a385bd5a07311b983fae93e910ff8797d9f7d0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 8 May 2020 10:55:13 +0200 Subject: [PATCH 51/52] Allow SelectionNode children to change. Make `SelectionModelChildrenRequestedEventArgs.Children` an observable, so that the we can react to the children collection object changing, as well as the children inside the collection changing. Upstream issue: https://github.com/microsoft/microsoft-ui-xaml/issues/2404 --- src/Avalonia.Controls/SelectionModel.cs | 20 +--- ...electionModelChildrenRequestedEventArgs.cs | 17 ++- src/Avalonia.Controls/SelectionNode.cs | 56 ++++++--- src/Avalonia.Controls/TreeView.cs | 2 +- .../Avalonia.Controls.UnitTests.csproj | 1 + .../ButtonTests.cs | 1 + .../SelectionModelTests.cs | 107 +++++++++++++++++- 7 files changed, 168 insertions(+), 36 deletions(-) diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs index 023c4ee65e..d930edc529 100644 --- a/src/Avalonia.Controls/SelectionModel.cs +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using System.Reactive.Linq; using Avalonia.Controls.Utils; #nullable enable @@ -575,7 +576,7 @@ namespace Avalonia.Controls public void OnSelectionInvalidatedDueToCollectionChange( bool selectionInvalidated, - IReadOnlyList? removedItems) + IReadOnlyList? removedItems) { SelectionModelSelectionChangedEventArgs? e = null; @@ -588,9 +589,9 @@ namespace Avalonia.Controls ApplyAutoSelect(); } - internal object? ResolvePath(object data, IndexPath dataIndexPath) + internal IObservable? ResolvePath(object data, IndexPath dataIndexPath) { - object? resolved = null; + IObservable? resolved = null; // Raise ChildrenRequested event if there is a handler if (ChildrenRequested != null) @@ -610,19 +611,6 @@ namespace Avalonia.Controls // Clear out the values in the args so that it cannot be used after the event handler call. _childrenRequestedEventArgs.Initialize(null, default, true); } - 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; } diff --git a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs index 823e7b9447..974da0cf71 100644 --- a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs @@ -9,6 +9,9 @@ using System; namespace Avalonia.Controls { + /// + /// Provides data for the event. + /// public class SelectionModelChildrenRequestedEventArgs : EventArgs { private object? _source; @@ -24,8 +27,15 @@ namespace Avalonia.Controls Initialize(source, sourceIndexPath, throwOnAccess); } - public object? Children { get; set; } - + /// + /// Gets or sets an observable which produces the children of the + /// object. + /// + public IObservable? Children { get; set; } + + /// + /// Gets the object whose children are being requested. + /// public object Source { get @@ -39,6 +49,9 @@ namespace Avalonia.Controls } } + /// + /// Gets the index of the object whose children are being requested. + /// public IndexPath SourceIndex { get diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs index 136964b5b1..e25f88ff29 100644 --- a/src/Avalonia.Controls/SelectionNode.cs +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -29,6 +29,7 @@ namespace Avalonia.Controls private readonly SelectionNode? _parent; private readonly List _selected = new List(); private readonly List _selectedIndicesCached = new List(); + private IDisposable? _childrenSubscription; private SelectionNodeOperation? _operation; private object? _source; private bool _selectedIndicesCacheIsValid; @@ -83,6 +84,7 @@ namespace Avalonia.Controls if (_source != null) { ClearSelection(); + ClearChildNodes(); UnhookCollectionChangedHandler(); } @@ -163,32 +165,34 @@ namespace Avalonia.Controls if (_childrenNodes[index] == null) { var childData = ItemsSourceView!.GetAt(index); + IObservable? resolver = null; if (childData != null) { var childDataIndexPath = IndexPath.CloneWithChildIndex(index); - var resolvedChild = _manager.ResolvePath(childData, childDataIndexPath); - - if (resolvedChild != null) - { - child = new SelectionNode(_manager, parent: this); - child.Source = resolvedChild; + resolver = _manager.ResolvePath(childData, childDataIndexPath); + } - if (_operation != null) - { - child.BeginOperation(); - } - } - else - { - child = _manager.SharedLeafNode; - } + if (resolver != null) + { + child = new SelectionNode(_manager, parent: this); + child.SetChildrenObservable(resolver); } - else + else if (childData is IEnumerable || childData is IList) { + child = new SelectionNode(_manager, parent: this); + child.Source = childData; + } + else + { child = _manager.SharedLeafNode; } + if (_operation != null && child != _manager.SharedLeafNode) + { + child.BeginOperation(); + } + _childrenNodes[index] = child; RealizedChildrenNodeCount++; } @@ -208,6 +212,11 @@ namespace Avalonia.Controls return child; } + public void SetChildrenObservable(IObservable resolver) + { + _childrenSubscription = resolver.Subscribe(x => Source = x); + } + public int SelectedCount { get; private set; } public bool IsSelected(int index) @@ -327,7 +336,9 @@ namespace Avalonia.Controls public void Dispose() { + _childrenSubscription?.Dispose(); ItemsSourceView?.Dispose(); + ClearChildNodes(); UnhookCollectionChangedHandler(); } @@ -531,6 +542,19 @@ namespace Avalonia.Controls AnchorIndex = -1; } + private void ClearChildNodes() + { + foreach (var child in _childrenNodes) + { + if (child != null && child != _manager.SharedLeafNode) + { + child.Dispose(); + } + } + + RealizedChildrenNodeCount = 0; + } + private bool Select(int index, bool select, bool raiseOnSelectionChanged) { if (IsValidIndex(index)) diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index b63f4afbcc..da71078439 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -395,7 +395,7 @@ namespace Avalonia.Controls private void OnSelectionModelChildrenRequested(object sender, SelectionModelChildrenRequestedEventArgs e) { var container = ItemContainerGenerator.Index.ContainerFromItem(e.Source) as ItemsControl; - e.Children = container?.Items as IEnumerable; + e.Children = container.GetObservable(ItemsProperty); } private TreeViewItem GetContainerInDirection( diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj index 373b1bc82f..2ca93dcf56 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs index 994804e9e1..7ad0e480c6 100644 --- a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs @@ -9,6 +9,7 @@ using Avalonia.UnitTests; using Avalonia.VisualTree; using Moq; using Xunit; +using MouseButton = Avalonia.Input.MouseButton; namespace Avalonia.Controls.UnitTests { diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs index 9c3b0c17b6..c4a682cc54 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -8,9 +8,12 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; +using System.Reactive.Linq; using Avalonia.Collections; using Avalonia.Diagnostics; +using ReactiveUI; using Xunit; using Xunit.Abstractions; @@ -254,7 +257,7 @@ namespace Avalonia.Controls.UnitTests { _output.WriteLine("ChildrenRequestedIndexPath:" + args.SourceIndex); sourcePaths.Add(args.SourceIndex); - args.Children = args.Source is IEnumerable ? args.Source : null; + args.Children = Observable.Return(args.Source as IEnumerable); }; } @@ -1894,6 +1897,108 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(0, raised); } + [Fact] + public void Can_Replace_Children_Collection() + { + var root = new Node("Root"); + var target = new SelectionModel { Source = new[] { root } }; + target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children); + + target.Select(0, 9); + + Assert.Equal("Child 9", ((Node)target.SelectedItem).Header); + + root.ReplaceChildren(); + + Assert.Null(target.SelectedItem); + } + + [Fact] + public void Child_Resolver_Is_Unsubscribed_When_Source_Changed() + { + var root = new Node("Root"); + var target = new SelectionModel { Source = new[] { root } }; + target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children); + + target.Select(0, 9); + + Assert.Equal(1, root.PropertyChangedSubscriptions); + + target.Source = null; + + Assert.Equal(0, root.PropertyChangedSubscriptions); + } + + [Fact] + public void Child_Resolver_Is_Unsubscribed_When_Parent_Removed() + { + var root = new Node("Root"); + var target = new SelectionModel { Source = new[] { root } }; + var node = root.Children[1]; + var path = new IndexPath(new[] { 0, 1, 1 }); + + target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children); + + target.SelectAt(path); + + Assert.Equal(1, node.PropertyChangedSubscriptions); + + root.ReplaceChildren(); + + Assert.Equal(0, node.PropertyChangedSubscriptions); + } + + private class Node : INotifyPropertyChanged + { + private ObservableCollection _children; + private PropertyChangedEventHandler _propertyChanged; + + public Node(string header) + { + Header = header; + } + + public string Header { get; } + + public ObservableCollection Children + { + get => _children ??= CreateChildren(10); + private set + { + _children = value; + _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Children))); + } + } + + public event PropertyChangedEventHandler PropertyChanged + { + add + { + _propertyChanged += value; + ++PropertyChangedSubscriptions; + } + + remove + { + _propertyChanged -= value; + --PropertyChangedSubscriptions; + } + } + + public int PropertyChangedSubscriptions { get; private set; } + + public void ReplaceChildren() + { + Children = CreateChildren(5); + } + + private ObservableCollection CreateChildren(int count) + { + return new ObservableCollection( + Enumerable.Range(0, count).Select(x => new Node("Child " + x))); + } + } + private int GetSubscriberCount(AvaloniaList list) { return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; From f06eaf38f879a3b222b9467704f4264c58c9dc27 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 8 May 2020 12:10:19 +0200 Subject: [PATCH 52/52] Use correct unit test AppBuilder. A reference was added to `Avalonia.ReactiveUI` from `Avalonia.Controls.UnitTests` which caused the incorrect `AppBuilder` to be resolved. --- tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs b/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs index fae08d37b7..b5004bc6a5 100644 --- a/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Xunit; -using Avalonia.Controls.UnitTests; +using Avalonia.Controls.UnitTests; using Avalonia.Platform; -using Avalonia.UnitTests; +using Xunit; [assembly: ExportAvaloniaModule("DefaultModule", typeof(AppBuilderTests.DefaultModule))] [assembly: ExportAvaloniaModule("RenderingModule", typeof(AppBuilderTests.Direct2DModule), ForRenderingSubsystem = "Direct2D1")] @@ -16,6 +10,7 @@ using Avalonia.UnitTests; namespace Avalonia.Controls.UnitTests { + using AppBuilder = Avalonia.UnitTests.AppBuilder; public class AppBuilderTests {