Browse Source

Reafactor SelectingItemsControl selection.

- Remove `SelectedItemsSync` and store `SelectedItems` in a new `InternalSelectionModel`
- Store transient `SelectingItemsControl` state in an `UpdateState` object

Fixes #4272
pull/4659/head
Steven Kirk 6 years ago
parent
commit
b73ba99077
  1. 225
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  2. 251
      src/Avalonia.Controls/Selection/InternalSelectionModel.cs
  3. 56
      src/Avalonia.Controls/Selection/SelectionModel.cs
  4. 283
      src/Avalonia.Controls/Utils/SelectedItemsSync.cs
  5. 243
      tests/Avalonia.Controls.UnitTests/Selection/InternalSelectionModelTests.cs
  6. 278
      tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs

225
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
/// <summary>
/// Defines the <see cref="SelectedItems"/> property.
/// </summary>
protected static readonly DirectProperty<SelectingItemsControl, IList> SelectedItemsProperty =
AvaloniaProperty.RegisterDirect<SelectingItemsControl, IList>(
protected static readonly DirectProperty<SelectingItemsControl, IList?> SelectedItemsProperty =
AvaloniaProperty.RegisterDirect<SelectingItemsControl, IList?>(
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<object>();
private SelectedItemsSync? _selectedItemsSync;
private ISelectionModel? _selection;
private int _oldSelectedIndex;
private object? _oldSelectedItem;
private int _initializing;
private bool _ignoreContainerSelectionChanged;
private UpdateState? _updateState;
/// <summary>
/// Initializes static members of the <see cref="SelectingItemsControl"/> class.
@ -149,8 +147,23 @@ namespace Avalonia.Controls.Primitives
/// </summary>
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;
}
}
}
/// <summary>
@ -158,17 +171,56 @@ namespace Avalonia.Controls.Primitives
/// </summary>
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;
}
}
}
/// <summary>
/// Gets or sets the selected items.
/// </summary>
protected IList SelectedItems
/// <remarks>
/// By default returns a collection that can be modified in order to manipulate the control
/// selection, however this property will return null if <see cref="Selection"/> is
/// re-assigned; you should only use _either_ Selection or SelectedItems.
/// </remarks>
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<IList?>(value);
}
else if (Selection is InternalSelectionModel i)
{
i.SelectedItems = value;
}
else
{
throw new InvalidOperationException("Cannot set both Selection and SelectedItems.");
}
}
}
/// <summary>
@ -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<ISelectionModel>(value);
}
else if (_selection != value)
{
if (value.Source != null && value.Source != Items)
{
@ -234,20 +297,18 @@ namespace Avalonia.Controls.Primitives
/// </summary>
protected bool AlwaysSelected => (SelectionMode & SelectionMode.AlwaysSelected) != 0;
private SelectedItemsSync SelectedItemsSync => _selectedItemsSync ??= new SelectedItemsSync(Selection);
/// <inheritdoc/>
public override void BeginInit()
{
base.BeginInit();
++_initializing;
BeginUpdating();
}
/// <inheritdoc/>
public override void EndInit()
{
base.EndInit();
--_initializing;
EndUpdating();
}
/// <summary>
@ -351,30 +412,14 @@ namespace Avalonia.Controls.Primitives
protected override void OnDataContextBeginUpdate()
{
base.OnDataContextBeginUpdate();
++_initializing;
if (_selection is object)
{
_selection.Source = null;
}
BeginUpdating();
}
/// <inheritdoc/>
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<IEnumerable>();
_selection.Source = newValue;
@ -789,7 +832,7 @@ namespace Avalonia.Controls.Primitives
private ISelectionModel CreateDefaultSelectionModel()
{
return new SelectionModel<object>
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<int> _selectedIndex;
private Optional<object?> _selectedItem;
public int UpdateCount { get; set; }
public Optional<ISelectionModel> Selection { get; set; }
public Optional<IList?> SelectedItems { get; set; }
public Optional<int> SelectedIndex
{
get => _selectedIndex;
set
{
_selectedIndex = value;
_selectedItem = default;
}
}
public Optional<object?> SelectedItem
{
get => _selectedItem;
set
{
_selectedItem = value;
_selectedIndex = default;
}
}
}
}
}

251
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<object?>
{
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<object?>();
SubscribeToSelectedItems();
}
return _selectedItems;
}
set
{
value ??= new AvaloniaList<object?>();
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;
}
}
}

56
src/Avalonia.Controls/Selection/SelectionModel.cs

@ -20,8 +20,7 @@ namespace Avalonia.Controls.Selection
private SelectedItems<T>? _selectedItems;
private SelectedItems<T>.Untyped? _selectedItemsUntyped;
private EventHandler<SelectionModelSelectionChangedEventArgs>? _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<T> i ?
i : _initSelectedItems.Cast<T>().ToList();
}
return _selectedItems ??= new SelectedItems<T>(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]

283
src/Avalonia.Controls/Utils/SelectedItemsSync.cs

@ -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
{
/// <summary>
/// Synchronizes an <see cref="ISelectionModel"/> with a list of SelectedItems.
/// </summary>
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<object?>();
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<object?>();
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;
}
}
}

243
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<string> { "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<AvaloniaList<object>>(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<string> { "foo", "bar", "baz" };
var selectedItems = new AvaloniaList<string> { "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<NotSupportedException>(() =>
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<string>, INotifyCollectionChanged
{
public ResettingCollection(IEnumerable<string> items)
{
AddRange(items);
}
public void Reset(IEnumerable<string> items)
{
Clear();
AddRange(items);
CollectionChanged?.Invoke(
this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public event NotifyCollectionChangedEventHandler CollectionChanged;
}
}
}

278
tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs

@ -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<string>
{
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<string>
{
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<string> { "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<string> { "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<AvaloniaList<object>>(target.SelectedItems);
Assert.NotSame(oldItems, newItems);
}
[Fact]
public void Handles_Null_Model_Source()
{
var model = new SelectionModel<string> { 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<NotSupportedException>(() =>
target.SelectedItems = new[] { "foo", "bar", "baz" });
}
[Fact]
public void Selected_Items_Can_Be_Set_Before_SelectionModel_Source()
{
var model = new SelectionModel<string>();
var target = new SelectedItemsSync(model);
var items = new AvaloniaList<string> { "foo", "bar", "baz" };
var selectedItems = new AvaloniaList<string> { "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<string> { 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<string> items = null)
{
items ??= new[] { "foo", "bar", "baz" };
var model = new SelectionModel<string> { Source = items, SingleSelect = false };
model.SelectRange(1, 2);
var target = new SelectedItemsSync(model);
return target;
}
private class ResettingCollection : List<string>, INotifyCollectionChanged
{
public ResettingCollection(IEnumerable<string> items)
{
AddRange(items);
}
public void Reset(IEnumerable<string> items)
{
Clear();
AddRange(items);
CollectionChanged?.Invoke(
this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public event NotifyCollectionChangedEventHandler CollectionChanged;
}
}
}
Loading…
Cancel
Save