From 88794773229e5c9fe93f29672c41b9cbac601a89 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 26 Jan 2020 11:47:51 +0100 Subject: [PATCH] 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()