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;