From b73ba990777c9727cad4efcd64b968c33b86f336 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 15 Sep 2020 15:16:34 +0200 Subject: [PATCH] Reafactor SelectingItemsControl selection. - Remove `SelectedItemsSync` and store `SelectedItems` in a new `InternalSelectionModel` - Store transient `SelectingItemsControl` state in an `UpdateState` object Fixes #4272 --- .../Primitives/SelectingItemsControl.cs | 225 +++++++++++--- .../Selection/InternalSelectionModel.cs | 251 ++++++++++++++++ .../Selection/SelectionModel.cs | 56 +++- .../Utils/SelectedItemsSync.cs | 283 ------------------ .../Selection/InternalSelectionModelTests.cs | 243 +++++++++++++++ .../Utils/SelectedItemsSyncTests.cs | 278 ----------------- 6 files changed, 712 insertions(+), 624 deletions(-) create mode 100644 src/Avalonia.Controls/Selection/InternalSelectionModel.cs delete mode 100644 src/Avalonia.Controls/Utils/SelectedItemsSync.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Selection/InternalSelectionModelTests.cs delete mode 100644 tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index fcef3b1d08..3e106afdc0 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -6,7 +6,6 @@ using System.ComponentModel; using System.Linq; using Avalonia.Controls.Generators; using Avalonia.Controls.Selection; -using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; @@ -70,8 +69,8 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - protected static readonly DirectProperty SelectedItemsProperty = - AvaloniaProperty.RegisterDirect( + protected static readonly DirectProperty SelectedItemsProperty = + AvaloniaProperty.RegisterDirect( nameof(SelectedItems), o => o.SelectedItems, (o, v) => o.SelectedItems = v); @@ -111,12 +110,11 @@ namespace Avalonia.Controls.Primitives RoutingStrategies.Bubble); private static readonly IList Empty = Array.Empty(); - private SelectedItemsSync? _selectedItemsSync; private ISelectionModel? _selection; private int _oldSelectedIndex; private object? _oldSelectedItem; - private int _initializing; private bool _ignoreContainerSelectionChanged; + private UpdateState? _updateState; /// /// Initializes static members of the class. @@ -149,8 +147,23 @@ namespace Avalonia.Controls.Primitives /// public int SelectedIndex { - get => Selection.SelectedIndex; - set => Selection.SelectedIndex = value; + get + { + return _updateState?.SelectedIndex.HasValue == true ? + _updateState.SelectedIndex.Value : + Selection.SelectedIndex; + } + set + { + if (_updateState is object) + { + _updateState.SelectedIndex = value; + } + else + { + Selection.SelectedIndex = value; + } + } } /// @@ -158,17 +171,56 @@ namespace Avalonia.Controls.Primitives /// public object? SelectedItem { - get => Selection.SelectedItem; - set => Selection.SelectedItem = value; + get + { + return _updateState?.SelectedItem.HasValue == true ? + _updateState.SelectedItem.Value : + Selection.SelectedItem; + } + set + { + if (_updateState is object) + { + _updateState.SelectedItem = value; + } + else + { + Selection.SelectedItem = value; + } + } } /// /// Gets or sets the selected items. /// - protected IList SelectedItems + /// + /// By default returns a collection that can be modified in order to manipulate the control + /// selection, however this property will return null if is + /// re-assigned; you should only use _either_ Selection or SelectedItems. + /// + protected IList? SelectedItems { - get => SelectedItemsSync.SelectedItems; - set => SelectedItemsSync.SelectedItems = value; + get + { + return _updateState?.SelectedItems.HasValue == true ? + _updateState.SelectedItems.Value : + (Selection as InternalSelectionModel)?.SelectedItems; + } + set + { + if (_updateState is object) + { + _updateState.SelectedItems = new Optional(value); + } + else if (Selection is InternalSelectionModel i) + { + i.SelectedItems = value; + } + else + { + throw new InvalidOperationException("Cannot set both Selection and SelectedItems."); + } + } } /// @@ -178,19 +230,30 @@ namespace Avalonia.Controls.Primitives { get { - if (_selection is null) + if (_updateState?.Selection.HasValue == true) { - _selection = CreateDefaultSelectionModel(); - InitializeSelectionModel(_selection); + return _updateState.Selection.Value; } + else + { + if (_selection is null) + { + _selection = CreateDefaultSelectionModel(); + InitializeSelectionModel(_selection); + } - return _selection; + return _selection; + } } set { value ??= CreateDefaultSelectionModel(); - if (_selection != value) + if (_updateState is object) + { + _updateState.Selection = new Optional(value); + } + else if (_selection != value) { if (value.Source != null && value.Source != Items) { @@ -234,20 +297,18 @@ namespace Avalonia.Controls.Primitives /// protected bool AlwaysSelected => (SelectionMode & SelectionMode.AlwaysSelected) != 0; - private SelectedItemsSync SelectedItemsSync => _selectedItemsSync ??= new SelectedItemsSync(Selection); - /// public override void BeginInit() { base.BeginInit(); - ++_initializing; + BeginUpdating(); } /// public override void EndInit() { base.EndInit(); - --_initializing; + EndUpdating(); } /// @@ -351,30 +412,14 @@ namespace Avalonia.Controls.Primitives protected override void OnDataContextBeginUpdate() { base.OnDataContextBeginUpdate(); - ++_initializing; - - if (_selection is object) - { - _selection.Source = null; - } + BeginUpdating(); } /// protected override void OnDataContextEndUpdate() { base.OnDataContextEndUpdate(); - --_initializing; - - if (_selection is object && _initializing == 0) - { - _selection.Source = Items; - - if (Items is null) - { - _selection.Clear(); - _selectedItemsSync?.SelectedItems?.Clear(); - } - } + EndUpdating(); } protected override void OnInitialized() @@ -411,9 +456,7 @@ namespace Avalonia.Controls.Primitives { base.OnPropertyChanged(change); - if (change.Property == ItemsProperty && - _initializing == 0 && - _selection is object) + if (change.Property == ItemsProperty && _updateState is null && _selection is object) { var newValue = change.NewValue.GetValueOrDefault(); _selection.Source = newValue; @@ -789,7 +832,7 @@ namespace Avalonia.Controls.Primitives private ISelectionModel CreateDefaultSelectionModel() { - return new SelectionModel + return new InternalSelectionModel { SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), }; @@ -797,7 +840,7 @@ namespace Avalonia.Controls.Primitives private void InitializeSelectionModel(ISelectionModel model) { - if (_initializing == 0) + if (_updateState is null) { model.Source = Items; } @@ -825,9 +868,6 @@ namespace Avalonia.Controls.Primitives UpdateContainerSelection(); - _selectedItemsSync ??= new SelectedItemsSync(model); - _selectedItemsSync.SelectionModel = model; - if (SelectedIndex != -1) { RaiseEvent(new SelectionChangedEventArgs( @@ -845,5 +885,96 @@ namespace Avalonia.Controls.Primitives model.SelectionChanged -= OnSelectionModelSelectionChanged; } } + + private void BeginUpdating() + { + _updateState ??= new UpdateState(); + _updateState.UpdateCount++; + } + + private void EndUpdating() + { + if (_updateState is object && --_updateState.UpdateCount == 0) + { + var state = _updateState; + _updateState = null; + + if (state.Selection.HasValue) + { + Selection = state.Selection.Value; + } + + if (state.SelectedItems.HasValue) + { + SelectedItems = state.SelectedItems.Value; + } + + Selection.Source = Items; + + if (Items is null) + { + Selection.Clear(); + } + + if (state.SelectedIndex.HasValue) + { + SelectedIndex = state.SelectedIndex.Value; + } + else if (state.SelectedItem.HasValue) + { + SelectedItem = state.SelectedItem.Value; + } + } + } + + // When in a BeginInit..EndInit block, or when the DataContext is updating, we need to + // defer changes to the selection model because we have no idea in which order properties + // will be set. Consider: + // + // - Both Items and SelectedItem are bound + // - The DataContext changes + // - The binding for SelectedItem updates first, producing an item + // - Items is searched to find the index of the new selected item + // - However Items isn't yet updated; the item is not found + // - SelectedIndex is incorrectly set to -1 + // + // This logic cannot be encapsulated in SelectionModel because the selection model can also + // be bound, consider: + // + // - Both Items and Selection are bound + // - The DataContext changes + // - The binding for Items updates first + // - The new items are assigned to Selection.Source + // - The binding for Selection updates, producing a new SelectionModel + // - Both the old and new SelectionModels have the incorrect Source + private class UpdateState + { + private Optional _selectedIndex; + private Optional _selectedItem; + + public int UpdateCount { get; set; } + public Optional Selection { get; set; } + public Optional SelectedItems { get; set; } + + public Optional SelectedIndex + { + get => _selectedIndex; + set + { + _selectedIndex = value; + _selectedItem = default; + } + } + + public Optional SelectedItem + { + get => _selectedItem; + set + { + _selectedItem = value; + _selectedIndex = default; + } + } + } } } diff --git a/src/Avalonia.Controls/Selection/InternalSelectionModel.cs b/src/Avalonia.Controls/Selection/InternalSelectionModel.cs new file mode 100644 index 0000000000..dd7b7d25cd --- /dev/null +++ b/src/Avalonia.Controls/Selection/InternalSelectionModel.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections; +using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Avalonia.Collections; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + internal class InternalSelectionModel : SelectionModel + { + private IList? _selectedItems; + private bool _ignoreModelChanges; + private bool _ignoreSelectedItemsChanges; + + public InternalSelectionModel() + { + SelectionChanged += OnSelectionChanged; + SourceReset += OnSourceReset; + } + + [AllowNull] + public new IList SelectedItems + { + get + { + if (_selectedItems is null) + { + _selectedItems = new AvaloniaList(); + SubscribeToSelectedItems(); + } + + return _selectedItems; + } + set + { + value ??= new AvaloniaList(); + + if (value.IsFixedSize) + { + throw new NotSupportedException("Cannot assign fixed size selection to SelectedItems."); + } + + if (_selectedItems != value) + { + UnsubscribeFromSelectedItems(); + _selectedItems = value; + SyncFromSelectedItems(); + SubscribeToSelectedItems(); + + if (ItemsView is null) + { + SetInitSelectedItems(value); + } + } + } + } + + private protected override void SetSource(IEnumerable? value) + { + try + { + _ignoreSelectedItemsChanges = true; + base.SetSource(value); + } + finally + { + _ignoreSelectedItemsChanges = false; + } + + SyncToSelectedItems(); + } + + private void SyncToSelectedItems() + { + if (_selectedItems is object) + { + try + { + _ignoreSelectedItemsChanges = true; + _selectedItems.Clear(); + + foreach (var i in base.SelectedItems) + { + _selectedItems.Add(i); + } + } + finally + { + _ignoreSelectedItemsChanges = false; + } + } + } + + private void SyncFromSelectedItems() + { + if (Source is null || _selectedItems is null) + { + return; + } + + try + { + _ignoreModelChanges = true; + + using (BatchUpdate()) + { + Clear(); + Add(_selectedItems); + } + } + finally + { + _ignoreModelChanges = false; + } + } + + private void SubscribeToSelectedItems() + { + if (_selectedItems is INotifyCollectionChanged incc) + { + incc.CollectionChanged += OnSelectedItemsCollectionChanged; + } + } + + private void UnsubscribeFromSelectedItems() + { + if (_selectedItems is INotifyCollectionChanged incc) + { + incc.CollectionChanged += OnSelectedItemsCollectionChanged; + } + } + + private void OnSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + if (_ignoreModelChanges) + { + return; + } + + try + { + var items = SelectedItems; + var deselected = e.DeselectedItems.ToList(); + var selected = e.SelectedItems.ToList(); + + _ignoreSelectedItemsChanges = true; + + foreach (var i in deselected) + { + items.Remove(i); + } + + foreach (var i in selected) + { + items.Add(i); + } + } + finally + { + _ignoreSelectedItemsChanges = false; + } + } + + private void OnSourceReset(object sender, EventArgs e) => SyncFromSelectedItems(); + + private void OnSelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_ignoreSelectedItemsChanges) + { + return; + } + + if (_selectedItems == null) + { + throw new AvaloniaInternalException("CollectionChanged raised but we don't have items."); + } + + void Remove() + { + foreach (var i in e.OldItems) + { + var index = IndexOf(Source, i); + + if (index != -1) + { + Deselect(index); + } + } + } + + try + { + using var operation = BatchUpdate(); + + _ignoreModelChanges = 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: + Clear(); + Add(_selectedItems); + break; + } + } + finally + { + _ignoreModelChanges = false; + } + } + + private void Add(IList newItems) + { + foreach (var i in newItems) + { + var index = IndexOf(Source, i); + + if (index != -1) + { + Select(index); + } + } + } + + 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/src/Avalonia.Controls/Selection/SelectionModel.cs b/src/Avalonia.Controls/Selection/SelectionModel.cs index 326bd10655..fd27cb340a 100644 --- a/src/Avalonia.Controls/Selection/SelectionModel.cs +++ b/src/Avalonia.Controls/Selection/SelectionModel.cs @@ -20,8 +20,7 @@ namespace Avalonia.Controls.Selection private SelectedItems? _selectedItems; private SelectedItems.Untyped? _selectedItemsUntyped; private EventHandler? _untypedSelectionChanged; - [AllowNull] private T _initSelectedItem = default; - private bool _hasInitSelectedItem; + private IList? _initSelectedItems; public SelectionModel() { @@ -82,7 +81,19 @@ namespace Avalonia.Controls.Selection [MaybeNull, AllowNull] public T SelectedItem { - get => ItemsView is object ? GetItemAt(_selectedIndex) : _initSelectedItem; + get + { + if (ItemsView is object) + { + return GetItemAt(_selectedIndex); + } + else if (_initSelectedItems is object && _initSelectedItems.Count > 0) + { + return (T)_initSelectedItems[0]; + } + + return default; + } set { if (ItemsView is object) @@ -92,8 +103,9 @@ namespace Avalonia.Controls.Selection else { Clear(); - _initSelectedItem = value; - _hasInitSelectedItem = true; +#pragma warning disable CS8601 + SetInitSelectedItems(new T[] { value }); +#pragma warning restore CS8601 } } } @@ -102,9 +114,10 @@ namespace Avalonia.Controls.Selection { get { - if (ItemsView is null && _hasInitSelectedItem) + if (ItemsView is null && _initSelectedItems is object) { - return new[] { _initSelectedItem }; + return _initSelectedItems is IReadOnlyList i ? + i : _initSelectedItems.Cast().ToList(); } return _selectedItems ??= new SelectedItems(this); @@ -258,8 +271,7 @@ namespace Avalonia.Controls.Selection o.SelectedIndex = -1; } - _initSelectedItem = default; - _hasInitSelectedItem = false; + _initSelectedItems = null; } public void SelectAll() => SelectRange(0, int.MaxValue); @@ -270,7 +282,7 @@ namespace Avalonia.Controls.Selection PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - private void SetSource(IEnumerable? value) + private protected virtual void SetSource(IEnumerable? value) { if (base.Source != value) { @@ -292,11 +304,14 @@ namespace Avalonia.Controls.Selection { update.Operation.IsSourceUpdate = true; - if (_hasInitSelectedItem) + if (_initSelectedItems is object && ItemsView is object) { - SelectedItem = _initSelectedItem; - _initSelectedItem = default; - _hasInitSelectedItem = false; + foreach (T i in _initSelectedItems) + { + Select(ItemsView.IndexOf(i)); + } + + _initSelectedItems = null; } else { @@ -466,6 +481,16 @@ namespace Avalonia.Controls.Selection return true; } + private protected void SetInitSelectedItems(IList items) + { + if (Source is object) + { + throw new InvalidOperationException("Cannot set init selected items when Source is set."); + } + + _initSelectedItems = items; + } + protected override void OnSourceCollectionChangeFinished() { if (_operation is object) @@ -539,8 +564,7 @@ namespace Avalonia.Controls.Selection o.SelectedIndex = o.AnchorIndex = start; } - _initSelectedItem = default; - _hasInitSelectedItem = false; + _initSelectedItems = null; } [return: MaybeNull] diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs deleted file mode 100644 index 83b62c7b6e..0000000000 --- a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs +++ /dev/null @@ -1,283 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Linq; -using Avalonia.Collections; -using Avalonia.Controls.Selection; - -#nullable enable - -namespace Avalonia.Controls.Utils -{ - /// - /// Synchronizes an with a list of SelectedItems. - /// - internal class SelectedItemsSync : IDisposable - { - private ISelectionModel _selectionModel; - private IList _selectedItems; - private bool _updatingItems; - private bool _updatingModel; - - public SelectedItemsSync(ISelectionModel model) - { - _selectionModel = model ?? throw new ArgumentNullException(nameof(model)); - _selectedItems = new AvaloniaList(); - SyncSelectedItemsWithSelectionModel(); - SubscribeToSelectedItems(_selectedItems); - SubscribeToSelectionModel(model); - } - - public ISelectionModel SelectionModel - { - get => _selectionModel; - set - { - if (_selectionModel != value) - { - value = value ?? throw new ArgumentNullException(nameof(value)); - UnsubscribeFromSelectionModel(_selectionModel); - _selectionModel = value; - SubscribeToSelectionModel(_selectionModel); - SyncSelectedItemsWithSelectionModel(); - } - } - } - - public IList SelectedItems - { - get => _selectedItems; - set - { - value ??= new AvaloniaList(); - - if (_selectedItems != value) - { - if (value.IsFixedSize) - { - throw new NotSupportedException( - "Cannot assign fixed size selection to SelectedItems."); - } - - UnsubscribeFromSelectedItems(_selectedItems); - _selectedItems = value; - SubscribeToSelectedItems(_selectedItems); - SyncSelectionModelWithSelectedItems(); - } - } - } - - public void Dispose() - { - UnsubscribeFromSelectedItems(_selectedItems); - UnsubscribeFromSelectionModel(_selectionModel); - } - - private void SyncSelectedItemsWithSelectionModel() - { - _updatingItems = true; - - try - { - _selectedItems.Clear(); - - if (_selectionModel.Source is object) - { - foreach (var i in _selectionModel.SelectedItems) - { - _selectedItems.Add(i); - } - } - } - finally - { - _updatingItems = false; - } - } - - private void SyncSelectionModelWithSelectedItems() - { - _updatingModel = true; - - try - { - if (_selectionModel.Source is object) - { - using (_selectionModel.BatchUpdate()) - { - SelectionModel.Clear(); - Add(_selectedItems); - } - } - } - finally - { - _updatingModel = false; - } - } - - private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - if (_updatingItems) - { - return; - } - - if (_selectedItems == null) - { - throw new AvaloniaInternalException("CollectionChanged raised but we don't have items."); - } - - void Remove() - { - foreach (var i in e.OldItems) - { - var index = IndexOf(SelectionModel.Source, i); - - if (index != -1) - { - SelectionModel.Deselect(index); - } - } - } - - try - { - using var operation = SelectionModel.BatchUpdate(); - - _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: - SelectionModel.Clear(); - Add(_selectedItems); - break; - } - } - finally - { - _updatingModel = false; - } - } - - private void Add(IList newItems) - { - foreach (var i in newItems) - { - var index = IndexOf(SelectionModel.Source, i); - - if (index != -1) - { - SelectionModel.Select(index); - } - } - } - - private void SelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(ISelectionModel.Source)) - { - if (_selectedItems.Count > 0) - { - SyncSelectionModelWithSelectedItems(); - } - else - { - SyncSelectedItemsWithSelectionModel(); - } - } - } - - private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) - { - if (_updatingModel || _selectionModel.Source is null) - { - return; - } - - try - { - var deselected = e.DeselectedItems.ToList(); - var selected = e.SelectedItems.ToList(); - - _updatingItems = true; - - foreach (var i in deselected) - { - _selectedItems.Remove(i); - } - - foreach (var i in selected) - { - _selectedItems.Add(i); - } - } - finally - { - _updatingItems = false; - } - } - - private void SelectionModelSourceReset(object sender, EventArgs e) - { - SyncSelectionModelWithSelectedItems(); - } - - - private void SubscribeToSelectedItems(IList selectedItems) - { - if (selectedItems is INotifyCollectionChanged incc) - { - incc.CollectionChanged += SelectedItemsCollectionChanged; - } - } - - private void SubscribeToSelectionModel(ISelectionModel model) - { - model.PropertyChanged += SelectionModelPropertyChanged; - model.SelectionChanged += SelectionModelSelectionChanged; - model.SourceReset += SelectionModelSourceReset; - } - - private void UnsubscribeFromSelectedItems(IList selectedItems) - { - if (selectedItems is INotifyCollectionChanged incc) - { - incc.CollectionChanged -= SelectedItemsCollectionChanged; - } - } - - private void UnsubscribeFromSelectionModel(ISelectionModel model) - { - model.PropertyChanged -= SelectionModelPropertyChanged; - model.SelectionChanged -= SelectionModelSelectionChanged; - model.SourceReset -= SelectionModelSourceReset; - } - - 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/Selection/InternalSelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/Selection/InternalSelectionModelTests.cs new file mode 100644 index 0000000000..0f3ecef969 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Selection/InternalSelectionModelTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Collections; +using Avalonia.Controls.Selection; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Selection +{ + public class InternalSelectionModelTests + { + [Fact] + public void Selecting_Item_Adds_To_SelectedItems() + { + var target = CreateTarget(); + + target.Select(0); + + Assert.Equal(new[] { "foo" }, target.SelectedItems); + } + + [Fact] + public void Selecting_Duplicate_On_Model_Adds_To_SelectedItems() + { + var target = CreateTarget(source: new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + + target.SelectRange(1, 4); + + Assert.Equal(new[] { "bar", "baz", "foo", "bar" }, target.SelectedItems); + } + + [Fact] + public void Deselecting_On_Model_Removes_SelectedItem() + { + var target = CreateTarget(); + + target.SelectRange(1, 2); + target.Deselect(1); + + Assert.Equal(new[] { "baz" }, target.SelectedItems); + } + + [Fact] + public void Deselecting_Duplicate_On_Model_Removes_SelectedItem() + { + var target = CreateTarget(source: new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + + target.SelectRange(1, 2); + target.Select(4); + target.Deselect(4); + + Assert.Equal(new[] { "baz", "bar" }, target.SelectedItems); + } + + [Fact] + public void Adding_To_SelectedItems_Selects_On_Model() + { + var target = CreateTarget(); + + target.SelectRange(1, 2); + target.SelectedItems.Add("foo"); + + Assert.Equal(new[] { 0, 1, 2 }, target.SelectedIndexes); + Assert.Equal(new[] { "bar", "baz", "foo" }, target.SelectedItems); + } + + [Fact] + public void Removing_From_SelectedItems_Deselects_On_Model() + { + var target = CreateTarget(); + + target.SelectRange(1, 2); + target.SelectedItems.Remove("baz"); + + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + } + + [Fact] + public void Replacing_SelectedItem_Updates_Model() + { + var target = CreateTarget(); + + target.SelectRange(1, 2); + target.SelectedItems[0] = "foo"; + + Assert.Equal(new[] { 0, 2 }, target.SelectedIndexes); + Assert.Equal(new[] { "foo", "baz" }, target.SelectedItems); + } + + [Fact] + public void Clearing_SelectedItems_Updates_Model() + { + var target = CreateTarget(); + + target.SelectedItems.Clear(); + + Assert.Empty(target.SelectedIndexes); + } + + [Fact] + public void Setting_SelectedItems_Updates_Model() + { + var target = CreateTarget(); + var oldItems = target.SelectedItems; + + var newItems = new AvaloniaList { "foo", "baz" }; + target.SelectedItems = newItems; + + Assert.Equal(new[] { 0, 2 }, target.SelectedIndexes); + Assert.Same(newItems, target.SelectedItems); + Assert.NotSame(oldItems, target.SelectedItems); + Assert.Equal(new[] { "foo", "baz" }, newItems); + } + + [Fact] + public void Setting_Items_To_Null_Clears_Selection() + { + var target = CreateTarget(); + + target.SelectRange(1, 2); + target.SelectedItems = null; + + Assert.Empty(target.SelectedIndexes); + } + + [Fact] + public void Setting_Items_To_Null_Creates_Empty_Items() + { + var target = CreateTarget(); + var oldItems = target.SelectedItems; + + target.SelectedItems = null; + + Assert.NotNull(target.SelectedItems); + Assert.NotSame(oldItems, target.SelectedItems); + Assert.IsType>(target.SelectedItems); + } + + [Fact] + public void Adds_Null_SelectedItems_When_Source_Is_Null() + { + var target = CreateTarget(nullSource: true); + + target.SelectRange(1, 2); + Assert.Equal(new object[] { null, null }, target.SelectedItems); + } + + [Fact] + public void Updates_SelectedItems_When_Source_Changes_From_Null() + { + var target = CreateTarget(nullSource: true); + + target.SelectRange(1, 2); + Assert.Equal(new object[] { null, null }, target.SelectedItems); + + target.Source = new[] { "foo", "bar", "baz" }; + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + } + + [Fact] + public void Updates_SelectedItems_When_Source_Changes_To_Null() + { + var target = CreateTarget(); + + target.SelectRange(1, 2); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + + target.Source = null; + Assert.Equal(new object[] { null, null }, target.SelectedItems); + } + + [Fact] + public void SelectedItems_Can_Be_Set_Before_Source() + { + var target = CreateTarget(nullSource: true); + var items = new AvaloniaList { "foo", "bar", "baz" }; + var selectedItems = new AvaloniaList { "bar" }; + + target.SelectedItems = selectedItems; + target.Source = items; + + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Does_Not_Accept_Fixed_Size_Items() + { + var target = CreateTarget(); + + Assert.Throws(() => + target.SelectedItems = new[] { "foo", "bar", "baz" }); + } + + [Fact] + public void Restores_Selection_On_Items_Reset() + { + var items = new ResettingCollection(new[] { "foo", "bar", "baz" }); + var target = CreateTarget(source: items); + + target.SelectedIndex = 1; + items.Reset(new[] { "baz", "foo", "bar" }); + + Assert.Equal(2, target.SelectedIndex); + } + + private static InternalSelectionModel CreateTarget( + bool singleSelect = false, + IList source = null, + bool nullSource = false) + { + source ??= !nullSource ? new[] { "foo", "bar", "baz" } : null; + + var result = new InternalSelectionModel + { + SingleSelect = singleSelect, + }; + + ((ISelectionModel)result).Source = source; + return result; + } + + private class ResettingCollection : List, INotifyCollectionChanged + { + public ResettingCollection(IEnumerable items) + { + AddRange(items); + } + + public void Reset(IEnumerable items) + { + Clear(); + AddRange(items); + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public event NotifyCollectionChangedEventHandler CollectionChanged; + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs deleted file mode 100644 index 3899d9dfbf..0000000000 --- a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using Avalonia.Collections; -using Avalonia.Controls.Selection; -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.SelectedItems; - - Assert.Equal(new[] { "bar", "baz" }, items); - } - - [Fact] - public void Selecting_On_Model_Adds_Item() - { - var target = CreateTarget(); - var items = target.SelectedItems; - - target.SelectionModel.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.SelectedItems; - - target.SelectionModel.Select(4); - - Assert.Equal(new[] { "bar", "baz", "bar" }, items); - } - - [Fact] - public void Deselecting_On_Model_Removes_Item() - { - var target = CreateTarget(); - var items = target.SelectedItems; - - target.SelectionModel.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.SelectedItems; - - target.SelectionModel.Select(4); - target.SelectionModel.Deselect(4); - - Assert.Equal(new[] { "baz", "bar" }, items); - } - - [Fact] - public void Reassigning_Model_Resets_Items() - { - var target = CreateTarget(); - var items = target.SelectedItems; - - var newModel = new SelectionModel - { - Source = (string[])target.SelectionModel.Source, - SingleSelect = false - }; - - newModel.Select(0); - newModel.Select(1); - - target.SelectionModel = newModel; - - Assert.Equal(new[] { "foo", "bar" }, items); - } - - [Fact] - public void Reassigning_Model_Tracks_New_Model() - { - var target = CreateTarget(); - var items = target.SelectedItems; - - var newModel = new SelectionModel - { - Source = (string[])target.SelectionModel.Source, - SingleSelect = false - }; - - target.SelectionModel = 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.SelectedItems; - - items.Add("foo"); - - Assert.Equal(new[] { 0, 1, 2 }, target.SelectionModel.SelectedIndexes); - Assert.Equal(new[] { "bar", "baz", "foo" }, items); - } - - [Fact] - public void Removing_From_Items_Deselects_On_Model() - { - var target = CreateTarget(); - var items = target.SelectedItems; - - items.Remove("baz"); - - Assert.Equal(new[] { 1 }, target.SelectionModel.SelectedIndexes); - Assert.Equal(new[] { "bar" }, items); - } - - [Fact] - public void Replacing_Item_Updates_Model() - { - var target = CreateTarget(); - var items = target.SelectedItems; - - items[0] = "foo"; - - Assert.Equal(new[] { 0, 2 }, target.SelectionModel.SelectedIndexes); - Assert.Equal(new[] { "foo", "baz" }, items); - } - - [Fact] - public void Clearing_Items_Updates_Model() - { - var target = CreateTarget(); - var items = target.SelectedItems; - - items.Clear(); - - Assert.Empty(target.SelectionModel.SelectedIndexes); - } - - [Fact] - public void Setting_Items_Updates_Model() - { - var target = CreateTarget(); - var oldItems = target.SelectedItems; - - var newItems = new AvaloniaList { "foo", "baz" }; - target.SelectedItems = newItems; - - Assert.Equal(new[] { 0, 2 }, target.SelectionModel.SelectedIndexes); - Assert.Same(newItems, target.SelectedItems); - Assert.NotSame(oldItems, target.SelectedItems); - Assert.Equal(new[] { "foo", "baz" }, newItems); - } - - [Fact] - public void Setting_Items_Subscribes_To_Model() - { - var target = CreateTarget(); - var items = new AvaloniaList { "foo", "baz" }; - - target.SelectedItems = items; - target.SelectionModel.Select(1); - - Assert.Equal(new[] { "foo", "baz", "bar" }, items); - } - - [Fact] - public void Setting_Items_To_Null_Creates_Empty_Items() - { - var target = CreateTarget(); - var oldItems = target.SelectedItems; - - target.SelectedItems = null; - - var newItems = Assert.IsType>(target.SelectedItems); - - Assert.NotSame(oldItems, newItems); - } - - [Fact] - public void Handles_Null_Model_Source() - { - var model = new SelectionModel { SingleSelect = false }; - model.Select(1); - - var target = new SelectedItemsSync(model); - var items = target.SelectedItems; - - 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.SelectedItems = new[] { "foo", "bar", "baz" }); - } - - [Fact] - public void Selected_Items_Can_Be_Set_Before_SelectionModel_Source() - { - var model = new SelectionModel(); - var target = new SelectedItemsSync(model); - var items = new AvaloniaList { "foo", "bar", "baz" }; - var selectedItems = new AvaloniaList { "bar" }; - - target.SelectedItems = selectedItems; - model.Source = items; - - Assert.Equal(1, model.SelectedIndex); - } - - [Fact] - public void Restores_Selection_On_Items_Reset() - { - var items = new ResettingCollection(new[] { "foo", "bar", "baz" }); - var model = new SelectionModel { Source = items }; - var target = new SelectedItemsSync(model); - - model.SelectedIndex = 1; - items.Reset(new[] { "baz", "foo", "bar" }); - - Assert.Equal(2, model.SelectedIndex); - } - - private static SelectedItemsSync CreateTarget( - IEnumerable items = null) - { - items ??= new[] { "foo", "bar", "baz" }; - - var model = new SelectionModel { Source = items, SingleSelect = false }; - model.SelectRange(1, 2); - - var target = new SelectedItemsSync(model); - return target; - } - - private class ResettingCollection : List, INotifyCollectionChanged - { - public ResettingCollection(IEnumerable items) - { - AddRange(items); - } - - public void Reset(IEnumerable items) - { - Clear(); - AddRange(items); - CollectionChanged?.Invoke( - this, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - public event NotifyCollectionChangedEventHandler CollectionChanged; - } - } -}