diff --git a/build/SkiaSharp.props b/build/SkiaSharp.props index bbef48050e..d7d04c7971 100644 --- a/build/SkiaSharp.props +++ b/build/SkiaSharp.props @@ -1,6 +1,6 @@  - - + + diff --git a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs index e74d4cbcc8..f0241cad48 100644 --- a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs @@ -8,6 +8,7 @@ using System.Threading; using ReactiveUI; using Avalonia.Controls; using Avalonia.Metadata; +using Avalonia.Controls.Selection; namespace BindingDemo.ViewModels { @@ -29,7 +30,7 @@ namespace BindingDemo.ViewModels Detail = "Item " + x + " details", })); - Selection = new SelectionModel(); + Selection = new SelectionModel { SingleSelect = false }; ShuffleItems = ReactiveCommand.Create(() => { @@ -58,7 +59,7 @@ namespace BindingDemo.ViewModels } public ObservableCollection Items { get; } - public SelectionModel Selection { get; } + public SelectionModel Selection { get; } public ReactiveCommand ShuffleItems { get; } public string BooleanString diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml b/samples/ControlCatalog/Pages/TreeViewPage.xaml index 789b45e62c..6d99132680 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml @@ -10,7 +10,7 @@ HorizontalAlignment="Center" Spacing="16"> - + diff --git a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs index 6bdb5c0103..d088576998 100644 --- a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs +++ b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Reactive; using Avalonia.Controls; +using Avalonia.Controls.Selection; using ReactiveUI; namespace ControlCatalog.ViewModels @@ -15,16 +16,16 @@ namespace ControlCatalog.ViewModels public ListBoxPageViewModel() { Items = new ObservableCollection(Enumerable.Range(1, 10000).Select(i => GenerateItem())); - Selection = new SelectionModel(); + Selection = new SelectionModel(); Selection.Select(1); AddItemCommand = ReactiveCommand.Create(() => Items.Add(GenerateItem())); RemoveItemCommand = ReactiveCommand.Create(() => { - while (Selection.SelectedItems.Count > 0) + while (Selection.Count > 0) { - Items.Remove((string)Selection.SelectedItems.First()); + Items.Remove(Selection.SelectedItems.First()); } }); @@ -32,9 +33,9 @@ namespace ControlCatalog.ViewModels { var random = new Random(); - using (Selection.Update()) + using (Selection.BatchUpdate()) { - Selection.ClearSelection(); + Selection.Clear(); Selection.Select(random.Next(Items.Count - 1)); } }); @@ -42,7 +43,7 @@ namespace ControlCatalog.ViewModels public ObservableCollection Items { get; } - public SelectionModel Selection { get; } + public SelectionModel Selection { get; } public ReactiveCommand AddItemCommand { get; } @@ -55,7 +56,7 @@ namespace ControlCatalog.ViewModels get => _selectionMode; set { - Selection.ClearSelection(); + Selection.Clear(); this.RaiseAndSetIfChanged(ref _selectionMode, value); } } diff --git a/samples/ControlCatalog/ViewModels/TreeViewPageViewModel.cs b/samples/ControlCatalog/ViewModels/TreeViewPageViewModel.cs index 5bc23e2fe5..210e281ed6 100644 --- a/samples/ControlCatalog/ViewModels/TreeViewPageViewModel.cs +++ b/samples/ControlCatalog/ViewModels/TreeViewPageViewModel.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reactive; @@ -18,8 +17,7 @@ namespace ControlCatalog.ViewModels _root = new Node(); Items = _root.Children; - Selection = new SelectionModel(); - Selection.SelectionChanged += SelectionChanged; + SelectedItems = new ObservableCollection(); AddItemCommand = ReactiveCommand.Create(AddItem); RemoveItemCommand = ReactiveCommand.Create(RemoveItem); @@ -27,7 +25,7 @@ namespace ControlCatalog.ViewModels } public ObservableCollection Items { get; } - public SelectionModel Selection { get; } + public ObservableCollection SelectedItems { get; } public ReactiveCommand AddItemCommand { get; } public ReactiveCommand RemoveItemCommand { get; } public ReactiveCommand SelectRandomItemCommand { get; } @@ -37,24 +35,24 @@ namespace ControlCatalog.ViewModels get => _selectionMode; set { - Selection.ClearSelection(); + SelectedItems.Clear(); this.RaiseAndSetIfChanged(ref _selectionMode, value); } } private void AddItem() { - var parentItem = Selection.SelectedItems.Count > 0 ? (Node)Selection.SelectedItems[0] : _root; + var parentItem = SelectedItems.Count > 0 ? (Node)SelectedItems[0] : _root; parentItem.AddItem(); } private void RemoveItem() { - while (Selection.SelectedItems.Count > 0) + while (SelectedItems.Count > 0) { - Node lastItem = (Node)Selection.SelectedItems[0]; + Node lastItem = (Node)SelectedItems[0]; RecursiveRemove(Items, lastItem); - Selection.DeselectAt(Selection.SelectedIndices[0]); + SelectedItems.RemoveAt(0); } bool RecursiveRemove(ObservableCollection items, Node selectedItem) @@ -80,16 +78,16 @@ namespace ControlCatalog.ViewModels { var random = new Random(); var depth = random.Next(4); - var indexes = Enumerable.Range(0, 4).Select(x => random.Next(10)); - var path = new IndexPath(indexes); - Selection.SelectedIndex = path; - } + var indexes = Enumerable.Range(0, depth).Select(x => random.Next(10)); + var node = _root; - private void SelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) - { - var selected = string.Join(",", e.SelectedIndices); - var deselected = string.Join(",", e.DeselectedIndices); - System.Diagnostics.Debug.WriteLine($"Selected '{selected}', Deselected '{deselected}'"); + foreach (var i in indexes) + { + node = node.Children[i]; + } + + SelectedItems.Clear(); + SelectedItems.Add(node); } public class Node diff --git a/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs b/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs index 3a6ed88fcd..852c01399f 100644 --- a/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs @@ -7,6 +7,7 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using ReactiveUI; using Avalonia.Layout; +using Avalonia.Controls.Selection; namespace VirtualizationDemo.ViewModels { @@ -48,7 +49,7 @@ namespace VirtualizationDemo.ViewModels set { this.RaiseAndSetIfChanged(ref _itemCount, value); } } - public SelectionModel Selection { get; } = new SelectionModel(); + public SelectionModel Selection { get; } = new SelectionModel(); public AvaloniaList Items { @@ -137,9 +138,9 @@ namespace VirtualizationDemo.ViewModels { var index = Items.Count; - if (Selection.SelectedIndices.Count > 0) + if (Selection.SelectedItems.Count > 0) { - index = Selection.SelectedIndex.GetAt(0); + index = Selection.SelectedIndex; } Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString)); @@ -149,7 +150,7 @@ namespace VirtualizationDemo.ViewModels { if (Selection.SelectedItems.Count > 0) { - Items.RemoveAll(Selection.SelectedItems.Cast().ToList()); + Items.RemoveAll(Selection.SelectedItems.ToList()); } } @@ -163,7 +164,7 @@ namespace VirtualizationDemo.ViewModels private void SelectItem(int index) { - Selection.SelectedIndex = new IndexPath(index); + Selection.SelectedIndex = index; } } } diff --git a/src/Avalonia.Controls/ApiCompatBaseline.txt b/src/Avalonia.Controls/ApiCompatBaseline.txt new file mode 100644 index 0000000000..11708b360f --- /dev/null +++ b/src/Avalonia.Controls/ApiCompatBaseline.txt @@ -0,0 +1,18 @@ +Compat issues with assembly Avalonia.Controls: +TypesMustExist : Type 'Avalonia.Controls.IndexPath' does not exist in the implementation but it does exist in the contract. +TypesMustExist : Type 'Avalonia.Controls.ISelectedItemInfo' does not exist in the implementation but it does exist in the contract. +TypesMustExist : Type 'Avalonia.Controls.ISelectionModel' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.DirectProperty Avalonia.DirectProperty Avalonia.Controls.ListBox.SelectionProperty' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.Controls.ISelectionModel Avalonia.Controls.ListBox.Selection.get()' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public void Avalonia.Controls.ListBox.Selection.set(Avalonia.Controls.ISelectionModel)' does not exist in the implementation but it does exist in the contract. +TypesMustExist : Type 'Avalonia.Controls.SelectionModel' does not exist in the implementation but it does exist in the contract. +TypesMustExist : Type 'Avalonia.Controls.SelectionModelChildrenRequestedEventArgs' does not exist in the implementation but it does exist in the contract. +TypesMustExist : Type 'Avalonia.Controls.SelectionModelSelectionChangedEventArgs' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.DirectProperty Avalonia.DirectProperty Avalonia.Controls.TreeView.SelectionProperty' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.Interactivity.RoutedEvent Avalonia.Interactivity.RoutedEvent Avalonia.Controls.TreeView.SelectionChangedEvent' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.Controls.ISelectionModel Avalonia.Controls.TreeView.Selection.get()' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public void Avalonia.Controls.TreeView.Selection.set(Avalonia.Controls.ISelectionModel)' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'public Avalonia.DirectProperty Avalonia.DirectProperty Avalonia.Controls.Primitives.SelectingItemsControl.SelectionProperty' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'protected Avalonia.Controls.ISelectionModel Avalonia.Controls.Primitives.SelectingItemsControl.Selection.get()' does not exist in the implementation but it does exist in the contract. +MembersMustExist : Member 'protected void Avalonia.Controls.Primitives.SelectingItemsControl.Selection.set(Avalonia.Controls.ISelectionModel)' does not exist in the implementation but it does exist in the contract. +Total Issues: 16 diff --git a/src/Avalonia.Controls/Avalonia.Controls.csproj b/src/Avalonia.Controls/Avalonia.Controls.csproj index 480dcfcb85..7f1f4bc8f3 100644 --- a/src/Avalonia.Controls/Avalonia.Controls.csproj +++ b/src/Avalonia.Controls/Avalonia.Controls.csproj @@ -2,6 +2,9 @@ netstandard2.0 + + + diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 9bb4cc4816..c4df5c1815 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -279,6 +279,11 @@ namespace Avalonia.Controls ((ISetLogicalParent)_popup).SetParent(control); } + if (PlacementTarget is null && _popup.PlacementTarget != control) + { + _popup.PlacementTarget = control; + } + _popup.Child = this; IsOpen = true; _popup.IsOpen = true; diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs deleted file mode 100644 index 6570921c03..0000000000 --- a/src/Avalonia.Controls/ISelectionModel.cs +++ /dev/null @@ -1,249 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections.Generic; -using System.ComponentModel; - -namespace Avalonia.Controls -{ - /// - /// Holds the selected items for a control. - /// - public interface ISelectionModel : INotifyPropertyChanged - { - /// - /// Gets or sets the anchor index. - /// - IndexPath AnchorIndex { get; set; } - - /// - /// Gets or set the index of the first selected item. - /// - IndexPath SelectedIndex { get; set; } - - /// - /// Gets or set the indexes of the selected items. - /// - IReadOnlyList SelectedIndices { get; } - - /// - /// Gets the first selected item. - /// - object SelectedItem { get; } - - /// - /// Gets the selected items. - /// - IReadOnlyList SelectedItems { get; } - - /// - /// Gets a value indicating whether the model represents a single or multiple selection. - /// - bool SingleSelect { get; set; } - - /// - /// Gets a value indicating whether to always keep an item selected where possible. - /// - bool AutoSelect { get; set; } - - /// - /// Gets or sets the collection that contains the items that can be selected. - /// - object Source { get; set; } - - /// - /// Raised when the children of a selection are required. - /// - event EventHandler ChildrenRequested; - - /// - /// Raised when the selection has changed. - /// - event EventHandler SelectionChanged; - - /// - /// Clears the selection. - /// - void ClearSelection(); - - /// - /// Deselects an item. - /// - /// The index of the item. - void Deselect(int index); - - /// - /// Deselects an item. - /// - /// The index of the item group. - /// The index of the item in the group. - void Deselect(int groupIndex, int itemIndex); - - /// - /// Deselects an item. - /// - /// The index of the item. - void DeselectAt(IndexPath index); - - /// - /// Deselects a range of items. - /// - /// The start index of the range. - /// The end index of the range. - void DeselectRange(IndexPath start, IndexPath end); - - /// - /// Deselects a range of items, starting at . - /// - /// The end index of the range. - void DeselectRangeFromAnchor(int index); - - /// - /// Deselects a range of items, starting at . - /// - /// - /// The index of the item group that represents the end of the selection. - /// - /// - /// The index of the item in the group that represents the end of the selection. - /// - void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex); - - /// - /// Deselects a range of items, starting at . - /// - /// The end index of the range. - void DeselectRangeFromAnchorTo(IndexPath index); - - /// - /// Disposes the object and clears the selection. - /// - void Dispose(); - - /// - /// Checks whether an item is selected. - /// - /// The index of the item - bool IsSelected(int index); - - /// - /// Checks whether an item is selected. - /// - /// The index of the item group. - /// The index of the item in the group. - bool IsSelected(int groupIndex, int itemIndex); - - /// - /// Checks whether an item is selected. - /// - /// The index of the item - public bool IsSelectedAt(IndexPath index); - - /// - /// Checks whether an item or its descendents are selected. - /// - /// The index of the item - /// - /// True if the item and all its descendents are selected, false if the item and all its - /// descendents are deselected, or null if a combination of selected and deselected. - /// - bool? IsSelectedWithPartial(int index); - - /// - /// Checks whether an item or its descendents are selected. - /// - /// The index of the item group. - /// The index of the item in the group. - /// - /// True if the item and all its descendents are selected, false if the item and all its - /// descendents are deselected, or null if a combination of selected and deselected. - /// - bool? IsSelectedWithPartial(int groupIndex, int itemIndex); - - /// - /// Checks whether an item or its descendents are selected. - /// - /// The index of the item - /// - /// True if the item and all its descendents are selected, false if the item and all its - /// descendents are deselected, or null if a combination of selected and deselected. - /// - bool? IsSelectedWithPartialAt(IndexPath index); - - /// - /// Selects an item. - /// - /// The index of the item - void Select(int index); - - /// - /// Selects an item. - /// - /// The index of the item group. - /// The index of the item in the group. - void Select(int groupIndex, int itemIndex); - - /// - /// Selects an item. - /// - /// The index of the item - void SelectAt(IndexPath index); - - /// - /// Selects all items. - /// - void SelectAll(); - - /// - /// Selects a range of items. - /// - /// The start index of the range. - /// The end index of the range. - void SelectRange(IndexPath start, IndexPath end); - - /// - /// Selects a range of items, starting at . - /// - /// The end index of the range. - void SelectRangeFromAnchor(int index); - - /// - /// Selects a range of items, starting at . - /// - /// - /// The index of the item group that represents the end of the selection. - /// - /// - /// The index of the item in the group that represents the end of the selection. - /// - void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex); - - /// - /// Selects a range of items, starting at . - /// - /// The end index of the range. - void SelectRangeFromAnchorTo(IndexPath index); - - /// - /// Sets the . - /// - /// The anchor index. - void SetAnchorIndex(int index); - - /// - /// Sets the . - /// - /// The index of the item group. - /// The index of the item in the group. - void SetAnchorIndex(int groupIndex, int index); - - /// - /// Begins a batch update of the selection. - /// - /// An that finishes the batch update. - IDisposable Update(); - } -} diff --git a/src/Avalonia.Controls/IndexPath.cs b/src/Avalonia.Controls/IndexPath.cs deleted file mode 100644 index 73b75bc23d..0000000000 --- a/src/Avalonia.Controls/IndexPath.cs +++ /dev/null @@ -1,200 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections.Generic; -using System.Linq; - -#nullable enable - -namespace Avalonia.Controls -{ - public readonly struct IndexPath : IComparable, IEquatable - { - public static readonly IndexPath Unselected = default; - - private readonly int _index; - private readonly int[]? _path; - - public IndexPath(int index) - { - _index = index + 1; - _path = null; - } - - public IndexPath(int groupIndex, int itemIndex) - { - _index = 0; - _path = new[] { groupIndex, itemIndex }; - } - - public IndexPath(IEnumerable? indices) - { - if (indices != null) - { - _index = 0; - _path = indices.ToArray(); - } - else - { - _index = 0; - _path = null; - } - } - - private IndexPath(int[] basePath, int index) - { - basePath = basePath ?? throw new ArgumentNullException(nameof(basePath)); - - _index = 0; - _path = new int[basePath.Length + 1]; - Array.Copy(basePath, _path, basePath.Length); - _path[basePath.Length] = index; - } - - public int GetSize() => _path?.Length ?? (_index == 0 ? 0 : 1); - - public int GetAt(int index) - { - if (index >= GetSize()) - { - throw new IndexOutOfRangeException(); - } - - return _path?[index] ?? (_index - 1); - } - - public int CompareTo(IndexPath other) - { - var rhsPath = other; - int compareResult = 0; - int lhsCount = GetSize(); - int rhsCount = rhsPath.GetSize(); - - if (lhsCount == 0 || rhsCount == 0) - { - // one of the paths are empty, compare based on size - compareResult = (lhsCount - rhsCount); - } - else - { - // both paths are non-empty, but can be of different size - for (int i = 0; i < Math.Min(lhsCount, rhsCount); i++) - { - if (GetAt(i) < rhsPath.GetAt(i)) - { - compareResult = -1; - break; - } - else if (GetAt(i) > rhsPath.GetAt(i)) - { - compareResult = 1; - break; - } - } - - // if both match upto min(lhsCount, rhsCount), compare based on size - compareResult = compareResult == 0 ? (lhsCount - rhsCount) : compareResult; - } - - if (compareResult != 0) - { - compareResult = compareResult > 0 ? 1 : -1; - } - - return compareResult; - } - - public IndexPath CloneWithChildIndex(int childIndex) - { - if (_path != null) - { - return new IndexPath(_path, childIndex); - } - else if (_index != 0) - { - return new IndexPath(_index - 1, childIndex); - } - else - { - return new IndexPath(childIndex); - } - } - - public bool IsAncestorOf(in IndexPath other) - { - if (other.GetSize() <= GetSize()) - { - return false; - } - - var size = GetSize(); - - for (int i = 0; i < size; i++) - { - if (GetAt(i) != other.GetAt(i)) - { - return false; - } - } - - return true; - } - - public override string ToString() - { - if (_path != null) - { - return "R" + string.Join(".", _path); - } - else if (_index != 0) - { - return "R" + (_index - 1); - } - else - { - return "R"; - } - } - - public static IndexPath CreateFrom(int index) => new IndexPath(index); - - public static IndexPath CreateFrom(int groupIndex, int itemIndex) => new IndexPath(groupIndex, itemIndex); - - public static IndexPath CreateFromIndices(IList indices) => new IndexPath(indices); - - public override bool Equals(object obj) => obj is IndexPath other && Equals(other); - - public bool Equals(IndexPath other) => CompareTo(other) == 0; - - public override int GetHashCode() - { - var hashCode = -504981047; - - if (_path != null) - { - foreach (var i in _path) - { - hashCode = hashCode * -1521134295 + i.GetHashCode(); - } - } - else - { - hashCode = hashCode * -1521134295 + _index.GetHashCode(); - } - - return hashCode; - } - - public static bool operator <(IndexPath x, IndexPath y) { return x.CompareTo(y) < 0; } - public static bool operator >(IndexPath x, IndexPath y) { return x.CompareTo(y) > 0; } - public static bool operator <=(IndexPath x, IndexPath y) { return x.CompareTo(y) <= 0; } - public static bool operator >=(IndexPath x, IndexPath y) { return x.CompareTo(y) >= 0; } - public static bool operator ==(IndexPath x, IndexPath y) { return x.CompareTo(y) == 0; } - public static bool operator !=(IndexPath x, IndexPath y) { return x.CompareTo(y) != 0; } - public static bool operator ==(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) == 0; } - public static bool operator !=(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) != 0; } - } -} diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 1aa7945901..a3dfe33641 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -18,7 +18,7 @@ namespace Avalonia.Controls /// /// Displays a collection of items. /// - public class ItemsControl : TemplatedControl, IItemsPresenterHost + public class ItemsControl : TemplatedControl, IItemsPresenterHost, ICollectionChangedListener { /// /// The default value for the property. @@ -53,7 +53,6 @@ namespace Avalonia.Controls private IEnumerable _items = new AvaloniaList(); private int _itemCount; private IItemContainerGenerator _itemContainerGenerator; - private IDisposable _itemsCollectionChangedSubscription; /// /// Initializes static members of the class. @@ -150,6 +149,19 @@ namespace Avalonia.Controls ItemContainerGenerator.Clear(); } + void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + } + + void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + } + + void ICollectionChangedListener.PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + ItemsCollectionChanged(sender, e); + } + /// /// Gets the item at the specified index in a collection. /// @@ -315,12 +327,14 @@ namespace Avalonia.Controls /// The event args. protected virtual void ItemsChanged(AvaloniaPropertyChangedEventArgs e) { - _itemsCollectionChangedSubscription?.Dispose(); - _itemsCollectionChangedSubscription = null; - var oldValue = e.OldValue as IEnumerable; var newValue = e.NewValue as IEnumerable; + if (oldValue is INotifyCollectionChanged incc) + { + CollectionChangedEventManager.Instance.RemoveListener(incc, this); + } + UpdateItemCount(); RemoveControlItemsFromLogicalChildren(oldValue); AddControlItemsToLogicalChildren(newValue); @@ -418,11 +432,9 @@ namespace Avalonia.Controls PseudoClasses.Set(":empty", items == null || items.Count() == 0); PseudoClasses.Set(":singleitem", items != null && items.Count() == 1); - var incc = items as INotifyCollectionChanged; - - if (incc != null) + if (items is INotifyCollectionChanged incc) { - _itemsCollectionChangedSubscription = incc.WeakSubscribe(ItemsCollectionChanged); + CollectionChangedEventManager.Instance.AddListener(incc, this); } } diff --git a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs b/src/Avalonia.Controls/ItemsSourceView.cs similarity index 52% rename from src/Avalonia.Controls/Repeater/ItemsSourceView.cs rename to src/Avalonia.Controls/ItemsSourceView.cs index def9301e2d..b2663f3213 100644 --- a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs +++ b/src/Avalonia.Controls/ItemsSourceView.cs @@ -7,7 +7,11 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using Avalonia.Controls.Utils; + +#nullable enable namespace Avalonia.Controls { @@ -23,8 +27,13 @@ namespace Avalonia.Controls /// public class ItemsSourceView : INotifyCollectionChanged, IDisposable { - private readonly IList _inner; - private INotifyCollectionChanged _notifyCollectionChanged; + /// + /// Gets an empty + /// + public static ItemsSourceView Empty { get; } = new ItemsSourceView(Array.Empty()); + + private protected readonly IList _inner; + private INotifyCollectionChanged? _notifyCollectionChanged; /// /// Initializes a new instance of the ItemsSourceView class for the specified data source. @@ -32,7 +41,7 @@ namespace Avalonia.Controls /// The data source. public ItemsSourceView(IEnumerable source) { - Contract.Requires(source != null); + source = source ?? throw new ArgumentNullException(nameof(source)); if (source is IList list) { @@ -63,10 +72,17 @@ namespace Avalonia.Controls /// public bool HasKeyIndexMapping => false; + /// + /// Retrieves the item at the specified index. + /// + /// The index. + /// The item. + public object? this[int index] => GetAt(index); + /// /// Occurs when the collection has changed to indicate the reason for the change and which items changed. /// - public event NotifyCollectionChangedEventHandler CollectionChanged; + public event NotifyCollectionChangedEventHandler? CollectionChanged; /// public void Dispose() @@ -81,10 +97,26 @@ namespace Avalonia.Controls /// Retrieves the item at the specified index. /// /// The index. - /// the item. - public object GetAt(int index) => _inner[index]; + /// The item. + public object? GetAt(int index) => _inner[index]; - public int IndexOf(object item) => _inner.IndexOf(item); + public int IndexOf(object? item) => _inner.IndexOf(item); + + public static ItemsSourceView GetOrCreate(IEnumerable? items) + { + if (items is ItemsSourceView isv) + { + return isv; + } + else if (items is null) + { + return Empty; + } + else + { + return new ItemsSourceView(items); + } + } /// /// Retrieves the index of the item that has the specified unique identifier (key). @@ -112,6 +144,22 @@ namespace Avalonia.Controls throw new NotImplementedException(); } + internal void AddListener(ICollectionChangedListener listener) + { + if (_inner is INotifyCollectionChanged incc) + { + CollectionChangedEventManager.Instance.AddListener(incc, listener); + } + } + + internal void RemoveListener(ICollectionChangedListener listener) + { + if (_inner is INotifyCollectionChanged incc) + { + CollectionChangedEventManager.Instance.RemoveListener(incc, listener); + } + } + protected void OnItemsSourceChanged(NotifyCollectionChangedEventArgs args) { CollectionChanged?.Invoke(this, args); @@ -131,4 +179,62 @@ namespace Avalonia.Controls OnItemsSourceChanged(e); } } + + public class ItemsSourceView : ItemsSourceView, IReadOnlyList + { + /// + /// Gets an empty + /// + public new static ItemsSourceView Empty { get; } = new ItemsSourceView(Array.Empty()); + + /// + /// Initializes a new instance of the ItemsSourceView class for the specified data source. + /// + /// The data source. + public ItemsSourceView(IEnumerable source) + : base(source) + { + } + + private ItemsSourceView(IEnumerable source) + : base(source) + { + } + + /// + /// Retrieves the item at the specified index. + /// + /// The index. + /// The item. +#pragma warning disable CS8603 + public new T this[int index] => GetAt(index); +#pragma warning restore CS8603 + + /// + /// Retrieves the item at the specified index. + /// + /// The index. + /// The item. + [return: MaybeNull] + public new T GetAt(int index) => (T)_inner[index]; + + public IEnumerator GetEnumerator() => _inner.Cast().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _inner.GetEnumerator(); + + public static new ItemsSourceView GetOrCreate(IEnumerable? items) + { + if (items is ItemsSourceView isv) + { + return isv; + } + else if (items is null) + { + return Empty; + } + else + { + return new ItemsSourceView(items); + } + } + } } diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index a085bfb6bc..f7e86d697a 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -2,6 +2,7 @@ using System.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Selection; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.VisualTree; @@ -76,9 +77,7 @@ namespace Avalonia.Controls set => base.SelectedItems = value; } - /// - /// Gets or sets a model holding the current selection. - /// + /// public new ISelectionModel Selection { get => base.Selection; @@ -115,7 +114,7 @@ namespace Avalonia.Controls /// /// Deselects all items in the . /// - public void UnselectAll() => Selection.ClearSelection(); + public void UnselectAll() => Selection.Clear(); /// protected override IItemContainerGenerator CreateItemContainerGenerator() diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 59b7777b1b..5f8c5da2f8 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -3,9 +3,9 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; -using System.Diagnostics; using System.Linq; using Avalonia.Controls.Generators; +using Avalonia.Controls.Selection; using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; @@ -13,6 +13,8 @@ using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Controls.Primitives { /// @@ -24,8 +26,8 @@ namespace Avalonia.Controls.Primitives /// that maintain a selection (single or multiple). By default only its /// and properties are visible; the /// current multiple and together with the - /// and properties are protected, however a derived class can - /// expose these if it wishes to support multiple selection. + /// properties are protected, however a derived class can expose + /// these if it wishes to support multiple selection. /// /// /// maintains a selection respecting the current @@ -58,8 +60,8 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - public static readonly DirectProperty SelectedItemProperty = - AvaloniaProperty.RegisterDirect( + public static readonly DirectProperty SelectedItemProperty = + AvaloniaProperty.RegisterDirect( nameof(SelectedItem), o => o.SelectedItem, (o, v) => o.SelectedItem = v, @@ -77,7 +79,7 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - public static readonly DirectProperty SelectionProperty = + protected static readonly DirectProperty SelectionProperty = AvaloniaProperty.RegisterDirect( nameof(Selection), o => o.Selection, @@ -109,21 +111,12 @@ namespace Avalonia.Controls.Primitives RoutingStrategies.Bubble); private static readonly IList Empty = Array.Empty(); - private readonly SelectedItemsSync _selectedItems; - private ISelectionModel _selection; - private int _selectedIndex = -1; - private object _selectedItem; + private SelectedItemsSync? _selectedItemsSync; + private ISelectionModel? _selection; + private int _oldSelectedIndex; + private object? _oldSelectedItem; + private int _initializing; private bool _ignoreContainerSelectionChanged; - private int _updateCount; - private int _updateSelectedIndex; - private object _updateSelectedItem; - - public SelectingItemsControl() - { - // Setting Selection to null causes a default SelectionModel to be created. - Selection = null; - _selectedItems = new SelectedItemsSync(Selection); - } /// /// Initializes static members of the class. @@ -156,42 +149,17 @@ namespace Avalonia.Controls.Primitives /// public int SelectedIndex { - get => Selection.SelectedIndex != default ? Selection.SelectedIndex.GetAt(0) : -1; - set - { - if (_updateCount == 0) - { - if (value != SelectedIndex) - { - Selection.SelectedIndex = new IndexPath(value); - } - } - else - { - _updateSelectedIndex = value; - _updateSelectedItem = null; - } - } + get => Selection.SelectedIndex; + set => Selection.SelectedIndex = value; } /// /// Gets or sets the selected item. /// - public object SelectedItem + public object? SelectedItem { get => Selection.SelectedItem; - set - { - if (_updateCount == 0) - { - SelectedIndex = IndexOf(Items, value); - } - else - { - _updateSelectedItem = value; - _updateSelectedIndex = int.MinValue; - } - } + set => Selection.SelectedItem = value; } /// @@ -199,46 +167,40 @@ namespace Avalonia.Controls.Primitives /// protected IList SelectedItems { - get => _selectedItems.GetOrCreateItems(); - set => _selectedItems.SetItems(value); + get => SelectedItemsSync.SelectedItems; + set => SelectedItemsSync.SelectedItems = value; } /// - /// Gets or sets a model holding the current selection. + /// Gets or sets the model that holds the current selection. /// - protected ISelectionModel Selection + protected ISelectionModel Selection { - get => _selection; - set + get { - value ??= new SelectionModel + if (_selection is null) { - SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), - AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected), - RetainSelectionOnReset = true, - }; + _selection = CreateDefaultSelectionModel(); + InitializeSelectionModel(_selection); + } + + return _selection; + } + set + { + value ??= CreateDefaultSelectionModel(); if (_selection != value) { - if (value == null) - { - throw new ArgumentNullException(nameof(value), "Cannot set Selection to null."); - } - else if (value.Source != null && value.Source != Items) - { - throw new ArgumentException("Selection has invalid Source."); - } - - List oldSelection = null; - - if (_selection != null) + if (value.Source != null && value.Source != Items) { - oldSelection = Selection.SelectedItems.ToList(); - _selection.PropertyChanged -= OnSelectionModelPropertyChanged; - _selection.SelectionChanged -= OnSelectionModelSelectionChanged; - MarkContainersUnselected(); + throw new ArgumentException( + "The supplied ISelectionModel already has an assigned Source but this " + + "collection is different to the Items on the control."); } + var oldSelection = _selection?.SelectedItems.ToList(); + DeinitializeSelectionModel(_selection); _selection = value; if (oldSelection?.Count > 0) @@ -249,55 +211,7 @@ namespace Avalonia.Controls.Primitives Array.Empty())); } - if (_selection != null) - { - _selection.Source = Items; - _selection.PropertyChanged += OnSelectionModelPropertyChanged; - _selection.SelectionChanged += OnSelectionModelSelectionChanged; - - if (_selection.SingleSelect) - { - SelectionMode &= ~SelectionMode.Multiple; - } - else - { - SelectionMode |= SelectionMode.Multiple; - } - - if (_selection.AutoSelect) - { - SelectionMode |= SelectionMode.AlwaysSelected; - } - else - { - SelectionMode &= ~SelectionMode.AlwaysSelected; - } - - UpdateContainerSelection(); - - var selectedIndex = SelectedIndex; - var selectedItem = SelectedItem; - - if (_selectedIndex != selectedIndex) - { - RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, selectedIndex); - _selectedIndex = selectedIndex; - } - - if (_selectedItem != selectedItem) - { - RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem); - _selectedItem = selectedItem; - } - - if (selectedIndex != -1) - { - RaiseEvent(new SelectionChangedEventArgs( - SelectionChangedEvent, - Array.Empty(), - Selection.SelectedItems.ToList())); - } - } + InitializeSelectionModel(_selection); } } } @@ -320,20 +234,20 @@ namespace Avalonia.Controls.Primitives /// protected bool AlwaysSelected => (SelectionMode & SelectionMode.AlwaysSelected) != 0; + private SelectedItemsSync SelectedItemsSync => _selectedItemsSync ??= new SelectedItemsSync(Selection); + /// public override void BeginInit() { base.BeginInit(); - - InternalBeginInit(); + ++_initializing; } /// public override void EndInit() { - InternalEndInit(); - base.EndInit(); + --_initializing; } /// @@ -353,7 +267,7 @@ namespace Avalonia.Controls.Primitives /// /// The control that raised the event. /// The container or null if the event did not originate in a container. - protected IControl GetContainerFromEventSource(IInteractive eventSource) + protected IControl? GetContainerFromEventSource(IInteractive eventSource) { var parent = (IVisual)eventSource; @@ -371,21 +285,14 @@ namespace Avalonia.Controls.Primitives return null; } - /// - protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) - { - if (_updateCount == 0) - { - Selection.Source = e.NewValue; - } - - base.ItemsChanged(e); - } - - /// protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { base.ItemsCollectionChanged(sender, e); + + if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0) + { + SelectedIndex = 0; + } } /// @@ -400,9 +307,10 @@ namespace Avalonia.Controls.Primitives Selection.Select(container.Index); MarkContainerSelected(container.ContainerControl, true); } - else if (Selection.IsSelected(container.Index) == true) + else { - MarkContainerSelected(container.ContainerControl, true); + var selected = Selection.IsSelected(container.Index); + MarkContainerSelected(container.ContainerControl, selected); } } } @@ -433,7 +341,7 @@ namespace Avalonia.Controls.Primitives { if (i.ContainerControl != null && i.Item != null) { - bool selected = Selection.IsSelected(i.Index) == true; + bool selected = Selection.IsSelected(i.Index); MarkContainerSelected(i.ContainerControl, selected); } } @@ -443,27 +351,39 @@ namespace Avalonia.Controls.Primitives protected override void OnDataContextBeginUpdate() { base.OnDataContextBeginUpdate(); + ++_initializing; - InternalBeginInit(); + if (_selection is object) + { + _selection.Source = null; + } } /// protected override void OnDataContextEndUpdate() { base.OnDataContextEndUpdate(); + --_initializing; + + if (_selection is object && _initializing == 0) + { + _selection.Source = Items; - InternalEndInit(); + if (Items is null) + { + _selection.Clear(); + _selectedItemsSync?.SelectedItems?.Clear(); + } + } } - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + protected override void OnInitialized() { - base.OnPropertyChanged(change); + base.OnInitialized(); - if (change.Property == SelectionModeProperty) + if (_selection is object) { - var mode = change.NewValue.GetValueOrDefault(); - Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple); - Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected); + _selection.Source = Items; } } @@ -487,6 +407,29 @@ namespace Avalonia.Controls.Primitives } } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ItemsProperty && + _initializing == 0 && + _selection is object) + { + var newValue = change.NewValue.GetValueOrDefault(); + _selection.Source = newValue; + + if (newValue is null) + { + _selection.Clear(); + } + } + else if (change.Property == SelectionModeProperty && _selection is object) + { + var newValue = change.NewValue.GetValueOrDefault(); + _selection.SingleSelect = !newValue.HasFlagCustom(SelectionMode.Multiple); + } + } + /// /// Moves the selection in the specified direction relative to the current selection. /// @@ -506,7 +449,7 @@ namespace Avalonia.Controls.Primitives /// The direction to move. /// Whether to wrap when the selection reaches the first or last item. /// True if the selection was moved; otherwise false. - protected bool MoveSelection(IControl from, NavigationDirection direction, bool wrap) + protected bool MoveSelection(IControl? from, NavigationDirection direction, bool wrap) { if (Presenter?.Panel is INavigableContainer container && GetNextControl(container, direction, from, wrap) is IControl next) @@ -538,71 +481,62 @@ namespace Avalonia.Controls.Primitives bool toggleModifier = false, bool rightButton = false) { - if (index != -1) + if (index < 0 || index >= ItemCount) { - if (select) - { - var mode = SelectionMode; - var multi = (mode & SelectionMode.Multiple) != 0; - var toggle = (toggleModifier || (mode & SelectionMode.Toggle) != 0); - var range = multi && rangeModifier; - - if (rightButton) - { - if (Selection.IsSelected(index) == false) - { - SelectedIndex = index; - } - } - else if (range) - { - using var operation = Selection.Update(); - var anchor = Selection.AnchorIndex; - - if (anchor.GetSize() == 0) - { - anchor = new IndexPath(0); - } + return; + } - Selection.ClearSelection(); - Selection.AnchorIndex = anchor; - Selection.SelectRangeFromAnchor(index); - } - else if (multi && toggle) - { - if (Selection.IsSelected(index) == true) - { - Selection.Deselect(index); - } - else - { - Selection.Select(index); - } - } - else if (toggle) - { - SelectedIndex = (SelectedIndex == index) ? -1 : index; - } - else - { - using var operation = Selection.Update(); - Selection.ClearSelection(); - Selection.Select(index); - } + var mode = SelectionMode; + var multi = (mode & SelectionMode.Multiple) != 0; + var toggle = (toggleModifier || (mode & SelectionMode.Toggle) != 0); + var range = multi && rangeModifier; - if (Presenter?.Panel != null) - { - var container = ItemContainerGenerator.ContainerFromIndex(index); - KeyboardNavigation.SetTabOnceActiveElement( - (InputElement)Presenter.Panel, - container); - } + if (!select) + { + Selection.Deselect(index); + } + else if (rightButton) + { + if (Selection.IsSelected(index) == false) + { + SelectedIndex = index; + } + } + else if (range) + { + using var operation = Selection.BatchUpdate(); + Selection.Clear(); + Selection.SelectRange(Selection.AnchorIndex, index); + } + else if (multi && toggle) + { + if (Selection.IsSelected(index) == true) + { + Selection.Deselect(index); } else { - LostSelection(); + Selection.Select(index); } } + else if (toggle) + { + SelectedIndex = (SelectedIndex == index) ? -1 : index; + } + else + { + using var operation = Selection.BatchUpdate(); + Selection.Clear(); + Selection.Select(index); + } + + if (Presenter?.Panel != null) + { + var container = ItemContainerGenerator.ContainerFromIndex(index); + KeyboardNavigation.SetTabOnceActiveElement( + (InputElement)Presenter.Panel, + container); + } } /// @@ -660,23 +594,35 @@ namespace Avalonia.Controls.Primitives } /// - /// Called when is raised. + /// Called when is raised on + /// . /// /// The sender. /// The event args. private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) + if (e.PropertyName == nameof(ISelectionModel.AnchorIndex) && AutoScrollToSelectedItem) { - if (Selection.AnchorIndex.GetSize() > 0) + if (Selection.AnchorIndex > 0) { - ScrollIntoView(Selection.AnchorIndex.GetAt(0)); + ScrollIntoView(Selection.AnchorIndex); } } + else if (e.PropertyName == nameof(ISelectionModel.SelectedIndex)) + { + RaisePropertyChanged(SelectedIndexProperty, _oldSelectedIndex, SelectedIndex); + _oldSelectedIndex = SelectedIndex; + } + else if (e.PropertyName == nameof(ISelectionModel.SelectedItem)) + { + RaisePropertyChanged(SelectedItemProperty, _oldSelectedItem, SelectedItem); + _oldSelectedItem = SelectedItem; + } } /// - /// Called when is raised. + /// Called when event is raised on + /// . /// /// The sender. /// The event args. @@ -692,46 +638,40 @@ namespace Avalonia.Controls.Primitives } } - if (e.SelectedIndices.Count > 0 || e.DeselectedIndices.Count > 0) + foreach (var i in e.SelectedIndexes) { - foreach (var i in e.SelectedIndices) - { - Mark(i.GetAt(0), true); - } - - foreach (var i in e.DeselectedIndices) - { - Mark(i.GetAt(0), false); - } + Mark(i, true); } - else if (e.DeselectedItems.Count > 0) + + foreach (var i in e.DeselectedIndexes) { - // (De)selected indices being empty means that a selected item was removed from - // the Items (it can't tell us the index of the item because the index is no longer - // valid). In this case, we just update the selection state of all containers. - UpdateContainerSelection(); + Mark(i, false); } - var newSelectedIndex = SelectedIndex; - var newSelectedItem = SelectedItem; + var route = BuildEventRoute(SelectionChangedEvent); - if (newSelectedIndex != _selectedIndex) + if (route.HasHandlers) { - RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, newSelectedIndex); - _selectedIndex = newSelectedIndex; + var ev = new SelectionChangedEventArgs( + SelectionChangedEvent, + e.DeselectedItems.ToList(), + e.SelectedItems.ToList()); + RaiseEvent(ev); } + } - if (newSelectedItem != _selectedItem) + /// + /// Called when event is raised on + /// . + /// + /// The sender. + /// The event args. + private void OnSelectionModelLostSelection(object sender, EventArgs e) + { + if (AlwaysSelected && Items is object) { - RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem); - _selectedItem = newSelectedItem; + SelectedIndex = 0; } - - var ev = new SelectionChangedEventArgs( - SelectionChangedEvent, - e.DeselectedItems.ToList(), - e.SelectedItems.ToList()); - RaiseEvent(ev); } /// @@ -760,23 +700,6 @@ namespace Avalonia.Controls.Primitives } } - /// - /// Called when the currently selected item is lost and the selection must be changed - /// depending on the property. - /// - private void LostSelection() - { - var items = Items?.Cast(); - var index = -1; - - if (items != null && AlwaysSelected) - { - index = Math.Min(SelectedIndex, items.Count() - 1); - } - - SelectedIndex = index; - } - /// /// Sets a container's 'selected' class or . /// @@ -819,16 +742,6 @@ namespace Avalonia.Controls.Primitives } } - private void UpdateContainerSelection() - { - foreach (var container in ItemContainerGenerator.Containers) - { - MarkContainerSelected( - container.ContainerControl, - Selection.IsSelected(container.Index) != false); - } - } - /// /// Sets an item container's 'selected' class or . /// @@ -844,52 +757,92 @@ namespace Avalonia.Controls.Primitives } } - private void UpdateFinished() + /// + /// Sets an item container's 'selected' class or . + /// + /// The item. + /// Whether the item should be selected or deselected. + private int MarkItemSelected(object item, bool selected) { - Selection.Source = Items; + var index = IndexOf(Items, item); - if (_updateSelectedItem != null) + if (index != -1) { - SelectedItem = _updateSelectedItem; + MarkItemSelected(index, selected); } - else + + return index; + } + + private void UpdateContainerSelection() + { + if (Presenter?.Panel is IPanel panel) { - if (ItemCount == 0 && SelectedIndex != -1) + foreach (var container in panel.Children) { - SelectedIndex = -1; - } - else - { - if (_updateSelectedIndex != int.MinValue) - { - SelectedIndex = _updateSelectedIndex; - } - - if (AlwaysSelected && SelectedIndex == -1) - { - SelectedIndex = 0; - } + MarkContainerSelected( + container, + Selection.IsSelected(ItemContainerGenerator.IndexFromContainer(container))); } } } - private void InternalBeginInit() + private ISelectionModel CreateDefaultSelectionModel() + { + return new SelectionModel + { + SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), + }; + } + + private void InitializeSelectionModel(ISelectionModel model) { - if (_updateCount == 0) + if (_initializing == 0) { - _updateSelectedIndex = int.MinValue; + model.Source = Items; } - ++_updateCount; + model.PropertyChanged += OnSelectionModelPropertyChanged; + model.SelectionChanged += OnSelectionModelSelectionChanged; + model.LostSelection += OnSelectionModelLostSelection; + + if (model.SingleSelect) + { + SelectionMode &= ~SelectionMode.Multiple; + } + else + { + SelectionMode |= SelectionMode.Multiple; + } + + _oldSelectedIndex = model.SelectedIndex; + _oldSelectedItem = model.SelectedItem; + + if (AlwaysSelected && model.Count == 0) + { + model.SelectedIndex = 0; + } + + UpdateContainerSelection(); + + _selectedItemsSync ??= new SelectedItemsSync(model); + _selectedItemsSync.SelectionModel = model; + + if (SelectedIndex != -1) + { + RaiseEvent(new SelectionChangedEventArgs( + SelectionChangedEvent, + Array.Empty(), + Selection.SelectedItems.ToList())); + } } - private void InternalEndInit() + private void DeinitializeSelectionModel(ISelectionModel? model) { - Debug.Assert(_updateCount > 0); - - if (--_updateCount == 0) + if (model is object) { - UpdateFinished(); + model.PropertyChanged -= OnSelectionModelPropertyChanged; + model.SelectionChanged -= OnSelectionModelSelectionChanged; } } } diff --git a/src/Avalonia.Controls/SelectedItems.cs b/src/Avalonia.Controls/SelectedItems.cs deleted file mode 100644 index a3acb48765..0000000000 --- a/src/Avalonia.Controls/SelectedItems.cs +++ /dev/null @@ -1,49 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections; -using System.Collections.Generic; - -#nullable enable - -namespace Avalonia.Controls -{ - public interface ISelectedItemInfo - { - public IndexPath Path { get; } - } - - internal class SelectedItems : IReadOnlyList - where Tinfo : ISelectedItemInfo - { - private readonly List _infos; - private readonly Func, int, TValue> _getAtImpl; - - public SelectedItems( - List infos, - int count, - Func, int, TValue> getAtImpl) - { - _infos = infos; - _getAtImpl = getAtImpl; - Count = count; - } - - public TValue this[int index] => _getAtImpl(_infos, index); - - public int Count { get; } - - public IEnumerator GetEnumerator() - { - for (var i = 0; i < Count; ++i) - { - yield return this[i]; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } -} diff --git a/src/Avalonia.Controls/Selection/ISelectionModel.cs b/src/Avalonia.Controls/Selection/ISelectionModel.cs new file mode 100644 index 0000000000..3b8fd0c8b7 --- /dev/null +++ b/src/Avalonia.Controls/Selection/ISelectionModel.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public interface ISelectionModel : INotifyPropertyChanged + { + IEnumerable? Source { get; set; } + bool SingleSelect { get; set; } + int SelectedIndex { get; set; } + IReadOnlyList SelectedIndexes { get; } + object? SelectedItem { get; set; } + IReadOnlyList SelectedItems { get; } + int AnchorIndex { get; set; } + int Count { get; } + + public event EventHandler? IndexesChanged; + public event EventHandler? SelectionChanged; + public event EventHandler? LostSelection; + public event EventHandler? SourceReset; + + public void BeginBatchUpdate(); + public void EndBatchUpdate(); + bool IsSelected(int index); + void Select(int index); + void Deselect(int index); + void SelectRange(int start, int end); + void DeselectRange(int start, int end); + void SelectAll(); + void Clear(); + } + + public static class SelectionModelExtensions + { + public static IDisposable BatchUpdate(this ISelectionModel model) + { + return new BatchUpdateOperation(model); + } + + public struct BatchUpdateOperation : IDisposable + { + private readonly ISelectionModel _owner; + private bool _isDisposed; + + public BatchUpdateOperation(ISelectionModel owner) + { + _owner = owner; + _isDisposed = false; + owner.BeginBatchUpdate(); + } + + public void Dispose() + { + if (!_isDisposed) + { + _owner?.EndBatchUpdate(); + _isDisposed = true; + } + } + } + } +} diff --git a/src/Avalonia.Controls/IndexRange.cs b/src/Avalonia.Controls/Selection/IndexRange.cs similarity index 81% rename from src/Avalonia.Controls/IndexRange.cs rename to src/Avalonia.Controls/Selection/IndexRange.cs index e45d013af4..fa7b44faea 100644 --- a/src/Avalonia.Controls/IndexRange.cs +++ b/src/Avalonia.Controls/Selection/IndexRange.cs @@ -8,12 +8,18 @@ using System.Collections.Generic; #nullable enable -namespace Avalonia.Controls +namespace Avalonia.Controls.Selection { internal readonly struct IndexRange : IEquatable { private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue); + public IndexRange(int index) + { + Begin = index; + End = index; + } + public IndexRange(int begin, int end) { // Accept out of order begin/end pairs, just swap them. @@ -87,6 +93,43 @@ namespace Avalonia.Controls public static bool operator ==(IndexRange left, IndexRange right) => left.Equals(right); public static bool operator !=(IndexRange left, IndexRange right) => !(left == right); + public static bool Contains(IReadOnlyList? ranges, int index) + { + if (ranges is null || index < 0) + { + return false; + } + + foreach (var range in ranges) + { + if (range.Contains(index)) + { + return true; + } + } + + return false; + } + + public static int GetAt(IReadOnlyList ranges, int index) + { + var currentIndex = 0; + + foreach (var range in ranges) + { + var currentCount = range.Count; + + if (index >= currentIndex && index < currentIndex + currentCount) + { + return range.Begin + (index - currentIndex); + } + + currentIndex += currentCount; + } + + throw new IndexOutOfRangeException("The index was out of range."); + } + public static int Add( IList ranges, IndexRange range, @@ -132,6 +175,21 @@ namespace Avalonia.Controls return result; } + public static int Add( + IList destination, + IReadOnlyList source, + IList? added = null) + { + var result = 0; + + foreach (var range in source) + { + result += Add(destination, range, added); + } + + return result; + } + public static int Intersect( IList ranges, IndexRange range, @@ -180,10 +238,15 @@ namespace Avalonia.Controls } public static int Remove( - IList ranges, + IList? ranges, IndexRange range, IList? removed = null) { + if (ranges is null) + { + return 0; + } + var result = 0; for (var i = 0; i < ranges.Count; ++i) @@ -224,15 +287,16 @@ namespace Avalonia.Controls return result; } - public static IEnumerable Subtract( - IndexRange lhs, - IEnumerable rhs) + public static int Remove( + IList destination, + IReadOnlyList source, + IList? added = null) { - var result = new List { lhs }; - - foreach (var range in rhs) + var result = 0; + + foreach (var range in source) { - Remove(result, range); + result += Remove(destination, range, added); } return result; diff --git a/src/Avalonia.Controls/Selection/SelectedIndexes.cs b/src/Avalonia.Controls/Selection/SelectedIndexes.cs new file mode 100644 index 0000000000..36df175ed2 --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectedIndexes.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + internal class SelectedIndexes : IReadOnlyList + { + private readonly SelectionModel? _owner; + private readonly IReadOnlyList? _ranges; + + public SelectedIndexes(SelectionModel owner) => _owner = owner; + public SelectedIndexes(IReadOnlyList ranges) => _ranges = ranges; + + public int this[int index] + { + get + { + if (index >= Count) + { + throw new IndexOutOfRangeException("The index was out of range."); + } + + if (_owner?.SingleSelect == true) + { + return _owner.SelectedIndex; + } + else + { + return IndexRange.GetAt(Ranges!, index); + } + } + } + + public int Count + { + get + { + if (_owner?.SingleSelect == true) + { + return _owner.SelectedIndex == -1 ? 0 : 1; + } + else + { + return IndexRange.GetCount(Ranges!); + } + } + } + + private IReadOnlyList Ranges => _ranges ?? _owner!.Ranges!; + + public IEnumerator GetEnumerator() + { + IEnumerator SingleSelect() + { + if (_owner.SelectedIndex >= 0) + { + yield return _owner.SelectedIndex; + } + } + + if (_owner?.SingleSelect == true) + { + return SingleSelect(); + } + else + { + return IndexRange.EnumerateIndices(Ranges).GetEnumerator(); + } + } + + public static SelectedIndexes? Create(IReadOnlyList? ranges) + { + return ranges is object ? new SelectedIndexes(ranges) : null; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Avalonia.Controls/Selection/SelectedItems.cs b/src/Avalonia.Controls/Selection/SelectedItems.cs new file mode 100644 index 0000000000..92781fd54a --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectedItems.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + internal class SelectedItems : IReadOnlyList + { + private readonly SelectionModel? _owner; + private readonly ItemsSourceView? _items; + private readonly IReadOnlyList? _ranges; + + public SelectedItems(SelectionModel owner) => _owner = owner; + + public SelectedItems(IReadOnlyList ranges, ItemsSourceView? items) + { + _ranges = ranges ?? throw new ArgumentNullException(nameof(ranges)); + _items = items; + } + + [MaybeNull] + public T this[int index] + { +#pragma warning disable CS8766 + get +#pragma warning restore CS8766 + { + if (index >= Count) + { + throw new IndexOutOfRangeException("The index was out of range."); + } + + if (_owner?.SingleSelect == true) + { + return _owner.SelectedItem; + } + else if (Items is object) + { + return Items[index]; + } + else + { + return default; + } + } + } + + public int Count + { + get + { + if (_owner?.SingleSelect == true) + { + return _owner.SelectedIndex == -1 ? 0 : 1; + } + else + { + return Ranges is object ? IndexRange.GetCount(Ranges) : 0; + } + } + } + + private ItemsSourceView? Items => _items ?? _owner?.ItemsView; + private IReadOnlyList? Ranges => _ranges ?? _owner!.Ranges; + + public IEnumerator GetEnumerator() + { + if (_owner?.SingleSelect == true) + { + if (_owner.SelectedIndex >= 0) + { +#pragma warning disable CS8603 + yield return _owner.SelectedItem; +#pragma warning restore CS8603 + } + } + else + { + var items = Items; + + foreach (var range in Ranges!) + { + for (var i = range.Begin; i <= range.End; ++i) + { +#pragma warning disable CS8603 + yield return items is object ? items[i] : default; +#pragma warning restore CS8603 + } + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public static SelectedItems? Create( + IReadOnlyList? ranges, + ItemsSourceView? items) + { + return ranges is object ? new SelectedItems(ranges, items) : null; + } + + public class Untyped : IReadOnlyList + { + private readonly IReadOnlyList _source; + public Untyped(IReadOnlyList source) => _source = source; + public object? this[int index] => _source[index]; + public int Count => _source.Count; + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public IEnumerator GetEnumerator() + { + foreach (var i in _source) + { + yield return i; + } + } + } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionModel.cs b/src/Avalonia.Controls/Selection/SelectionModel.cs new file mode 100644 index 0000000000..7ce2624d02 --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionModel.cs @@ -0,0 +1,726 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public class SelectionModel : SelectionNodeBase, ISelectionModel + { + private bool _singleSelect = true; + private int _anchorIndex = -1; + private int _selectedIndex = -1; + private Operation? _operation; + private SelectedIndexes? _selectedIndexes; + private SelectedItems? _selectedItems; + private SelectedItems.Untyped? _selectedItemsUntyped; + private EventHandler? _untypedSelectionChanged; + [AllowNull] private T _initSelectedItem = default; + private bool _hasInitSelectedItem; + + public SelectionModel() + { + } + + public SelectionModel(IEnumerable? source) + { + Source = source; + } + + public new IEnumerable? Source + { + get => base.Source as IEnumerable; + set => SetSource(value); + } + + public bool SingleSelect + { + get => _singleSelect; + set + { + if (_singleSelect != value) + { + if (value == true) + { + using var update = BatchUpdate(); + var selectedIndex = SelectedIndex; + Clear(); + SelectedIndex = selectedIndex; + } + + _singleSelect = value; + RangesEnabled = !value; + + if (RangesEnabled && _selectedIndex >= 0) + { + CommitSelect(new IndexRange(_selectedIndex)); + } + + RaisePropertyChanged(nameof(SingleSelect)); + } + } + } + + public int SelectedIndex + { + get => _selectedIndex; + set + { + using var update = BatchUpdate(); + Clear(); + Select(value); + } + } + + public IReadOnlyList SelectedIndexes => _selectedIndexes ??= new SelectedIndexes(this); + + [MaybeNull, AllowNull] + public T SelectedItem + { + get => ItemsView is object ? GetItemAt(_selectedIndex) : _initSelectedItem; + set + { + if (ItemsView is object) + { + SelectedIndex = ItemsView.IndexOf(value!); + } + else + { + Clear(); + _initSelectedItem = value; + _hasInitSelectedItem = true; + } + } + } + + public IReadOnlyList SelectedItems + { + get + { + if (ItemsView is null && _hasInitSelectedItem) + { + return new[] { _initSelectedItem }; + } + + return _selectedItems ??= new SelectedItems(this); + } + } + + public int AnchorIndex + { + get => _anchorIndex; + set + { + using var update = BatchUpdate(); + var index = CoerceIndex(value); + update.Operation.AnchorIndex = index; + } + } + + public int Count + { + get + { + if (SingleSelect) + { + return _selectedIndex >= 0 ? 1 : 0; + } + else + { + return IndexRange.GetCount(Ranges); + } + } + } + + IEnumerable? ISelectionModel.Source + { + get => Source; + set => SetSource(value); + } + + object? ISelectionModel.SelectedItem + { + get => SelectedItem; + set + { + if (value is T t) + { + SelectedItem = t; + } + else + { + SelectedIndex = -1; + } + } + + } + + IReadOnlyList ISelectionModel.SelectedItems + { + get => _selectedItemsUntyped ??= new SelectedItems.Untyped(SelectedItems); + } + + public event EventHandler? IndexesChanged; + public event EventHandler>? SelectionChanged; + public event EventHandler? LostSelection; + public event EventHandler? SourceReset; + public event PropertyChangedEventHandler? PropertyChanged; + + event EventHandler? ISelectionModel.SelectionChanged + { + add => _untypedSelectionChanged += value; + remove => _untypedSelectionChanged -= value; + } + + public BatchUpdateOperation BatchUpdate() => new BatchUpdateOperation(this); + + public void BeginBatchUpdate() + { + _operation ??= new Operation(this); + ++_operation.UpdateCount; + } + + public void EndBatchUpdate() + { + if (_operation is null || _operation.UpdateCount == 0) + { + throw new InvalidOperationException("No batch update in progress."); + } + + if (--_operation.UpdateCount == 0) + { + // If the collection is currently changing, commit the update when the + // collection change finishes. + if (!IsSourceCollectionChanging) + { + CommitOperation(_operation); + } + } + } + + public bool IsSelected(int index) + { + if (index < 0) + { + return false; + } + else if (SingleSelect) + { + return _selectedIndex == index; + } + else + { + return IndexRange.Contains(Ranges, index); + } + } + + public void Select(int index) => SelectRange(index, index, false, true); + + public void Deselect(int index) => DeselectRange(index, index); + + public void SelectRange(int start, int end) => SelectRange(start, end, false, false); + + public void DeselectRange(int start, int end) + { + using var update = BatchUpdate(); + var o = update.Operation; + var range = CoerceRange(start, end); + + if (range.Begin == -1) + { + return; + } + + if (RangesEnabled) + { + var selected = Ranges.ToList(); + var deselected = new List(); + var operationDeselected = new List(); + + o.DeselectedRanges ??= new List(); + IndexRange.Remove(o.SelectedRanges, range, operationDeselected); + IndexRange.Remove(selected, range, deselected); + IndexRange.Add(o.DeselectedRanges, deselected); + + if (IndexRange.Contains(deselected, o.SelectedIndex) || + IndexRange.Contains(operationDeselected, o.SelectedIndex)) + { + o.SelectedIndex = GetFirstSelectedIndexFromRanges(except: deselected); + } + } + else if(range.Contains(_selectedIndex)) + { + o.SelectedIndex = -1; + } + + _initSelectedItem = default; + _hasInitSelectedItem = false; + } + + public void SelectAll() => SelectRange(0, int.MaxValue); + public void Clear() => DeselectRange(0, int.MaxValue); + + protected void RaisePropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void SetSource(IEnumerable? value) + { + if (base.Source != value) + { + if (_operation is object) + { + throw new InvalidOperationException("Cannot change source while update is in progress."); + } + + if (base.Source is object && value is object) + { + using var update = BatchUpdate(); + update.Operation.SkipLostSelection = true; + Clear(); + } + + base.Source = value; + + using (var update = BatchUpdate()) + { + update.Operation.IsSourceUpdate = true; + + if (_hasInitSelectedItem) + { + SelectedItem = _initSelectedItem; + _initSelectedItem = default; + _hasInitSelectedItem = false; + } + else + { + TrimInvalidSelections(update.Operation); + } + + RaisePropertyChanged(nameof(Source)); + } + } + } + + private protected override void OnIndexesChanged(int shiftIndex, int shiftDelta) + { + IndexesChanged?.Invoke(this, new SelectionModelIndexesChangedEventArgs(shiftIndex, shiftDelta)); + } + + private protected override void OnSourceReset() + { + _selectedIndex = _anchorIndex = -1; + CommitDeselect(new IndexRange(0, int.MaxValue)); + + if (SourceReset is object) + { + SourceReset.Invoke(this, EventArgs.Empty); + } + else + { + //Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log( + // this, + // "SelectionModel received Reset but no SourceReset handler was registered to handle it. " + + // "Selection may be out of sync.", + // typeof(SelectionModel)); + } + } + + private protected override void OnSelectionChanged(IReadOnlyList deselectedItems) + { + // Note: We're *not* putting this in a using scope. A collection update is still in progress + // so the operation won't get commited by normal means: we have to commit it manually. + var update = BatchUpdate(); + + update.Operation.DeselectedItems = deselectedItems; + + if (_selectedIndex == -1 && LostSelection is object) + { + LostSelection(this, EventArgs.Empty); + } + + CommitOperation(update.Operation); + } + + private protected override CollectionChangeState OnItemsAdded(int index, IList items) + { + var count = items.Count; + var shifted = SelectedIndex >= index; + var shiftCount = shifted ? count : 0; + + _selectedIndex += shiftCount; + _anchorIndex += shiftCount; + + var baseResult = base.OnItemsAdded(index, items); + shifted |= baseResult.ShiftDelta != 0; + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? count : 0, + }; + } + + private protected override CollectionChangeState OnItemsRemoved(int index, IList items) + { + var count = items.Count; + var removedRange = new IndexRange(index, index + count - 1); + var shifted = false; + List? removed; + + var baseResult = base.OnItemsRemoved(index, items); + shifted |= baseResult.ShiftDelta != 0; + removed = baseResult.RemovedItems; + + if (removedRange.Contains(SelectedIndex)) + { + if (SingleSelect) + { +#pragma warning disable CS8604 + removed = new List { (T)items[SelectedIndex - index] }; +#pragma warning restore CS8604 + } + + _selectedIndex = GetFirstSelectedIndexFromRanges(); + } + else if (SelectedIndex >= index) + { + _selectedIndex -= count; + shifted = true; + } + + if (removedRange.Contains(AnchorIndex)) + { + _anchorIndex = GetFirstSelectedIndexFromRanges(); + } + else if (AnchorIndex >= index) + { + _anchorIndex -= count; + shifted = true; + } + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? -count : 0, + RemovedItems = removed, + }; + } + + private protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e) + { + if (_operation?.UpdateCount > 0) + { + throw new InvalidOperationException("Source collection was modified during selection update."); + } + + var oldAnchorIndex = _anchorIndex; + var oldSelectedIndex = _selectedIndex; + + base.OnSourceCollectionChanged(e); + + if (oldSelectedIndex != _selectedIndex) + { + RaisePropertyChanged(nameof(SelectedIndex)); + } + + if (oldAnchorIndex != _anchorIndex) + { + RaisePropertyChanged(nameof(AnchorIndex)); + } + } + + protected override void OnSourceCollectionChangeFinished() + { + if (_operation is object) + { + CommitOperation(_operation); + } + } + + private int GetFirstSelectedIndexFromRanges(List? except = null) + { + if (RangesEnabled) + { + var count = IndexRange.GetCount(Ranges); + var index = 0; + + while (index < count) + { + var result = IndexRange.GetAt(Ranges, index++); + + if (!IndexRange.Contains(except, result)) + { + return result; + } + } + } + + return -1; + } + + private void SelectRange( + int start, + int end, + bool forceSelectedIndex, + bool forceAnchorIndex) + { + if (SingleSelect && start != end) + { + throw new InvalidOperationException("Cannot select range with single selection."); + } + + var range = CoerceRange(start, end); + + if (range.Begin == -1) + { + return; + } + + using var update = BatchUpdate(); + var o = update.Operation; + var selected = new List(); + + if (RangesEnabled) + { + o.SelectedRanges ??= new List(); + IndexRange.Remove(o.DeselectedRanges, range); + IndexRange.Add(o.SelectedRanges, range); + IndexRange.Remove(o.SelectedRanges, Ranges); + + if (o.SelectedIndex == -1 || forceSelectedIndex) + { + o.SelectedIndex = range.Begin; + } + + if (o.AnchorIndex == -1 || forceAnchorIndex) + { + o.AnchorIndex = range.Begin; + } + } + else + { + o.SelectedIndex = o.AnchorIndex = start; + } + + _initSelectedItem = default; + _hasInitSelectedItem = false; + } + + [return: MaybeNull] + private T GetItemAt(int index) + { + if (ItemsView is null || index < 0 || index >= ItemsView.Count) + { + return default; + } + + return ItemsView[index]; + } + + private int CoerceIndex(int index) + { + index = Math.Max(index, -1); + + if (ItemsView is object && index >= ItemsView.Count) + { + index = -1; + } + + return index; + } + + private IndexRange CoerceRange(int start, int end) + { + var max = ItemsView is object ? ItemsView.Count - 1 : int.MaxValue; + + if (start > max || (start < 0 && end < 0)) + { + return new IndexRange(-1); + } + + start = Math.Max(start, 0); + end = Math.Min(end, max); + + return new IndexRange(start, end); + } + + private void TrimInvalidSelections(Operation operation) + { + if (ItemsView is null) + { + return; + } + + var max = ItemsView.Count - 1; + + if (operation.SelectedIndex > max) + { + operation.SelectedIndex = GetFirstSelectedIndexFromRanges(); + } + + if (operation.AnchorIndex > max) + { + operation.AnchorIndex = GetFirstSelectedIndexFromRanges(); + } + + if (RangesEnabled && Ranges.Count > 0) + { + var selected = Ranges.ToList(); + + if (max < 0) + { + operation.DeselectedRanges = selected; + } + else + { + var valid = new IndexRange(0, max); + var removed = new List(); + IndexRange.Intersect(selected, valid, removed); + operation.DeselectedRanges = removed; + } + } + } + + private void CommitOperation(Operation operation) + { + try + { + var oldAnchorIndex = _anchorIndex; + var oldSelectedIndex = _selectedIndex; + var indexesChanged = false; + + if (operation.SelectedIndex == -1 && LostSelection is object && !operation.SkipLostSelection) + { + operation.UpdateCount++; + LostSelection?.Invoke(this, EventArgs.Empty); + } + + _selectedIndex = operation.SelectedIndex; + _anchorIndex = operation.AnchorIndex; + + if (operation.SelectedRanges is object) + { + indexesChanged |= CommitSelect(operation.SelectedRanges) > 0; + } + + if (operation.DeselectedRanges is object) + { + indexesChanged |= CommitDeselect(operation.DeselectedRanges) > 0; + } + + if (SelectionChanged is object || _untypedSelectionChanged is object) + { + IReadOnlyList? deselected = operation.DeselectedRanges; + IReadOnlyList? selected = operation.SelectedRanges; + + if (SingleSelect && oldSelectedIndex != _selectedIndex) + { + if (oldSelectedIndex != -1) + { + deselected = new[] { new IndexRange(oldSelectedIndex) }; + } + + if (_selectedIndex != -1) + { + selected = new[] { new IndexRange(_selectedIndex) }; + } + } + + if (deselected?.Count > 0 || selected?.Count > 0 || operation.DeselectedItems is object) + { + // If the operation was caused by Source being updated, then use a null source + // so that the items will appear as nulls. + var deselectedSource = operation.IsSourceUpdate ? null : ItemsView; + + // If the operation contains DeselectedItems then we're notifying a source + // CollectionChanged event. LostFocus may have caused another item to have been + // selected, but it can't have caused a deselection (as it was called due to + // selection being lost) so we're ok to discard `deselected` here. + var deselectedItems = operation.DeselectedItems ?? + SelectedItems.Create(deselected, deselectedSource); + + var e = new SelectionModelSelectionChangedEventArgs( + SelectedIndexes.Create(deselected), + SelectedIndexes.Create(selected), + deselectedItems, + SelectedItems.Create(selected, ItemsView)); + SelectionChanged?.Invoke(this, e); + _untypedSelectionChanged?.Invoke(this, e); + } + } + + if (oldSelectedIndex != _selectedIndex) + { + indexesChanged = true; + RaisePropertyChanged(nameof(SelectedIndex)); + RaisePropertyChanged(nameof(SelectedItem)); + } + + if (oldAnchorIndex != _anchorIndex) + { + indexesChanged = true; + RaisePropertyChanged(nameof(AnchorIndex)); + } + + if (indexesChanged) + { + RaisePropertyChanged(nameof(SelectedIndexes)); + RaisePropertyChanged(nameof(SelectedItems)); + } + } + finally + { + _operation = null; + } + } + + public struct BatchUpdateOperation : IDisposable + { + private readonly SelectionModel _owner; + private bool _isDisposed; + + public BatchUpdateOperation(SelectionModel owner) + { + _owner = owner; + _isDisposed = false; + owner.BeginBatchUpdate(); + } + + internal Operation Operation => _owner._operation!; + + public void Dispose() + { + if (!_isDisposed) + { + _owner?.EndBatchUpdate(); + _isDisposed = true; + } + } + } + + internal class Operation + { + public Operation(SelectionModel owner) + { + AnchorIndex = owner.AnchorIndex; + SelectedIndex = owner.SelectedIndex; + } + + public int UpdateCount { get; set; } + public bool IsSourceUpdate { get; set; } + public bool SkipLostSelection { get; set; } + public int AnchorIndex { get; set; } + public int SelectedIndex { get; set; } + public List? SelectedRanges { get; set; } + public List? DeselectedRanges { get; set; } + public IReadOnlyList? DeselectedItems { get; set; } + } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs b/src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs new file mode 100644 index 0000000000..a1fef578a2 --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs @@ -0,0 +1,18 @@ +using System; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public class SelectionModelIndexesChangedEventArgs : EventArgs + { + public SelectionModelIndexesChangedEventArgs(int startIndex, int delta) + { + StartIndex = startIndex; + Delta = delta; + } + + public int StartIndex { get; } + public int Delta { get; } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs new file mode 100644 index 0000000000..396943592d --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Avalonia.Controls.Selection; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public abstract class SelectionModelSelectionChangedEventArgs : EventArgs + { + /// + /// Gets the indexes of the items that were removed from the selection. + /// + public abstract IReadOnlyList DeselectedIndexes { get; } + + /// + /// Gets the indexes of the items that were added to the selection. + /// + public abstract IReadOnlyList SelectedIndexes { get; } + + /// + /// Gets the items that were removed from the selection. + /// + public IReadOnlyList DeselectedItems => GetUntypedDeselectedItems(); + + /// + /// Gets the items that were added to the selection. + /// + public IReadOnlyList SelectedItems => GetUntypedSelectedItems(); + + protected abstract IReadOnlyList GetUntypedDeselectedItems(); + protected abstract IReadOnlyList GetUntypedSelectedItems(); + } + + public class SelectionModelSelectionChangedEventArgs : SelectionModelSelectionChangedEventArgs + { + private IReadOnlyList? _deselectedItems; + private IReadOnlyList? _selectedItems; + + public SelectionModelSelectionChangedEventArgs( + IReadOnlyList? deselectedIndices = null, + IReadOnlyList? selectedIndices = null, + IReadOnlyList? deselectedItems = null, + IReadOnlyList? selectedItems = null) + { + DeselectedIndexes = deselectedIndices ?? Array.Empty(); + SelectedIndexes = selectedIndices ?? Array.Empty(); + DeselectedItems = deselectedItems ?? Array.Empty(); + SelectedItems = selectedItems ?? Array.Empty(); + } + + /// + /// Gets the indexes of the items that were removed from the selection. + /// + public override IReadOnlyList DeselectedIndexes { get; } + + /// + /// Gets the indexes of the items that were added to the selection. + /// + public override IReadOnlyList SelectedIndexes { get; } + + /// + /// Gets the items that were removed from the selection. + /// + public new IReadOnlyList DeselectedItems { get; } + + /// + /// Gets the items that were added to the selection. + /// + public new IReadOnlyList SelectedItems { get; } + + protected override IReadOnlyList GetUntypedDeselectedItems() + { + return _deselectedItems ??= (DeselectedItems as IReadOnlyList) ?? + new SelectedItems.Untyped(DeselectedItems); + } + + protected override IReadOnlyList GetUntypedSelectedItems() + { + return _selectedItems ??= (SelectedItems as IReadOnlyList) ?? + new SelectedItems.Untyped(SelectedItems); + } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionNodeBase.cs b/src/Avalonia.Controls/Selection/SelectionNodeBase.cs new file mode 100644 index 0000000000..ff3b8f43a8 --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionNodeBase.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Controls.Utils; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public abstract class SelectionNodeBase : ICollectionChangedListener + { + private IEnumerable? _source; + private bool _rangesEnabled; + private List? _ranges; + private int _collectionChanging; + + protected IEnumerable? Source + { + get => _source; + set + { + if (_source != value) + { + ItemsView?.RemoveListener(this); + _source = value; + ItemsView = value is object ? ItemsSourceView.GetOrCreate(value) : null; + ItemsView?.AddListener(this); + } + } + } + + protected bool IsSourceCollectionChanging => _collectionChanging > 0; + + protected bool RangesEnabled + { + get => _rangesEnabled; + set + { + if (_rangesEnabled != value) + { + _rangesEnabled = value; + + if (!_rangesEnabled) + { + _ranges = null; + } + } + } + } + + internal ItemsSourceView? ItemsView { get; set; } + + internal IReadOnlyList Ranges + { + get + { + if (!RangesEnabled) + { + throw new InvalidOperationException("Ranges not enabled."); + } + + return _ranges ??= new List(); + } + } + + void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + ++_collectionChanging; + } + + void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + OnSourceCollectionChanged(e); + } + + void ICollectionChangedListener.PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + if (--_collectionChanging == 0) + { + OnSourceCollectionChangeFinished(); + } + } + + protected abstract void OnSourceCollectionChangeFinished(); + + private protected abstract void OnIndexesChanged(int shiftIndex, int shiftDelta); + + private protected abstract void OnSourceReset(); + + private protected abstract void OnSelectionChanged(IReadOnlyList deselectedItems); + + private protected int CommitSelect(IndexRange range) + { + if (RangesEnabled) + { + _ranges ??= new List(); + return IndexRange.Add(_ranges, range); + } + + return 0; + } + + private protected int CommitSelect(IReadOnlyList ranges) + { + if (RangesEnabled) + { + _ranges ??= new List(); + return IndexRange.Add(_ranges, ranges); + } + + return 0; + } + + private protected int CommitDeselect(IndexRange range) + { + if (RangesEnabled) + { + _ranges ??= new List(); + return IndexRange.Remove(_ranges, range); + } + + return 0; + } + + private protected int CommitDeselect(IReadOnlyList ranges) + { + if (RangesEnabled && _ranges is object) + { + return IndexRange.Remove(_ranges, ranges); + } + + return 0; + } + + private protected virtual CollectionChangeState OnItemsAdded(int index, IList items) + { + var count = items.Count; + var shifted = false; + + if (_ranges is object) + { + List? toAdd = null; + + for (var i = 0; i < Ranges!.Count; ++i) + { + var range = Ranges[i]; + + // The range is after the inserted items, need to shift the range right + if (range.End >= index) + { + int begin = range.Begin; + + // If the index left of newIndex is inside the range, + // Split the range and remember the left piece to add later + if (range.Contains(index - 1)) + { + range.Split(index - 1, out var before, out _); + (toAdd ??= new List()).Add(before); + begin = index; + } + + // Shift the range to the right + _ranges[i] = new IndexRange(begin + count, range.End + count); + shifted = true; + } + } + + if (toAdd is object) + { + foreach (var range in toAdd) + { + IndexRange.Add(_ranges, range); + } + } + } + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? count : 0, + }; + } + + private protected virtual CollectionChangeState OnItemsRemoved(int index, IList items) + { + var count = items.Count; + var removedRange = new IndexRange(index, index + count - 1); + bool shifted = false; + List? removed = null; + + if (_ranges is object) + { + var deselected = new List(); + + if (IndexRange.Remove(_ranges, removedRange, deselected) > 0) + { + removed = new List(); + + foreach (var range in deselected) + { + for (var i = range.Begin; i <= range.End; ++i) + { +#pragma warning disable CS8604 + removed.Add((T)items[i - index]); +#pragma warning restore CS8604 + } + } + } + + for (var i = 0; i < Ranges!.Count; ++i) + { + var existing = Ranges[i]; + + if (existing.End > removedRange.Begin) + { + _ranges[i] = new IndexRange(existing.Begin - count, existing.End - count); + shifted = true; + } + } + } + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? -count : 0, + RemovedItems = removed, + }; + } + + private protected virtual void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e) + { + var shiftDelta = 0; + var shiftIndex = -1; + List? removed = null; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + var change = OnItemsAdded(e.NewStartingIndex, e.NewItems); + shiftIndex = change.ShiftIndex; + shiftDelta = change.ShiftDelta; + break; + } + case NotifyCollectionChangedAction.Remove: + { + var change = OnItemsRemoved(e.OldStartingIndex, e.OldItems); + shiftIndex = change.ShiftIndex; + shiftDelta = change.ShiftDelta; + removed = change.RemovedItems; + break; + } + case NotifyCollectionChangedAction.Replace: + { + var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems); + var addChange = OnItemsAdded(e.NewStartingIndex, e.NewItems); + shiftIndex = removeChange.ShiftIndex; + shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta; + removed = removeChange.RemovedItems; + } + break; + case NotifyCollectionChangedAction.Reset: + OnSourceReset(); + break; + } + + if (shiftDelta != 0) + { + OnIndexesChanged(shiftIndex, shiftDelta); + } + + if (removed is object) + { + OnSelectionChanged(removed); + } + } + + private protected struct CollectionChangeState + { + public int ShiftIndex; + public int ShiftDelta; + public List? RemovedItems; + } + } +} diff --git a/src/Avalonia.Controls/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs deleted file mode 100644 index aa6552579f..0000000000 --- a/src/Avalonia.Controls/SelectionModel.cs +++ /dev/null @@ -1,894 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Reactive.Linq; -using Avalonia.Controls.Utils; - -#nullable enable - -namespace Avalonia.Controls -{ - public class SelectionModel : ISelectionModel, IDisposable - { - private readonly SelectionNode _rootNode; - private bool _singleSelect; - private bool _autoSelect; - private int _operationCount; - private IndexPath _oldAnchorIndex; - private IReadOnlyList? _selectedIndicesCached; - private IReadOnlyList? _selectedItemsCached; - private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs; - - public event EventHandler? ChildrenRequested; - public event PropertyChangedEventHandler? PropertyChanged; - public event EventHandler? SelectionChanged; - - public SelectionModel() - { - _rootNode = new SelectionNode(this, null); - SharedLeafNode = new SelectionNode(this, null); - } - - public object? Source - { - get => _rootNode.Source; - set - { - if (_rootNode.Source != value) - { - var raiseChanged = _rootNode.Source == null && SelectedIndices.Count > 0; - - if (_rootNode.Source != null) - { - // Temporarily prevent auto-select when switching source. - var restoreAutoSelect = _autoSelect; - _autoSelect = false; - - try - { - using (var operation = new Operation(this)) - { - ClearSelection(resetAnchor: true); - } - } - finally - { - _autoSelect = restoreAutoSelect; - } - } - - _rootNode.Source = value; - ApplyAutoSelect(true); - - RaisePropertyChanged("Source"); - - if (raiseChanged) - { - var e = new SelectionModelSelectionChangedEventArgs( - null, - SelectedIndices, - null, - SelectedItems); - OnSelectionChanged(e); - } - } - } - } - - public bool SingleSelect - { - get => _singleSelect; - set - { - if (_singleSelect != value) - { - _singleSelect = value; - var selectedIndices = SelectedIndices; - - 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); - SelectWithPathImpl(firstSelectionIndexPath, select: true); - SelectedIndex = firstSelectionIndexPath; - } - - RaisePropertyChanged("SingleSelect"); - } - } - } - - public bool RetainSelectionOnReset - { - get => _rootNode.RetainSelectionOnReset; - set => _rootNode.RetainSelectionOnReset = value; - } - - public bool AutoSelect - { - get => _autoSelect; - set - { - if (_autoSelect != value) - { - _autoSelect = value; - ApplyAutoSelect(true); - } - } - } - - public IndexPath AnchorIndex - { - get - { - IndexPath anchor = default; - - if (_rootNode.AnchorIndex >= 0) - { - var path = new List(); - SelectionNode? current = _rootNode; - - while (current?.AnchorIndex >= 0) - { - path.Add(current.AnchorIndex); - current = current.GetAt(current.AnchorIndex, false, default); - } - - anchor = new IndexPath(path); - } - - return anchor; - } - set - { - var oldValue = AnchorIndex; - - if (value != null) - { - SelectionTreeHelper.TraverseIndexPath( - _rootNode, - value, - realizeChildren: true, - (currentNode, path, depth, childIndex) => currentNode.AnchorIndex = path.GetAt(depth)); - } - else - { - _rootNode.AnchorIndex = -1; - } - - if (_operationCount == 0 && oldValue != AnchorIndex) - { - RaisePropertyChanged("AnchorIndex"); - } - } - } - - public IndexPath SelectedIndex - { - get - { - IndexPath selectedIndex = default; - var selectedIndices = SelectedIndices; - - if (selectedIndices?.Count > 0) - { - selectedIndex = selectedIndices[0]; - } - - return selectedIndex; - } - set - { - if (!IsSelectedAt(value) || SelectedItems.Count > 1) - { - using var operation = new Operation(this); - ClearSelection(resetAnchor: true); - SelectWithPathImpl(value, select: true); - } - } - } - - public object? SelectedItem - { - get - { - object? item = null; - var selectedItems = SelectedItems; - - if (selectedItems?.Count > 0) - { - item = selectedItems[0]; - } - - return item; - } - } - - public IReadOnlyList SelectedItems - { - get - { - if (_selectedItemsCached == null) - { - var selectedInfos = new List(); - var count = 0; - - if (_rootNode.Source != null) - { - SelectionTreeHelper.Traverse( - _rootNode, - realizeChildren: false, - currentInfo => - { - if (currentInfo.Node.SelectedCount > 0) - { - selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); - count += currentInfo.Node.SelectedCount; - } - }); - } - - // Instead of creating a dumb vector that takes up the space for all the selected items, - // we create a custom IReadOnlyList implementation that calls back using a delegate to find - // the selected item at a particular index. This avoid having to create the storage and copying - // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an - // easier to consume flat vector view of objects. - var selectedItems = new SelectedItems ( - selectedInfos, - count, - (infos, index) => - { - var currentIndex = 0; - object? item = null; - - foreach (var info in infos) - { - var node = info.Node; - - if (node != null) - { - var currentCount = node.SelectedCount; - - if (index >= currentIndex && index < currentIndex + currentCount) - { - var targetIndex = node.SelectedIndices[index - currentIndex]; - item = node.ItemsSourceView!.GetAt(targetIndex); - break; - } - - currentIndex += currentCount; - } - else - { - throw new InvalidOperationException( - "Selection has changed since SelectedItems property was read."); - } - } - - return item; - }); - - _selectedItemsCached = selectedItems; - } - - return _selectedItemsCached; - } - } - - public IReadOnlyList SelectedIndices - { - get - { - if (_selectedIndicesCached == null) - { - var selectedInfos = new List(); - var count = 0; - - SelectionTreeHelper.Traverse( - _rootNode, - false, - currentInfo => - { - if (currentInfo.Node.SelectedCount > 0) - { - selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path)); - count += currentInfo.Node.SelectedCount; - } - }); - - // Instead of creating a dumb vector that takes up the space for all the selected indices, - // we create a custom VectorView implimentation that calls back using a delegate to find - // the IndexPath at a particular index. This avoid having to create the storage and copying - // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an - // easier to consume flat vector view of IndexPaths. - var indices = new SelectedItems( - selectedInfos, - count, - (infos, index) => // callback for GetAt(index) - { - var currentIndex = 0; - IndexPath path = default; - - foreach (var info in infos) - { - var node = info.Node; - - if (node != null) - { - var currentCount = node.SelectedCount; - if (index >= currentIndex && index < currentIndex + currentCount) - { - int targetIndex = node.SelectedIndices[index - currentIndex]; - path = info.Path.CloneWithChildIndex(targetIndex); - break; - } - - currentIndex += currentCount; - } - else - { - throw new InvalidOperationException( - "Selection has changed since SelectedIndices property was read."); - } - } - - return path; - }); - - _selectedIndicesCached = indices; - } - - return _selectedIndicesCached; - } - } - - internal SelectionNode SharedLeafNode { get; private set; } - - public void Dispose() - { - ClearSelection(resetAnchor: false); - _rootNode.Cleanup(); - _rootNode.Dispose(); - _selectedIndicesCached = null; - _selectedItemsCached = null; - } - - public void SetAnchorIndex(int index) => AnchorIndex = new IndexPath(index); - - public void SetAnchorIndex(int groupIndex, int index) => AnchorIndex = new IndexPath(groupIndex, index); - - public void Select(int index) - { - using var operation = new Operation(this); - SelectImpl(index, 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) - { - using var operation = new Operation(this); - SelectWithPathImpl(index, select: true); - } - - public void Deselect(int index) - { - using var operation = new Operation(this); - SelectImpl(index, 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) - { - using var operation = new Operation(this); - SelectWithPathImpl(index, select: false); - } - - public bool IsSelected(int index) => _rootNode.IsSelected(index); - - public bool IsSelected(int grouIndex, int itemIndex) - { - return IsSelectedAt(new IndexPath(grouIndex, itemIndex)); - } - - public bool IsSelectedAt(IndexPath index) - { - var path = index; - SelectionNode? node = _rootNode; - - for (int i = 0; i < path.GetSize() - 1; i++) - { - var childIndex = path.GetAt(i); - node = node.GetAt(childIndex, false, default); - - if (node == null) - { - return false; - } - } - - return node.IsSelected(index.GetAt(index.GetSize() - 1)); - } - - public bool? IsSelectedWithPartial(int index) - { - if (index < 0) - { - throw new ArgumentException("Index must be >= 0", nameof(index)); - } - - var isSelected = _rootNode.IsSelectedWithPartial(index); - return isSelected; - } - - public bool? IsSelectedWithPartial(int groupIndex, int itemIndex) - { - if (groupIndex < 0) - { - throw new ArgumentException("Group index must be >= 0", nameof(groupIndex)); - } - - if (itemIndex < 0) - { - throw new ArgumentException("Item index must be >= 0", nameof(itemIndex)); - } - - var isSelected = (bool?)false; - var childNode = _rootNode.GetAt(groupIndex, false, default); - - if (childNode != null) - { - isSelected = childNode.IsSelectedWithPartial(itemIndex); - } - - return isSelected; - } - - public bool? IsSelectedWithPartialAt(IndexPath index) - { - var path = index; - var isRealized = true; - SelectionNode? node = _rootNode; - - for (int i = 0; i < path.GetSize() - 1; i++) - { - var childIndex = path.GetAt(i); - node = node.GetAt(childIndex, false, default); - - if (node == null) - { - isRealized = false; - break; - } - } - - var isSelected = (bool?)false; - - if (isRealized) - { - var size = path.GetSize(); - if (size == 0) - { - isSelected = SelectionNode.ConvertToNullableBool(node!.EvaluateIsSelectedBasedOnChildrenNodes()); - } - else - { - isSelected = node!.IsSelectedWithPartial(path.GetAt(size - 1)); - } - } - - return isSelected; - } - - public void SelectRangeFromAnchor(int index) - { - 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, - info => - { - if (info.Node.DataCount > 0) - { - info.Node.SelectAll(); - } - }); - } - - public void ClearSelection() - { - using var operation = new Operation(this); - ClearSelection(resetAnchor: true); - } - - public IDisposable Update() => new Operation(this); - - protected void OnPropertyChanged(string propertyName) - { - RaisePropertyChanged(propertyName); - } - - private void RaisePropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - public void OnSelectionInvalidatedDueToCollectionChange( - bool selectionInvalidated, - IReadOnlyList? removedItems) - { - SelectionModelSelectionChangedEventArgs? e = null; - - if (selectionInvalidated) - { - e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null); - } - - OnSelectionChanged(e); - ApplyAutoSelect(true); - } - - internal IObservable? ResolvePath( - object data, - IndexPath dataIndexPath, - IndexPath finalIndexPath) - { - IObservable? resolved = null; - - // Raise ChildrenRequested event if there is a handler - if (ChildrenRequested != null) - { - if (_childrenRequestedEventArgs == null) - { - _childrenRequestedEventArgs = new SelectionModelChildrenRequestedEventArgs( - data, - dataIndexPath, - finalIndexPath, - false); - } - else - { - _childrenRequestedEventArgs.Initialize(data, dataIndexPath, finalIndexPath, false); - } - - ChildrenRequested(this, _childrenRequestedEventArgs); - resolved = _childrenRequestedEventArgs.Children; - - // Clear out the values in the args so that it cannot be used after the event handler call. - _childrenRequestedEventArgs.Initialize(null, default, default, true); - } - - return resolved; - } - - private void ClearSelection(bool resetAnchor) - { - SelectionTreeHelper.Traverse( - _rootNode, - realizeChildren: false, - info => info.Node.Clear()); - - if (resetAnchor) - { - AnchorIndex = default; - } - - OnSelectionChanged(); - } - - private void OnSelectionChanged(SelectionModelSelectionChangedEventArgs? e = null) - { - _selectedIndicesCached = null; - _selectedItemsCached = null; - - if (e != null) - { - SelectionChanged?.Invoke(this, e); - - RaisePropertyChanged(nameof(SelectedIndex)); - RaisePropertyChanged(nameof(SelectedIndices)); - - if (_rootNode.Source != null) - { - RaisePropertyChanged(nameof(SelectedItem)); - RaisePropertyChanged(nameof(SelectedItems)); - } - } - } - - private void SelectImpl(int index, bool select) - { - if (_singleSelect) - { - ClearSelection(resetAnchor: true); - } - - var selected = _rootNode.Select(index, select); - - if (selected) - { - AnchorIndex = new IndexPath(index); - } - - OnSelectionChanged(); - } - - private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select) - { - if (_singleSelect) - { - ClearSelection(resetAnchor: true); - } - - var childNode = _rootNode.GetAt(groupIndex, true, new IndexPath(groupIndex, itemIndex)); - var selected = childNode!.Select(itemIndex, select); - - if (selected) - { - AnchorIndex = new IndexPath(groupIndex, itemIndex); - } - - OnSelectionChanged(); - } - - private void SelectWithPathImpl(IndexPath index, bool select) - { - bool selected = false; - - if (_singleSelect) - { - ClearSelection(resetAnchor: true); - } - - SelectionTreeHelper.TraverseIndexPath( - _rootNode, - index, - true, - (currentNode, path, depth, childIndex) => - { - if (depth == path.GetSize() - 1) - { - selected = currentNode.Select(childIndex, select); - } - } - ); - - if (selected) - { - AnchorIndex = index; - } - - OnSelectionChanged(); - } - - private void SelectRangeFromAnchorImpl(int index, bool select) - { - int anchorIndex = 0; - var anchor = AnchorIndex; - - if (anchor != null) - { - anchorIndex = anchor.GetAt(0); - } - - _rootNode.SelectRange(new IndexRange(anchorIndex, index), select); - OnSelectionChanged(); - } - - private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select) - { - var startGroupIndex = 0; - var startItemIndex = 0; - var anchorIndex = AnchorIndex; - - if (anchorIndex != null) - { - startGroupIndex = anchorIndex.GetAt(0); - startItemIndex = anchorIndex.GetAt(1); - } - - // Make sure start > end - if (startGroupIndex > endGroupIndex || - (startGroupIndex == endGroupIndex && startItemIndex > endItemIndex)) - { - int temp = startGroupIndex; - startGroupIndex = endGroupIndex; - endGroupIndex = temp; - temp = startItemIndex; - startItemIndex = endItemIndex; - endItemIndex = temp; - } - - for (int groupIdx = startGroupIndex; groupIdx <= endGroupIndex; groupIdx++) - { - var groupNode = _rootNode.GetAt(groupIdx, true, new IndexPath(endGroupIndex, endItemIndex))!; - int startIndex = groupIdx == startGroupIndex ? startItemIndex : 0; - int endIndex = groupIdx == endGroupIndex ? endItemIndex : groupNode.DataCount - 1; - groupNode.SelectRange(new IndexRange(startIndex, endIndex), select); - } - - OnSelectionChanged(); - } - - private void SelectRangeImpl(IndexPath start, IndexPath end, bool select) - { - var winrtStart = start; - var winrtEnd = end; - - // Make sure start <= end - if (winrtEnd.CompareTo(winrtStart) == -1) - { - var temp = winrtStart; - winrtStart = winrtEnd; - winrtEnd = temp; - } - - // Note: Since we do not know the depth of the tree, we have to walk to each leaf - SelectionTreeHelper.TraverseRangeRealizeChildren( - _rootNode, - winrtStart, - winrtEnd, - info => - { - if (info.Path >= winrtStart && info.Path <= winrtEnd) - { - info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); - } - }); - - OnSelectionChanged(); - } - - private void BeginOperation() - { - if (_operationCount++ == 0) - { - _oldAnchorIndex = AnchorIndex; - _rootNode.BeginOperation(); - } - } - - private void EndOperation() - { - if (_operationCount == 0) - { - throw new AvaloniaInternalException("No selection operation in progress."); - } - - SelectionModelSelectionChangedEventArgs? e = null; - - if (--_operationCount == 0) - { - ApplyAutoSelect(false); - - var changes = new List(); - _rootNode.EndOperation(changes); - - if (changes.Count > 0) - { - var changeSet = new SelectionModelChangeSet(changes); - e = changeSet.CreateEventArgs(); - } - - OnSelectionChanged(e); - - if (_oldAnchorIndex != AnchorIndex) - { - RaisePropertyChanged(nameof(AnchorIndex)); - } - - _rootNode.Cleanup(); - _oldAnchorIndex = default; - } - } - - private void ApplyAutoSelect(bool createOperation) - { - if (AutoSelect) - { - _selectedIndicesCached = null; - - if (SelectedIndex == default && _rootNode.ItemsSourceView?.Count > 0) - { - if (createOperation) - { - using var operation = new Operation(this); - SelectImpl(0, true); - } - else - { - SelectImpl(0, true); - } - } - } - } - - internal class SelectedItemInfo : ISelectedItemInfo - { - public SelectedItemInfo(SelectionNode node, IndexPath path) - { - Node = node; - Path = path; - } - - public SelectionNode Node { get; } - public IndexPath Path { get; } - public int Count => Node.SelectedCount; - } - - 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 deleted file mode 100644 index d1df38656a..0000000000 --- a/src/Avalonia.Controls/SelectionModelChangeSet.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System; -using System.Collections.Generic; - -#nullable enable - -namespace Avalonia.Controls -{ - internal class SelectionModelChangeSet - { - private readonly List _changes; - - public SelectionModelChangeSet(List changes) - { - _changes = changes; - } - - public SelectionModelSelectionChangedEventArgs CreateEventArgs() - { - var deselectedIndexCount = 0; - var selectedIndexCount = 0; - var deselectedItemCount = 0; - var selectedItemCount = 0; - - foreach (var change in _changes) - { - deselectedIndexCount += change.DeselectedCount; - selectedIndexCount += change.SelectedCount; - - if (change.Items != null) - { - deselectedItemCount += change.DeselectedCount; - selectedItemCount += change.SelectedCount; - } - } - - var deselectedIndices = new SelectedItems( - _changes, - deselectedIndexCount, - GetDeselectedIndexAt); - var selectedIndices = new SelectedItems( - _changes, - selectedIndexCount, - GetSelectedIndexAt); - var deselectedItems = new SelectedItems( - _changes, - deselectedItemCount, - GetDeselectedItemAt); - var selectedItems = new SelectedItems( - _changes, - selectedItemCount, - GetSelectedItemAt); - - return new SelectionModelSelectionChangedEventArgs( - deselectedIndices, - selectedIndices, - deselectedItems, - selectedItems); - } - - private IndexPath GetDeselectedIndexAt( - List infos, - int index) - { - static int GetCount(SelectionNodeOperation info) => info.DeselectedCount; - static List? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; - return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x)); - } - - private IndexPath GetSelectedIndexAt( - List infos, - int index) - { - static int GetCount(SelectionNodeOperation info) => info.SelectedCount; - static List? GetRanges(SelectionNodeOperation info) => info.SelectedRanges; - return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x)); - } - - private object? GetDeselectedItemAt( - List infos, - int index) - { - static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.DeselectedCount : 0; - static List? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges; - return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x)); - } - - private object? GetSelectedItemAt( - List infos, - int index) - { - static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.SelectedCount : 0; - static List? GetRanges(SelectionNodeOperation info) => info.SelectedRanges; - return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x)); - } - - private IndexPath GetIndexAt( - List infos, - int index, - Func getCount, - Func?> getRanges) - { - var currentIndex = 0; - IndexPath path = default; - - foreach (var info in infos) - { - var currentCount = getCount(info); - - if (index >= currentIndex && index < currentIndex + currentCount) - { - int targetIndex = GetIndexAt(getRanges(info), index - currentIndex); - path = info.Path.CloneWithChildIndex(targetIndex); - break; - } - - currentIndex += currentCount; - } - - return path; - } - - private object? GetItemAt( - List infos, - int index, - Func getCount, - Func?> getRanges) - { - var currentIndex = 0; - object? item = null; - - foreach (var info in infos) - { - var currentCount = getCount(info); - - if (index >= currentIndex && index < currentIndex + currentCount) - { - int targetIndex = GetIndexAt(getRanges(info), index - currentIndex); - item = info.Items?.Count > targetIndex ? info.Items?.GetAt(targetIndex) : null; - break; - } - - currentIndex += currentCount; - } - - return item; - } - - private int GetIndexAt(List? ranges, int index) - { - var currentIndex = 0; - - if (ranges != null) - { - foreach (var range in ranges) - { - var currentCount = (range.End - range.Begin) + 1; - - if (index >= currentIndex && index < currentIndex + currentCount) - { - return range.Begin + (index - currentIndex); - } - - currentIndex += currentCount; - } - } - - throw new IndexOutOfRangeException(); - } - } -} diff --git a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs deleted file mode 100644 index b1f3e0b2c4..0000000000 --- a/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs +++ /dev/null @@ -1,103 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; - -#nullable enable - -namespace Avalonia.Controls -{ - /// - /// Provides data for the event. - /// - public class SelectionModelChildrenRequestedEventArgs : EventArgs - { - private object? _source; - private IndexPath _sourceIndexPath; - private IndexPath _finalIndexPath; - private bool _throwOnAccess; - - internal SelectionModelChildrenRequestedEventArgs( - object source, - IndexPath sourceIndexPath, - IndexPath finalIndexPath, - bool throwOnAccess) - { - source = source ?? throw new ArgumentNullException(nameof(source)); - Initialize(source, sourceIndexPath, finalIndexPath, throwOnAccess); - } - - /// - /// Gets or sets an observable which produces the children of the - /// object. - /// - public IObservable? Children { get; set; } - - /// - /// Gets the object whose children are being requested. - /// - public object Source - { - get - { - if (_throwOnAccess) - { - throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs)); - } - - return _source!; - } - } - - /// - /// Gets the index of the object whose children are being requested. - /// - public IndexPath SourceIndex - { - get - { - if (_throwOnAccess) - { - throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs)); - } - - return _sourceIndexPath; - } - } - - /// - /// Gets the index of the final object which is being attempted to be retrieved. - /// - public IndexPath FinalIndex - { - get - { - if (_throwOnAccess) - { - throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs)); - } - - return _finalIndexPath; - } - } - - internal void Initialize( - object? source, - IndexPath sourceIndexPath, - IndexPath finalIndexPath, - bool throwOnAccess) - { - if (!throwOnAccess && source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - _source = source; - _sourceIndexPath = sourceIndexPath; - _finalIndexPath = finalIndexPath; - _throwOnAccess = throwOnAccess; - } - } -} diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs deleted file mode 100644 index 5e2efdf331..0000000000 --- a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs +++ /dev/null @@ -1,47 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections.Generic; - -#nullable enable - -namespace Avalonia.Controls -{ - public class SelectionModelSelectionChangedEventArgs : EventArgs - { - public SelectionModelSelectionChangedEventArgs( - IReadOnlyList? deselectedIndices, - IReadOnlyList? selectedIndices, - IReadOnlyList? deselectedItems, - IReadOnlyList? selectedItems) - { - DeselectedIndices = deselectedIndices ?? Array.Empty(); - SelectedIndices = selectedIndices ?? Array.Empty(); - DeselectedItems = deselectedItems ?? Array.Empty(); - SelectedItems= selectedItems ?? Array.Empty(); - } - - /// - /// Gets the indices of the items that were removed from the selection. - /// - public IReadOnlyList DeselectedIndices { get; } - - /// - /// Gets the indices of the items that were added to the selection. - /// - public IReadOnlyList SelectedIndices { get; } - - /// - /// Gets the items that were removed from the selection. - /// - public IReadOnlyList DeselectedItems { get; } - - /// - /// Gets the items that were added to the selection. - /// - public IReadOnlyList SelectedItems { get; } - } -} diff --git a/src/Avalonia.Controls/SelectionNode.cs b/src/Avalonia.Controls/SelectionNode.cs deleted file mode 100644 index d99606673e..0000000000 --- a/src/Avalonia.Controls/SelectionNode.cs +++ /dev/null @@ -1,971 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Linq; -using Avalonia.Controls.Utils; - -#nullable enable - -namespace Avalonia.Controls -{ - /// - /// Tracks nested selection. - /// - /// - /// SelectionNode is the internal tree data structure that we keep track of for selection in - /// a nested scenario. This would map to one ItemsSourceView/Collection. This node reacts to - /// collection changes and keeps the selected indices up to date. This can either be a leaf - /// node or a non leaf node. - /// - internal class SelectionNode : IDisposable - { - private readonly SelectionModel _manager; - private readonly List _childrenNodes = new List(); - private readonly SelectionNode? _parent; - private readonly List _selected = new List(); - private readonly List _selectedIndicesCached = new List(); - private IDisposable? _childrenSubscription; - private SelectionNodeOperation? _operation; - private object? _source; - private bool _selectedIndicesCacheIsValid; - private bool _retainSelectionOnReset; - private List? _selectedItems; - - public SelectionNode(SelectionModel manager, SelectionNode? parent) - { - _manager = manager; - _parent = parent; - } - - public int AnchorIndex { get; set; } = -1; - - public bool RetainSelectionOnReset - { - get => _retainSelectionOnReset; - set - { - if (_retainSelectionOnReset != value) - { - _retainSelectionOnReset = value; - - if (_retainSelectionOnReset) - { - _selectedItems = new List(); - PopulateSelectedItemsFromSelectedIndices(); - } - else - { - _selectedItems = null; - } - - foreach (var child in _childrenNodes) - { - if (child != null) - { - child.RetainSelectionOnReset = value; - } - } - } - } - } - - public object? Source - { - get => _source; - set - { - if (_source != value) - { - if (_source != null) - { - ClearSelection(); - ClearChildNodes(); - UnhookCollectionChangedHandler(); - } - - _source = value; - - // Setup ItemsSourceView - var newDataSource = value as ItemsSourceView; - - if (value != null && newDataSource == null) - { - newDataSource = new ItemsSourceView((IEnumerable)value); - } - - ItemsSourceView = newDataSource; - - TrimInvalidSelections(); - PopulateSelectedItemsFromSelectedIndices(); - HookupCollectionChangedHandler(); - OnSelectionChanged(); - } - } - } - - private void TrimInvalidSelections() - { - if (_selected == null || ItemsSourceView == null) - { - return; - } - - var validRange = ItemsSourceView.Count > 0 ? new IndexRange(0, ItemsSourceView.Count - 1) : new IndexRange(-1, -1); - var removed = new List(); - var removedCount = IndexRange.Intersect(_selected, validRange, removed); - - if (removedCount > 0) - { - using var operation = _manager.Update(); - SelectedCount -= removedCount; - OnSelectionChanged(); - _operation!.Deselected(removed); - } - } - - public ItemsSourceView? ItemsSourceView { get; private set; } - public int DataCount => ItemsSourceView?.Count ?? 0; - public int ChildrenNodeCount => _childrenNodes.Count; - public int RealizedChildrenNodeCount { get; private set; } - - public IndexPath IndexPath - { - get - { - var path = new List(); ; - var parent = _parent; - var child = this; - - while (parent != null) - { - var childNodes = parent._childrenNodes; - var index = childNodes.IndexOf(child); - - // We are walking up to the parent, so the path will be backwards - path.Insert(0, index); - child = parent; - parent = parent._parent; - } - - return new IndexPath(path); - } - } - - // For a genuine tree view, we dont know which node is leaf until we - // actually walk to it, so currently the tree builds up to the leaf. I don't - // create a bunch of leaf node instances - instead i use the same instance m_leafNode to avoid - // an explosion of node objects. However, I'm still creating the m_childrenNodes - // collection unfortunately. - public SelectionNode? GetAt(int index, bool realizeChild, IndexPath finalIndexPath) - { - SelectionNode? child = null; - - if (realizeChild) - { - if (ItemsSourceView == null || index < 0 || index >= ItemsSourceView.Count) - { - throw new IndexOutOfRangeException(); - } - - if (_childrenNodes.Count == 0) - { - if (ItemsSourceView != null) - { - for (int i = 0; i < ItemsSourceView.Count; i++) - { - _childrenNodes.Add(null); - } - } - } - - if (_childrenNodes[index] == null) - { - var childData = ItemsSourceView!.GetAt(index); - IObservable? resolver = null; - - if (childData != null) - { - var childDataIndexPath = IndexPath.CloneWithChildIndex(index); - resolver = _manager.ResolvePath(childData, childDataIndexPath, finalIndexPath); - } - - if (resolver != null) - { - child = new SelectionNode(_manager, parent: this); - child.SetChildrenObservable(resolver); - } - else if (childData is IEnumerable || childData is IList) - { - child = new SelectionNode(_manager, parent: this); - child.Source = childData; - } - else - { - child = _manager.SharedLeafNode; - } - - if (_operation != null && child != _manager.SharedLeafNode) - { - child.BeginOperation(); - } - - _childrenNodes[index] = child; - RealizedChildrenNodeCount++; - } - else - { - child = _childrenNodes[index]; - } - } - else - { - if (_childrenNodes.Count > 0) - { - child = _childrenNodes[index]; - } - } - - return child; - } - - public void SetChildrenObservable(IObservable resolver) - { - _childrenSubscription = resolver.Subscribe(x => - { - if (Source != null) - { - using (_manager.Update()) - { - SelectionTreeHelper.Traverse( - this, - realizeChildren: false, - info => info.Node.Clear()); - } - } - - Source = x; - }); - } - - public int SelectedCount { get; private set; } - - public bool IsSelected(int index) - { - var isSelected = false; - - foreach (var range in _selected) - { - if (range.Contains(index)) - { - isSelected = true; - break; - } - } - - return isSelected; - } - - // True -> Selected - // False -> Not Selected - // Null -> Some descendents are selected and some are not - public bool? IsSelectedWithPartial() - { - var isSelected = (bool?)false; - - if (_parent != null) - { - var parentsChildren = _parent._childrenNodes; - - var myIndexInParent = parentsChildren.IndexOf(this); - - if (myIndexInParent != -1) - { - isSelected = _parent.IsSelectedWithPartial(myIndexInParent); - } - } - - return isSelected; - } - - // True -> Selected - // False -> Not Selected - // Null -> Some descendents are selected and some are not - public bool? IsSelectedWithPartial(int index) - { - SelectionState selectionState; - - if (_childrenNodes.Count == 0 || // no nodes realized - _childrenNodes.Count <= index || // target node is not realized - _childrenNodes[index] == null || // target node is not realized - _childrenNodes[index] == _manager.SharedLeafNode) // target node is a leaf node. - { - // Ask parent if the target node is selected. - selectionState = IsSelected(index) ? SelectionState.Selected : SelectionState.NotSelected; - } - else - { - // targetNode is the node representing the index. This node is the parent. - // targetNode is a non-leaf node, containing one or many children nodes. Evaluate - // based on children of targetNode. - var targetNode = _childrenNodes[index]; - selectionState = targetNode!.EvaluateIsSelectedBasedOnChildrenNodes(); - } - - return ConvertToNullableBool(selectionState); - } - - public int SelectedIndex - { - get => SelectedCount > 0 ? SelectedIndices[0] : -1; - set - { - if (IsValidIndex(value) && (SelectedCount != 1 || !IsSelected(value))) - { - ClearSelection(); - - if (value != -1) - { - Select(value, true); - } - } - } - } - - public List SelectedIndices - { - get - { - if (!_selectedIndicesCacheIsValid) - { - _selectedIndicesCacheIsValid = true; - - foreach (var range in _selected) - { - for (int index = range.Begin; index <= range.End; index++) - { - // Avoid duplicates - if (!_selectedIndicesCached.Contains(index)) - { - _selectedIndicesCached.Add(index); - } - } - } - - // Sort the list for easy consumption - _selectedIndicesCached.Sort(); - } - - return _selectedIndicesCached; - } - } - - public IEnumerable SelectedItems - { - get => SelectedIndices.Select(x => ItemsSourceView!.GetAt(x)); - } - - public void Dispose() - { - _childrenSubscription?.Dispose(); - ItemsSourceView?.Dispose(); - ClearChildNodes(); - UnhookCollectionChangedHandler(); - } - - public void BeginOperation() - { - if (_operation != null) - { - throw new AvaloniaInternalException("Selection operation already in progress."); - } - - _operation = new SelectionNodeOperation(this); - - for (var i = 0; i < _childrenNodes.Count; ++i) - { - var child = _childrenNodes[i]; - - if (child != null && child != _manager.SharedLeafNode) - { - child.BeginOperation(); - } - } - } - - public void EndOperation(List changes) - { - if (_operation == null) - { - throw new AvaloniaInternalException("No selection operation in progress."); - } - - if (_operation.HasChanges) - { - changes.Add(_operation); - } - - _operation = null; - - for (var i = 0; i < _childrenNodes.Count; ++i) - { - var child = _childrenNodes[i]; - - if (child != null && child != _manager.SharedLeafNode) - { - child.EndOperation(changes); - } - } - } - - public bool Cleanup() - { - var result = SelectedCount == 0; - - for (var i = 0; i < _childrenNodes.Count; ++i) - { - var child = _childrenNodes[i]; - - if (child != null) - { - if (child.Cleanup()) - { - child.Dispose(); - _childrenNodes[i] = null; - } - else - { - result = false; - } - } - } - - return result; - } - - public bool Select(int index, bool select) - { - return Select(index, select, raiseOnSelectionChanged: true); - } - - public bool ToggleSelect(int index) - { - return Select(index, !IsSelected(index)); - } - - public void SelectAll() - { - if (ItemsSourceView != null) - { - var size = ItemsSourceView.Count; - - if (size > 0) - { - SelectRange(new IndexRange(0, size - 1), select: true); - } - } - } - - public void Clear() => ClearSelection(); - - public bool SelectRange(IndexRange range, bool select) - { - if (IsValidIndex(range.Begin) && IsValidIndex(range.End)) - { - if (select) - { - AddRange(range, raiseOnSelectionChanged: true); - } - else - { - RemoveRange(range, raiseOnSelectionChanged: true); - } - - return true; - } - - return false; - } - - private void HookupCollectionChangedHandler() - { - if (ItemsSourceView != null) - { - ItemsSourceView.CollectionChanged += OnSourceListChanged; - } - } - - private void UnhookCollectionChangedHandler() - { - if (ItemsSourceView != null) - { - ItemsSourceView.CollectionChanged -= OnSourceListChanged; - } - } - - private bool IsValidIndex(int index) - { - return ItemsSourceView == null || (index >= 0 && index < ItemsSourceView.Count); - } - - private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged) - { - var selected = new List(); - - SelectedCount += IndexRange.Add(_selected, addRange, selected); - - if (selected.Count > 0) - { - _operation?.Selected(selected); - - if (_selectedItems != null && ItemsSourceView != null) - { - for (var i = addRange.Begin; i <= addRange.End; ++i) - { - _selectedItems.Add(ItemsSourceView!.GetAt(i)); - } - } - - if (raiseOnSelectionChanged) - { - OnSelectionChanged(); - } - } - } - - private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged) - { - var removed = new List(); - - SelectedCount -= IndexRange.Remove(_selected, removeRange, removed); - - if (removed.Count > 0) - { - _operation?.Deselected(removed); - - if (_selectedItems != null) - { - for (var i = removeRange.Begin; i <= removeRange.End; ++i) - { - _selectedItems.Remove(ItemsSourceView!.GetAt(i)); - } - } - - if (raiseOnSelectionChanged) - { - OnSelectionChanged(); - } - } - } - - private void ClearSelection() - { - // Deselect all items - if (_selected.Count > 0) - { - _operation?.Deselected(_selected); - _selected.Clear(); - OnSelectionChanged(); - } - - _selectedItems?.Clear(); - SelectedCount = 0; - AnchorIndex = -1; - } - - private void ClearChildNodes() - { - for (int i = 0; i < _childrenNodes.Count; i++) - { - var child = _childrenNodes[i]; - - if (child != null && child != _manager.SharedLeafNode) - { - child.Dispose(); - _childrenNodes[i] = null; - } - } - - RealizedChildrenNodeCount = 0; - } - - private bool Select(int index, bool select, bool raiseOnSelectionChanged) - { - if (IsValidIndex(index)) - { - // Ignore duplicate selection calls - if (IsSelected(index) == select) - { - return true; - } - - var range = new IndexRange(index, index); - - if (select) - { - AddRange(range, raiseOnSelectionChanged); - } - else - { - RemoveRange(range, raiseOnSelectionChanged); - } - - return true; - } - - return false; - } - - private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args) - { - bool selectionInvalidated = false; - List? removed = null; - - switch (args.Action) - { - case NotifyCollectionChangedAction.Add: - { - selectionInvalidated = OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); - break; - } - - case NotifyCollectionChangedAction.Remove: - { - (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems); - break; - } - - case NotifyCollectionChangedAction.Reset: - { - if (_selectedItems == null) - { - ClearSelection(); - } - else - { - removed = RecreateSelectionFromSelectedItems(); - } - - selectionInvalidated = true; - break; - } - - case NotifyCollectionChangedAction.Replace: - { - (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems); - selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count); - break; - } - } - - if (selectionInvalidated) - { - OnSelectionChanged(); - } - - _manager.OnSelectionInvalidatedDueToCollectionChange(selectionInvalidated, removed); - } - - private bool OnItemsAdded(int index, int count) - { - var selectionInvalidated = false; - - // Update ranges for leaf items - var toAdd = new List(); - - for (int i = 0; i < _selected.Count; i++) - { - var range = _selected[i]; - - // The range is after the inserted items, need to shift the range right - if (range.End >= index) - { - int begin = range.Begin; - - // If the index left of newIndex is inside the range, - // Split the range and remember the left piece to add later - if (range.Contains(index - 1)) - { - range.Split(index - 1, out var before, out _); - toAdd.Add(before); - begin = index; - } - - // Shift the range to the right - _selected[i] = new IndexRange(begin + count, range.End + count); - selectionInvalidated = true; - } - } - - // Add the left sides of the split ranges - _selected.AddRange(toAdd); - - // Update for non-leaf if we are tracking non-leaf nodes - if (_childrenNodes.Count > 0) - { - selectionInvalidated = true; - for (int i = 0; i < count; i++) - { - _childrenNodes.Insert(index, null); - } - } - - // Adjust the anchor - if (AnchorIndex >= index) - { - AnchorIndex += count; - } - - // Check if adding a node invalidated an ancestors - // selection state. For example if parent was selected before - // adding a new item makes the parent partially selected now. - if (!selectionInvalidated) - { - var parent = _parent; - - while (parent != null) - { - var isSelected = parent.IsSelectedWithPartial(); - - // If a parent is selected, then it will become partially selected. - // If it is not selected or partially selected - there is no change. - if (isSelected == true) - { - selectionInvalidated = true; - break; - } - - parent = parent._parent; - } - } - - return selectionInvalidated; - } - - private (bool, List) OnItemsRemoved(int index, IList items) - { - var selectionInvalidated = false; - var removed = new List(); - var count = items.Count; - var isSelected = false; - - for (int i = 0; i <= count - 1; i++) - { - if (IsSelected(index + i)) - { - isSelected = true; - removed.Add(items[i]); - } - } - - if (isSelected) - { - var removeRange = new IndexRange(index, index + count - 1); - SelectedCount -= IndexRange.Remove(_selected, removeRange); - selectionInvalidated = true; - - if (_selectedItems != null) - { - foreach (var i in items) - { - _selectedItems.Remove(i); - } - } - } - - for (int i = 0; i < _selected.Count; i++) - { - var range = _selected[i]; - - // The range is after the removed items, need to shift the range left - if (range.End > index) - { - // Shift the range to the left - _selected[i] = new IndexRange(range.Begin - count, range.End - count); - selectionInvalidated = true; - } - } - - // Update for non-leaf if we are tracking non-leaf nodes - if (_childrenNodes.Count > 0) - { - selectionInvalidated = true; - for (int i = 0; i < count; i++) - { - if (_childrenNodes[index] != null) - { - removed.AddRange(_childrenNodes[index]!.SelectedItems); - RealizedChildrenNodeCount--; - _childrenNodes[index]!.Dispose(); - } - _childrenNodes.RemoveAt(index); - } - } - - //Adjust the anchor - if (AnchorIndex >= index) - { - AnchorIndex -= count; - } - - return (selectionInvalidated, removed); - } - - private void OnSelectionChanged() - { - _selectedIndicesCacheIsValid = false; - _selectedIndicesCached.Clear(); - } - - public static bool? ConvertToNullableBool(SelectionState isSelected) - { - bool? result = null; // PartialySelected - - if (isSelected == SelectionState.Selected) - { - result = true; - } - else if (isSelected == SelectionState.NotSelected) - { - result = false; - } - - return result; - } - - public SelectionState EvaluateIsSelectedBasedOnChildrenNodes() - { - var selectionState = SelectionState.NotSelected; - int realizedChildrenNodeCount = RealizedChildrenNodeCount; - int selectedCount = SelectedCount; - - if (realizedChildrenNodeCount != 0 || selectedCount != 0) - { - // There are realized children or some selected leaves. - int dataCount = DataCount; - if (realizedChildrenNodeCount == 0 && selectedCount > 0) - { - // All nodes are leaves under it - we didn't create children nodes as an optimization. - // See if all/some or none of the leaves are selected. - selectionState = dataCount != selectedCount ? - SelectionState.PartiallySelected : - dataCount == selectedCount ? SelectionState.Selected : SelectionState.NotSelected; - } - else - { - // There are child nodes, walk them individually and evaluate based on each child - // being selected/not selected or partially selected. - selectedCount = 0; - int notSelectedCount = 0; - for (int i = 0; i < ChildrenNodeCount; i++) - { - var child = GetAt(i, false, default); - - if (child != null) - { - // child is realized, ask it. - var isChildSelected = IsSelectedWithPartial(i); - if (isChildSelected == null) - { - selectionState = SelectionState.PartiallySelected; - break; - } - else if (isChildSelected == true) - { - selectedCount++; - } - else - { - notSelectedCount++; - } - } - else - { - // not realized. - if (IsSelected(i)) - { - selectedCount++; - } - else - { - notSelectedCount++; - } - } - - if (selectedCount > 0 && notSelectedCount > 0) - { - selectionState = SelectionState.PartiallySelected; - break; - } - } - - if (selectionState != SelectionState.PartiallySelected) - { - if (selectedCount != 0 && selectedCount != dataCount) - { - selectionState = SelectionState.PartiallySelected; - } - else - { - selectionState = selectedCount == dataCount ? SelectionState.Selected : SelectionState.NotSelected; - } - } - } - } - - return selectionState; - } - - private void PopulateSelectedItemsFromSelectedIndices() - { - if (_selectedItems != null) - { - _selectedItems.Clear(); - - foreach (var i in SelectedIndices) - { - _selectedItems.Add(ItemsSourceView!.GetAt(i)); - } - } - } - - private List RecreateSelectionFromSelectedItems() - { - var removed = new List(); - - _selected.Clear(); - SelectedCount = 0; - - for (var i = 0; i < _selectedItems!.Count; ++i) - { - var item = _selectedItems[i]; - var index = ItemsSourceView!.IndexOf(item); - - if (index != -1) - { - IndexRange.Add(_selected, new IndexRange(index, index)); - ++SelectedCount; - } - else - { - removed.Add(item); - _selectedItems.RemoveAt(i--); - } - } - - return removed; - } - - public enum SelectionState - { - Selected, - NotSelected, - PartiallySelected - } - } -} diff --git a/src/Avalonia.Controls/SelectionNodeOperation.cs b/src/Avalonia.Controls/SelectionNodeOperation.cs deleted file mode 100644 index 9622a52f00..0000000000 --- a/src/Avalonia.Controls/SelectionNodeOperation.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -#nullable enable - -namespace Avalonia.Controls -{ - internal class SelectionNodeOperation : ISelectedItemInfo - { - private readonly SelectionNode _owner; - private List? _selected; - private List? _deselected; - private int _selectedCount = -1; - private int _deselectedCount = -1; - - public SelectionNodeOperation(SelectionNode owner) - { - _owner = owner; - } - - public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0; - public List? SelectedRanges => _selected; - public List? DeselectedRanges => _deselected; - public IndexPath Path => _owner.IndexPath; - public ItemsSourceView? Items => _owner.ItemsSourceView; - - public int SelectedCount - { - get - { - if (_selectedCount == -1) - { - _selectedCount = (_selected != null) ? IndexRange.GetCount(_selected) : 0; - } - - return _selectedCount; - } - } - - public int DeselectedCount - { - get - { - if (_deselectedCount == -1) - { - _deselectedCount = (_deselected != null) ? IndexRange.GetCount(_deselected) : 0; - } - - return _deselectedCount; - } - } - - public void Selected(IndexRange range) - { - Add(range, ref _selected, _deselected); - _selectedCount = -1; - } - - public void Selected(IEnumerable ranges) - { - foreach (var range in ranges) - { - Selected(range); - } - } - - public void Deselected(IndexRange range) - { - Add(range, ref _deselected, _selected); - _deselectedCount = -1; - } - - public void Deselected(IEnumerable ranges) - { - foreach (var range in ranges) - { - Deselected(range); - } - } - - private static void Add( - IndexRange range, - ref List? add, - List? remove) - { - if (remove != null) - { - var removed = new List(); - IndexRange.Remove(remove, range, removed); - var selected = IndexRange.Subtract(range, removed); - - if (selected.Any()) - { - add ??= new List(); - - foreach (var r in selected) - { - IndexRange.Add(add, r); - } - } - } - else - { - add ??= new List(); - IndexRange.Add(add, range); - } - } - } -} diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index a91655855c..b4c30e0149 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -2,9 +2,11 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using System.Reactive.Linq; +using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; using Avalonia.Controls.Utils; @@ -44,29 +46,16 @@ namespace Avalonia.Controls o => o.SelectedItems, (o, v) => o.SelectedItems = v); - /// - /// Defines the property. - /// - public static readonly DirectProperty SelectionProperty = - SelectingItemsControl.SelectionProperty.AddOwner( - o => o.Selection, - (o, v) => o.Selection = v); - /// /// Defines the property. /// public static readonly StyledProperty SelectionModeProperty = ListBox.SelectionModeProperty.AddOwner(); - /// - /// Defines the property. - /// - public static RoutedEvent SelectionChangedEvent = - SelectingItemsControl.SelectionChangedEvent; - + private static readonly IList Empty = Array.Empty(); private object _selectedItem; - private ISelectionModel _selection; - private readonly SelectedItemsSync _selectedItems; + private IList _selectedItems; + private bool _syncingSelectedItems; /// /// Initializes static members of the class. @@ -76,13 +65,6 @@ namespace Avalonia.Controls // HACK: Needed or SelectedItem property will not be found in Release build. } - public TreeView() - { - // Setting Selection to null causes a default SelectionModel to be created. - Selection = null; - _selectedItems = new SelectedItemsSync(Selection); - } - /// /// Occurs when the control's selection changes. /// @@ -125,94 +107,56 @@ namespace Avalonia.Controls /// public object SelectedItem { - get => Selection.SelectedItem; - set => Selection.SelectedIndex = IndexFromItem(value); - } + get => _selectedItem; + set + { + var selectedItems = SelectedItems; - /// - /// Gets or sets the selected items. - /// - protected IList SelectedItems - { - get => _selectedItems.GetOrCreateItems(); - set => _selectedItems.SetItems(value); + SetAndRaise(SelectedItemProperty, ref _selectedItem, value); + + if (value != null) + { + if (selectedItems.Count != 1 || selectedItems[0] != value) + { + _syncingSelectedItems = true; + SelectSingleItem(value); + _syncingSelectedItems = false; + } + } + else if (SelectedItems.Count > 0) + { + SelectedItems.Clear(); + } + } } /// - /// Gets or sets a model holding the current selection. + /// Gets or sets the selected items. /// - public ISelectionModel Selection + public IList SelectedItems { - get => _selection; - set + get { - value ??= new SelectionModel + if (_selectedItems == null) { - SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), - AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected), - RetainSelectionOnReset = true, - }; - - if (_selection != value) - { - if (value == null) - { - throw new ArgumentNullException(nameof(value), "Cannot set Selection to null."); - } - else if (value.Source != null && value.Source != Items) - { - throw new ArgumentException("Selection has invalid Source."); - } - - List oldSelection = null; - - if (_selection != null) - { - oldSelection = Selection.SelectedItems.ToList(); - _selection.PropertyChanged -= OnSelectionModelPropertyChanged; - _selection.SelectionChanged -= OnSelectionModelSelectionChanged; - _selection.ChildrenRequested -= OnSelectionModelChildrenRequested; - MarkContainersUnselected(); - } - - _selection = value; - - if (_selection != null) - { - _selection.Source = Items; - _selection.PropertyChanged += OnSelectionModelPropertyChanged; - _selection.SelectionChanged += OnSelectionModelSelectionChanged; - _selection.ChildrenRequested += OnSelectionModelChildrenRequested; - - if (_selection.SingleSelect) - { - SelectionMode &= ~SelectionMode.Multiple; - } - else - { - SelectionMode |= SelectionMode.Multiple; - } - - if (_selection.AutoSelect) - { - SelectionMode |= SelectionMode.AlwaysSelected; - } - else - { - SelectionMode &= ~SelectionMode.AlwaysSelected; - } - - UpdateContainerSelection(); + _selectedItems = new AvaloniaList(); + SubscribeToSelectedItems(); + } - var selectedItem = SelectedItem; + return _selectedItems; + } - if (_selectedItem != selectedItem) - { - RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem); - _selectedItem = selectedItem; - } - } + set + { + if (value?.IsFixedSize == true || value?.IsReadOnly == true) + { + throw new NotSupportedException( + "Cannot use a fixed size or read-only collection as SelectedItems."); } + + UnsubscribeFromSelectedItems(); + _selectedItems = value ?? new AvaloniaList(); + SubscribeToSelectedItems(); } } @@ -245,13 +189,186 @@ namespace Avalonia.Controls /// Note that this method only selects nodes currently visible due to their parent nodes /// being expanded: it does not expand nodes. /// - public void SelectAll() => Selection.SelectAll(); + public void SelectAll() + { + SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items); + } /// /// Deselects all items in the . /// - public void UnselectAll() => Selection.ClearSelection(); + public void UnselectAll() + { + SelectedItems.Clear(); + } + + /// + /// Subscribes to the CollectionChanged event, if any. + /// + private void SubscribeToSelectedItems() + { + if (_selectedItems is INotifyCollectionChanged incc) + { + incc.CollectionChanged += SelectedItemsCollectionChanged; + } + + SelectedItemsCollectionChanged( + _selectedItems, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + private void SelectSingleItem(object item) + { + SelectedItems.Clear(); + SelectedItems.Add(item); + } + + /// + /// Called when the CollectionChanged event is raised. + /// + /// The event sender. + /// The event args. + private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + IList added = null; + IList removed = null; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + + SelectedItemsAdded(e.NewItems.Cast().ToArray()); + + if (AutoScrollToSelectedItem) + { + var container = (TreeViewItem)ItemContainerGenerator.Index.ContainerFromItem(e.NewItems[0]); + + container?.BringIntoView(); + } + + added = e.NewItems; + + break; + case NotifyCollectionChangedAction.Remove: + + if (!_syncingSelectedItems) + { + if (SelectedItems.Count == 0) + { + SelectedItem = null; + } + else + { + var selectedIndex = SelectedItems.IndexOf(_selectedItem); + + if (selectedIndex == -1) + { + var old = _selectedItem; + _selectedItem = SelectedItems[0]; + + RaisePropertyChanged(SelectedItemProperty, old, _selectedItem); + } + } + } + + foreach (var item in e.OldItems) + { + MarkItemSelected(item, false); + } + + removed = e.OldItems; + + break; + case NotifyCollectionChangedAction.Reset: + + foreach (IControl container in ItemContainerGenerator.Index.Containers) + { + MarkContainerSelected(container, false); + } + + if (SelectedItems.Count > 0) + { + SelectedItemsAdded(SelectedItems); + + added = SelectedItems; + } + else if (!_syncingSelectedItems) + { + SelectedItem = null; + } + + break; + case NotifyCollectionChangedAction.Replace: + + foreach (var item in e.OldItems) + { + MarkItemSelected(item, false); + } + + foreach (var item in e.NewItems) + { + MarkItemSelected(item, true); + } + + if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems) + { + var oldItem = SelectedItem; + var item = SelectedItems[0]; + _selectedItem = item; + RaisePropertyChanged(SelectedItemProperty, oldItem, item); + } + + added = e.NewItems; + removed = e.OldItems; + + break; + } + + if (added?.Count > 0 || removed?.Count > 0) + { + var changed = new SelectionChangedEventArgs( + SelectingItemsControl.SelectionChangedEvent, + removed ?? Empty, + added ?? Empty); + RaiseEvent(changed); + } + } + + private void MarkItemSelected(object item, bool selected) + { + var container = ItemContainerGenerator.Index.ContainerFromItem(item); + + MarkContainerSelected(container, selected); + } + + private void SelectedItemsAdded(IList items) + { + if (items.Count == 0) + { + return; + } + + foreach (object item in items) + { + MarkItemSelected(item, true); + } + + if (SelectedItem == null && !_syncingSelectedItems) + { + SetAndRaise(SelectedItemProperty, ref _selectedItem, items[0]); + } + } + + /// + /// Unsubscribes from the CollectionChanged event, if any. + /// + private void UnsubscribeFromSelectedItems() + { + if (_selectedItems is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= SelectedItemsCollectionChanged; + } + } (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction) { @@ -334,86 +451,6 @@ namespace Avalonia.Controls } } - /// - /// Called when is raised. - /// - /// The sender. - /// The event args. - private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) - { - var container = ContainerFromIndex(Selection.AnchorIndex); - - if (container != null) - { - DispatcherTimer.RunOnce(container.BringIntoView, TimeSpan.Zero); - } - } - } - - /// - /// Called when is raised. - /// - /// The sender. - /// The event args. - private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) - { - void Mark(IndexPath index, bool selected) - { - var container = ContainerFromIndex(index); - - if (container != null) - { - MarkContainerSelected(container, selected); - } - } - - foreach (var i in e.SelectedIndices) - { - Mark(i, true); - } - - foreach (var i in e.DeselectedIndices) - { - Mark(i, false); - } - - var newSelectedItem = SelectedItem; - - if (newSelectedItem != _selectedItem) - { - RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem); - _selectedItem = newSelectedItem; - } - - var ev = new SelectionChangedEventArgs( - SelectionChangedEvent, - e.DeselectedItems.ToList(), - e.SelectedItems.ToList()); - RaiseEvent(ev); - } - - private void OnSelectionModelChildrenRequested(object sender, SelectionModelChildrenRequestedEventArgs e) - { - var container = ItemContainerGenerator.Index.ContainerFromItem(e.Source) as TreeViewItem; - - if (container is object) - { - if (e.SourceIndex.IsAncestorOf(e.FinalIndex)) - { - container.IsExpanded = true; - container.ApplyTemplate(); - container.Presenter?.ApplyTemplate(); - } - - e.Children = Observable.CombineLatest( - container.GetObservable(TreeViewItem.IsExpandedProperty), - container.GetObservable(ItemsProperty), - (expanded, items) => expanded ? items : null); - } - } - private TreeViewItem GetContainerInDirection( TreeViewItem from, NavigationDirection direction, @@ -467,12 +504,6 @@ namespace Avalonia.Controls return result; } - protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) - { - Selection.Source = Items; - base.ItemsChanged(e); - } - /// protected override void OnPointerPressed(PointerPressedEventArgs e) { @@ -494,18 +525,6 @@ namespace Avalonia.Controls } } - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == SelectionModeProperty) - { - var mode = change.NewValue.GetValueOrDefault(); - Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple); - Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected); - } - } - /// /// Updates the selection for an item based on user interaction. /// @@ -521,9 +540,9 @@ namespace Avalonia.Controls bool toggleModifier = false, bool rightButton = false) { - var index = IndexFromContainer((TreeViewItem)container); + var item = ItemContainerGenerator.Index.ItemFromContainer(container); - if (index.GetSize() == 0) + if (item == null) { return; } @@ -540,48 +559,41 @@ namespace Avalonia.Controls var multi = (mode & SelectionMode.Multiple) != 0; var range = multi && selectedContainer != null && rangeModifier; - if (!select) + if (rightButton) { - Selection.DeselectAt(index); - } - else if (rightButton) - { - if (!Selection.IsSelectedAt(index)) + if (!SelectedItems.Contains(item)) { - Selection.SelectedIndex = index; + SelectSingleItem(item); } } else if (!toggle && !range) { - Selection.SelectedIndex = index; + SelectSingleItem(item); } else if (multi && range) { - using var operation = Selection.Update(); - var anchor = Selection.AnchorIndex; - - if (anchor.GetSize() == 0) - { - anchor = new IndexPath(0); - } - - Selection.ClearSelection(); - Selection.AnchorIndex = anchor; - Selection.SelectRangeFromAnchorTo(index); + SynchronizeItems( + SelectedItems, + GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem)); } else { - if (Selection.IsSelectedAt(index)) - { - Selection.DeselectAt(index); - } - else if (multi) + var i = SelectedItems.IndexOf(item); + + if (i != -1) { - Selection.SelectAt(index); + SelectedItems.Remove(item); } else { - Selection.SelectedIndex = index; + if (multi) + { + SelectedItems.Add(item); + } + else + { + SelectedItem = item; + } } } } @@ -604,6 +616,117 @@ namespace Avalonia.Controls } } + /// + /// Find which node is first in hierarchy. + /// + /// Search root. + /// Nodes to find. + /// Node to find. + /// Found first node. + private static TreeViewItem FindFirstNode(TreeView treeView, TreeViewItem nodeA, TreeViewItem nodeB) + { + return FindInContainers(treeView.ItemContainerGenerator, nodeA, nodeB); + } + + private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator, + TreeViewItem nodeA, + TreeViewItem nodeB) + { + IEnumerable containers = containerGenerator.Containers; + + foreach (ItemContainerInfo container in containers) + { + TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, nodeA, nodeB); + + if (node != null) + { + return node; + } + } + + return null; + } + + private static TreeViewItem FindFirstNode(TreeViewItem node, TreeViewItem nodeA, TreeViewItem nodeB) + { + if (node == null) + { + return null; + } + + TreeViewItem match = node == nodeA ? nodeA : node == nodeB ? nodeB : null; + + if (match != null) + { + return match; + } + + return FindInContainers(node.ItemContainerGenerator, nodeA, nodeB); + } + + /// + /// Returns all items that belong to containers between and . + /// The range is inclusive. + /// + /// From container. + /// To container. + private List GetItemsInRange(TreeViewItem from, TreeViewItem to) + { + var items = new List(); + + if (from == null || to == null) + { + return items; + } + + TreeViewItem firstItem = FindFirstNode(this, from, to); + + if (firstItem == null) + { + return items; + } + + bool wasReversed = false; + + if (firstItem == to) + { + var temp = from; + + from = to; + to = temp; + + wasReversed = true; + } + + TreeViewItem node = from; + + while (node != to) + { + var item = ItemContainerGenerator.Index.ItemFromContainer(node); + + if (item != null) + { + items.Add(item); + } + + node = GetContainerInDirection(node, NavigationDirection.Down, true); + } + + var toItem = ItemContainerGenerator.Index.ItemFromContainer(to); + + if (toItem != null) + { + items.Add(toItem); + } + + if (wasReversed) + { + items.Reverse(); + } + + return items; + } + /// /// Updates the selection based on an event that may have originated in a container that /// belongs to the control. @@ -709,90 +832,26 @@ namespace Avalonia.Controls } } - private void MarkContainersUnselected() - { - foreach (var container in ItemContainerGenerator.Index.Containers) - { - MarkContainerSelected(container, false); - } - } - - private void UpdateContainerSelection() - { - var index = ItemContainerGenerator.Index; - - foreach (var container in index.Containers) - { - var i = IndexFromContainer((TreeViewItem)container); - - MarkContainerSelected( - container, - Selection.IsSelectedAt(i) != false); - } - } - - private static IndexPath IndexFromContainer(TreeViewItem container) - { - var result = new List(); - - while (true) - { - if (container.Level == 0) - { - var treeView = container.FindAncestorOfType(); - - if (treeView == null) - { - return default; - } - - result.Add(treeView.ItemContainerGenerator.IndexFromContainer(container)); - result.Reverse(); - return new IndexPath(result); - } - else - { - var parent = container.FindAncestorOfType(); - - if (parent == null) - { - return default; - } - - result.Add(parent.ItemContainerGenerator.IndexFromContainer(container)); - container = parent; - } - } - } - - private IndexPath IndexFromItem(object item) + /// + /// Makes a list of objects equal another (though doesn't preserve order). + /// + /// The items collection. + /// The desired items. + private static void SynchronizeItems(IList items, IEnumerable desired) { - var container = ItemContainerGenerator.Index.ContainerFromItem(item) as TreeViewItem; + var list = items.Cast().ToList(); + var toRemove = list.Except(desired).ToList(); + var toAdd = desired.Except(list).ToList(); - if (container != null) + foreach (var i in toRemove) { - return IndexFromContainer(container); + items.Remove(i); } - return default; - } - - private TreeViewItem ContainerFromIndex(IndexPath index) - { - TreeViewItem treeViewItem = null; - - for (var i = 0; i < index.GetSize(); ++i) + foreach (var i in toAdd) { - var generator = treeViewItem?.ItemContainerGenerator ?? ItemContainerGenerator; - treeViewItem = generator.ContainerFromIndex(index.GetAt(i)) as TreeViewItem; - - if (treeViewItem == null) - { - return null; - } + items.Add(i); } - - return treeViewItem; } } } diff --git a/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs new file mode 100644 index 0000000000..1e5ada8409 --- /dev/null +++ b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Runtime.CompilerServices; +using Avalonia.Threading; +using Avalonia.Utilities; + +#nullable enable + +namespace Avalonia.Controls.Utils +{ + internal interface ICollectionChangedListener + { + void PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e); + void Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e); + void PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e); + } + + internal class CollectionChangedEventManager : IWeakSubscriber + { + public static CollectionChangedEventManager Instance { get; } = new CollectionChangedEventManager(); + + private ConditionalWeakTable>> _entries = + new ConditionalWeakTable>>(); + + private CollectionChangedEventManager() + { + } + + public void AddListener(INotifyCollectionChanged collection, ICollectionChangedListener listener) + { + collection = collection ?? throw new ArgumentNullException(nameof(collection)); + listener = listener ?? throw new ArgumentNullException(nameof(listener)); + Dispatcher.UIThread.VerifyAccess(); + + if (!_entries.TryGetValue(collection, out var listeners)) + { + listeners = new List>(); + _entries.Add(collection, listeners); + WeakSubscriptionManager.Subscribe( + collection, + nameof(INotifyCollectionChanged.CollectionChanged), + this); + } + + foreach (var l in listeners) + { + if (l.TryGetTarget(out var target) && target == listener) + { + throw new InvalidOperationException( + "Collection listener already added for this collection/listener combination."); + } + } + + listeners.Add(new WeakReference(listener)); + } + + public void RemoveListener(INotifyCollectionChanged collection, ICollectionChangedListener listener) + { + collection = collection ?? throw new ArgumentNullException(nameof(collection)); + listener = listener ?? throw new ArgumentNullException(nameof(listener)); + Dispatcher.UIThread.VerifyAccess(); + + if (_entries.TryGetValue(collection, out var listeners)) + { + for (var i = 0; i < listeners.Count; ++i) + { + if (listeners[i].TryGetTarget(out var target) && target == listener) + { + listeners.RemoveAt(i); + + if (listeners.Count == 0) + { + WeakSubscriptionManager.Unsubscribe( + collection, + nameof(INotifyCollectionChanged.CollectionChanged), + this); + _entries.Remove(collection); + } + + return; + } + } + } + + throw new InvalidOperationException( + "Collection listener not registered for this collection/listener combination."); + } + + void IWeakSubscriber.OnEvent(object sender, NotifyCollectionChangedEventArgs e) + { + static void Notify( + INotifyCollectionChanged incc, + NotifyCollectionChangedEventArgs args, + List> listeners) + { + foreach (var l in listeners) + { + if (l.TryGetTarget(out var target)) + { + target.PreChanged(incc, args); + } + } + + foreach (var l in listeners) + { + if (l.TryGetTarget(out var target)) + { + target.Changed(incc, args); + } + } + + foreach (var l in listeners) + { + if (l.TryGetTarget(out var target)) + { + target.PostChanged(incc, args); + } + } + } + + if (sender is INotifyCollectionChanged incc && _entries.TryGetValue(incc, out var listeners)) + { + var l = listeners.ToList(); + + if (Dispatcher.UIThread.CheckAccess()) + { + Notify(incc, e, l); + } + else + { + var inccCapture = incc; + var eCapture = e; + Dispatcher.UIThread.Post(() => Notify(inccCapture, eCapture, l)); + } + } + } + } +} diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs index 91cef9fe64..83b62c7b6e 100644 --- a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -4,6 +4,7 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using Avalonia.Collections; +using Avalonia.Controls.Selection; #nullable enable @@ -12,121 +13,118 @@ namespace Avalonia.Controls.Utils /// /// Synchronizes an with a list of SelectedItems. /// - internal class SelectedItemsSync + internal class SelectedItemsSync : IDisposable { - private IList? _items; + private ISelectionModel _selectionModel; + private IList _selectedItems; private bool _updatingItems; private bool _updatingModel; - private bool _initializeOnSourceAssignment; public SelectedItemsSync(ISelectionModel model) { - model = model ?? throw new ArgumentNullException(nameof(model)); - Model = model; + _selectionModel = model ?? throw new ArgumentNullException(nameof(model)); + _selectedItems = new AvaloniaList(); + SyncSelectedItemsWithSelectionModel(); + SubscribeToSelectedItems(_selectedItems); + SubscribeToSelectionModel(model); } - public ISelectionModel Model { get; private set; } - - public IList GetOrCreateItems() + public ISelectionModel SelectionModel { - if (_items == null) + get => _selectionModel; + set { - var items = new AvaloniaList(Model.SelectedItems); - items.CollectionChanged += ItemsCollectionChanged; - Model.SelectionChanged += SelectionModelSelectionChanged; - _items = items; + if (_selectionModel != value) + { + value = value ?? throw new ArgumentNullException(nameof(value)); + UnsubscribeFromSelectionModel(_selectionModel); + _selectionModel = value; + SubscribeToSelectionModel(_selectionModel); + SyncSelectedItemsWithSelectionModel(); + } } - - return _items; } - - public void SetItems(IList? items) + + public IList SelectedItems { - items ??= new AvaloniaList(); - - if (items.IsFixedSize) + get => _selectedItems; + set { - throw new NotSupportedException( - "Cannot assign fixed size selection to SelectedItems."); - } + value ??= new AvaloniaList(); - if (_items is INotifyCollectionChanged incc) - { - incc.CollectionChanged -= ItemsCollectionChanged; - } + if (_selectedItems != value) + { + if (value.IsFixedSize) + { + throw new NotSupportedException( + "Cannot assign fixed size selection to SelectedItems."); + } - if (_items == null) - { - Model.SelectionChanged += SelectionModelSelectionChanged; + UnsubscribeFromSelectedItems(_selectedItems); + _selectedItems = value; + SubscribeToSelectedItems(_selectedItems); + SyncSelectionModelWithSelectedItems(); + } } + } + + public void Dispose() + { + UnsubscribeFromSelectedItems(_selectedItems); + UnsubscribeFromSelectionModel(_selectionModel); + } + + private void SyncSelectedItemsWithSelectionModel() + { + _updatingItems = true; try { - _updatingModel = true; - _items = items; + _selectedItems.Clear(); - if (Model.Source is object) + if (_selectionModel.Source is object) { - using (Model.Update()) + foreach (var i in _selectionModel.SelectedItems) { - Model.ClearSelection(); - Add(items); + _selectedItems.Add(i); } } - else if (!_initializeOnSourceAssignment) - { - Model.PropertyChanged += SelectionModelPropertyChanged; - _initializeOnSourceAssignment = true; - } - - if (_items is INotifyCollectionChanged incc2) - { - incc2.CollectionChanged += ItemsCollectionChanged; - } } finally { - _updatingModel = false; + _updatingItems = false; } } - public void SetModel(ISelectionModel model) + private void SyncSelectionModelWithSelectedItems() { - model = model ?? throw new ArgumentNullException(nameof(model)); + _updatingModel = true; - if (_items != null) + try { - Model.PropertyChanged -= SelectionModelPropertyChanged; - Model.SelectionChanged -= SelectionModelSelectionChanged; - Model = model; - Model.SelectionChanged += SelectionModelSelectionChanged; - _initializeOnSourceAssignment = false; - - try + if (_selectionModel.Source is object) { - _updatingItems = true; - _items.Clear(); - - foreach (var i in model.SelectedItems) + using (_selectionModel.BatchUpdate()) { - _items.Add(i); + SelectionModel.Clear(); + Add(_selectedItems); } } - finally - { - _updatingItems = false; - } + } + finally + { + _updatingModel = false; } } - private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { if (_updatingItems) { return; } - if (_items == null) + if (_selectedItems == null) { throw new AvaloniaInternalException("CollectionChanged raised but we don't have items."); } @@ -135,18 +133,18 @@ namespace Avalonia.Controls.Utils { foreach (var i in e.OldItems) { - var index = IndexOf(Model.Source, i); + var index = IndexOf(SelectionModel.Source, i); if (index != -1) { - Model.Deselect(index); + SelectionModel.Deselect(index); } } } try { - using var operation = Model.Update(); + using var operation = SelectionModel.BatchUpdate(); _updatingModel = true; @@ -163,8 +161,8 @@ namespace Avalonia.Controls.Utils Add(e.NewItems); break; case NotifyCollectionChangedAction.Reset: - Model.ClearSelection(); - Add(_items); + SelectionModel.Clear(); + Add(_selectedItems); break; } } @@ -178,46 +176,37 @@ namespace Avalonia.Controls.Utils { foreach (var i in newItems) { - var index = IndexOf(Model.Source, i); + var index = IndexOf(SelectionModel.Source, i); if (index != -1) { - Model.Select(index); + SelectionModel.Select(index); } } } private void SelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (_initializeOnSourceAssignment && - _items != null && - e.PropertyName == nameof(SelectionModel.Source)) + if (e.PropertyName == nameof(ISelectionModel.Source)) { - try + if (_selectedItems.Count > 0) { - _updatingModel = true; - Add(_items); - _initializeOnSourceAssignment = false; + SyncSelectionModelWithSelectedItems(); } - finally + else { - _updatingModel = false; + SyncSelectedItemsWithSelectionModel(); } } } private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) { - if (_updatingModel) + if (_updatingModel || _selectionModel.Source is null) { return; } - if (_items == null) - { - throw new AvaloniaInternalException("SelectionModelChanged raised but we don't have items."); - } - try { var deselected = e.DeselectedItems.ToList(); @@ -227,12 +216,12 @@ namespace Avalonia.Controls.Utils foreach (var i in deselected) { - _items.Remove(i); + _selectedItems.Remove(i); } foreach (var i in selected) { - _items.Add(i); + _selectedItems.Add(i); } } finally @@ -241,7 +230,43 @@ namespace Avalonia.Controls.Utils } } - private static int IndexOf(object source, object item) + 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) { diff --git a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs deleted file mode 100644 index 5adf5bdeea..0000000000 --- a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs +++ /dev/null @@ -1,189 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections.Generic; -using System.Linq; - -#nullable enable - -namespace Avalonia.Controls.Utils -{ - internal static class SelectionTreeHelper - { - public static void TraverseIndexPath( - SelectionNode root, - IndexPath path, - bool realizeChildren, - Action nodeAction) - { - var node = root; - - for (int depth = 0; depth < path.GetSize(); depth++) - { - int childIndex = path.GetAt(depth); - nodeAction(node, path, depth, childIndex); - - if (depth < path.GetSize() - 1) - { - node = node.GetAt(childIndex, realizeChildren, path)!; - } - } - } - - public static void Traverse( - SelectionNode root, - bool realizeChildren, - Action nodeAction) - { - var pendingNodes = new List(); - var current = new IndexPath(null); - - pendingNodes.Add(new TreeWalkNodeInfo(root, current)); - - while (pendingNodes.Count > 0) - { - var nextNode = pendingNodes.Last(); - pendingNodes.RemoveAt(pendingNodes.Count - 1); - int count = realizeChildren ? nextNode.Node.DataCount : nextNode.Node.ChildrenNodeCount; - for (int i = count - 1; i >= 0; i--) - { - var child = nextNode.Node.GetAt(i, realizeChildren, nextNode.Path); - var childPath = nextNode.Path.CloneWithChildIndex(i); - if (child != null) - { - pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, nextNode.Node)); - } - } - - // Queue the children first and then perform the action. This way - // the action can remove the children in the action if necessary - nodeAction(nextNode); - } - } - - public static void TraverseRangeRealizeChildren( - SelectionNode root, - IndexPath start, - IndexPath end, - Action nodeAction) - { - var pendingNodes = new List(); - var current = start; - - // Build up the stack to account for the depth first walk up to the - // start index path. - TraverseIndexPath( - root, - start, - true, - (node, path, depth, childIndex) => - { - var currentPath = StartPath(path, depth); - bool isStartPath = IsSubSet(start, currentPath); - bool isEndPath = IsSubSet(end, currentPath); - - int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0; - int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : node.DataCount - 1; - - for (int i = endIndex; i >= startIndex; i--) - { - var child = node.GetAt(i, true, end); - if (child != null) - { - var childPath = currentPath.CloneWithChildIndex(i); - pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, node)); - } - } - }); - - // From the start index path, do a depth first walk as long as the - // current path is less than the end path. - while (pendingNodes.Count > 0) - { - var info = pendingNodes.Last(); - pendingNodes.RemoveAt(pendingNodes.Count - 1); - int depth = info.Path.GetSize(); - bool isStartPath = IsSubSet(start, info.Path); - bool isEndPath = IsSubSet(end, info.Path); - int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0; - int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : info.Node.DataCount - 1; - for (int i = endIndex; i >= startIndex; i--) - { - var child = info.Node.GetAt(i, true, end); - if (child != null) - { - var childPath = info.Path.CloneWithChildIndex(i); - pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, info.Node)); - } - } - - nodeAction(info); - - if (info.Path.CompareTo(end) == 0) - { - // We reached the end index path. stop iterating. - break; - } - } - } - - private static bool IsSubSet(IndexPath path, IndexPath subset) - { - var subsetSize = subset.GetSize(); - if (path.GetSize() < subsetSize) - { - return false; - } - - for (int i = 0; i < subsetSize; i++) - { - if (path.GetAt(i) != subset.GetAt(i)) - { - return false; - } - } - - return true; - } - - private static IndexPath StartPath(IndexPath path, int length) - { - var subPath = new List(); - for (int i = 0; i < length; i++) - { - subPath.Add(path.GetAt(i)); - } - - return new IndexPath(subPath); - } - - public struct TreeWalkNodeInfo - { - public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath, SelectionNode? parent) - { - node = node ?? throw new ArgumentNullException(nameof(node)); - - Node = node; - Path = indexPath; - ParentNode = parent; - } - - public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath) - { - node = node ?? throw new ArgumentNullException(nameof(node)); - - Node = node; - Path = indexPath; - ParentNode = null; - } - - public SelectionNode Node { get; } - public IndexPath Path { get; } - public SelectionNode? ParentNode { get; } - }; - - } -} diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs index cb5f5b1fda..d9a0d17518 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs @@ -83,27 +83,6 @@ namespace Avalonia.Diagnostics.ViewModels private set; } - public IndexPath Index - { - get - { - var indices = new List(); - var child = this; - var parent = Parent; - - while (parent is object) - { - indices.Add(IndexOf(parent.Children, child)); - child = child.Parent; - parent = parent.Parent; - } - - indices.Add(0); - indices.Reverse(); - return new IndexPath(indices); - } - } - public void Dispose() { _classesSubscription.Dispose(); diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreePageViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreePageViewModel.cs index 748f67523b..d02c8994eb 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreePageViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreePageViewModel.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Controls; +using Avalonia.Controls.Selection; using Avalonia.VisualTree; namespace Avalonia.Diagnostics.ViewModels @@ -14,24 +15,12 @@ namespace Avalonia.Diagnostics.ViewModels { MainView = mainView; Nodes = nodes; - Selection = new SelectionModel - { - SingleSelect = true, - Source = Nodes - }; - - Selection.SelectionChanged += (s, e) => - { - SelectedNode = (TreeNode)Selection.SelectedItem; - }; } public MainViewModel MainView { get; } public TreeNode[] Nodes { get; protected set; } - public SelectionModel Selection { get; } - public TreeNode SelectedNode { get => _selectedNode; @@ -106,8 +95,8 @@ namespace Avalonia.Diagnostics.ViewModels if (node != null) { + SelectedNode = node; ExpandNode(node.Parent); - Selection.SelectedIndex = node.Index; } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml index 4ddb320175..a1e6ca7d37 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml @@ -6,7 +6,7 @@ + SelectedItem="{Binding SelectedNode, Mode=TwoWay}"> diff --git a/src/Avalonia.Themes.Default/TextBox.xaml b/src/Avalonia.Themes.Default/TextBox.xaml index d0b0c044f5..c24f13dc69 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -1,5 +1,8 @@ + M 11.416016,10 20,1.4160156 18.583984,0 10,8.5839846 1.4160156,0 0,1.4160156 8.5839844,10 0,18.583985 1.4160156,20 10,11.416015 18.583984,20 20,18.583985 Z + m10.051 7.0032c2.215 0 4.0105 1.7901 4.0105 3.9984s-1.7956 3.9984-4.0105 3.9984c-2.215 0-4.0105-1.7901-4.0105-3.9984s1.7956-3.9984 4.0105-3.9984zm0 1.4994c-1.3844 0-2.5066 1.1188-2.5066 2.499s1.1222 2.499 2.5066 2.499 2.5066-1.1188 2.5066-2.499-1.1222-2.499-2.5066-2.499zm0-5.0026c4.6257 0 8.6188 3.1487 9.7267 7.5613 0.10085 0.40165-0.14399 0.80877-0.54686 0.90931-0.40288 0.10054-0.81122-0.14355-0.91208-0.54521-0.94136-3.7492-4.3361-6.4261-8.2678-6.4261-3.9334 0-7.3292 2.6792-8.2689 6.4306-0.10063 0.40171-0.50884 0.64603-0.91177 0.54571s-0.648-0.5073-0.54737-0.90901c1.106-4.4152 5.1003-7.5667 9.728-7.5667z + m0.21967 0.21965c-0.26627 0.26627-0.29047 0.68293-0.07262 0.97654l0.07262 0.08412 4.0346 4.0346c-1.922 1.3495-3.3585 3.365-3.9554 5.7495-0.10058 0.4018 0.14362 0.8091 0.54543 0.9097 0.40182 0.1005 0.80909-0.1436 0.90968-0.5455 0.52947-2.1151 1.8371-3.8891 3.5802-5.0341l1.8096 1.8098c-0.70751 0.7215-1.1438 1.71-1.1438 2.8003 0 2.2092 1.7909 4 4 4 1.0904 0 2.0788-0.4363 2.8004-1.1438l5.9193 5.9195c0.2929 0.2929 0.7677 0.2929 1.0606 0 0.2663-0.2662 0.2905-0.6829 0.0726-0.9765l-0.0726-0.0841-6.1135-6.1142 0.0012-0.0015-1.2001-1.1979-2.8699-2.8693 2e-3 -8e-4 -2.8812-2.8782 0.0012-0.0018-1.1333-1.1305-4.3064-4.3058c-0.29289-0.29289-0.76777-0.29289-1.0607 0zm7.9844 9.0458 3.5351 3.5351c-0.45 0.4358-1.0633 0.704-1.7392 0.704-1.3807 0-2.5-1.1193-2.5-2.5 0-0.6759 0.26824-1.2892 0.7041-1.7391zm1.7959-5.7655c-1.0003 0-1.9709 0.14807-2.8889 0.425l1.237 1.2362c0.5358-0.10587 1.0883-0.16119 1.6519-0.16119 3.9231 0 7.3099 2.6803 8.2471 6.4332 0.1004 0.4018 0.5075 0.6462 0.9094 0.5459 0.4019-0.1004 0.6463-0.5075 0.5459-0.9094-1.103-4.417-5.0869-7.5697-9.7024-7.5697zm0.1947 3.5093 3.8013 3.8007c-0.1018-2.0569-1.7488-3.7024-3.8013-3.8007z @@ -88,7 +91,99 @@ - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Fluent/TextBox.xaml b/src/Avalonia.Themes.Fluent/TextBox.xaml index 6d6b379a03..85364bffea 100644 --- a/src/Avalonia.Themes.Fluent/TextBox.xaml +++ b/src/Avalonia.Themes.Fluent/TextBox.xaml @@ -170,7 +170,7 @@ - - - - - - - - - - - - diff --git a/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs b/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs index 2bb739f372..a90faf1448 100644 --- a/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs @@ -31,10 +31,11 @@ namespace Avalonia.Skia public GlGpuSession(GRContext grContext, GRBackendRenderTarget backendRenderTarget, - SKSurface surface, + SKSurface surface, IGlPlatformSurfaceRenderingSession glSession) { GrContext = grContext; + GrContext.PurgeResources(); _backendRenderTarget = backendRenderTarget; _surface = surface; _glSession = glSession; @@ -45,6 +46,7 @@ namespace Avalonia.Skia _surface.Dispose(); _backendRenderTarget.Dispose(); GrContext.Flush(); + GrContext.PurgeResources(); _glSession.Dispose(); } @@ -93,7 +95,7 @@ namespace Avalonia.Skia } finally { - if(!success) + if (!success) glSession.Dispose(); } } diff --git a/src/Windows/Avalonia.Win32/SystemDialogImpl.cs b/src/Windows/Avalonia.Win32/SystemDialogImpl.cs index 209b9e022a..ad81cc1778 100644 --- a/src/Windows/Avalonia.Win32/SystemDialogImpl.cs +++ b/src/Windows/Avalonia.Win32/SystemDialogImpl.cs @@ -9,7 +9,6 @@ using Avalonia.Win32.Interop; namespace Avalonia.Win32 { - class SystemDialogImpl : ISystemDialogImpl { private const UnmanagedMethods.FOS DefaultDialogOptions = UnmanagedMethods.FOS.FOS_FORCEFILESYSTEM | UnmanagedMethods.FOS.FOS_NOVALIDATE | @@ -113,6 +112,7 @@ namespace Avalonia.Win32 frm.GetOptions(out options); options |= (uint)(UnmanagedMethods.FOS.FOS_PICKFOLDERS | DefaultDialogOptions); frm.SetOptions(options); + frm.SetTitle(dialog.Title ?? ""); if (dialog.Directory != null) { diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs index a292910fae..051f6c3fd3 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs @@ -168,7 +168,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Selected_Index_Changes_To_When_Items_Assigned_Null() + public void Selected_Index_Changes_To_None_When_Items_Assigned_Null() { var items = new ObservableCollection { diff --git a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs index 7a2109e5a7..c179aef9ac 100644 --- a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Input; +using Avalonia.LogicalTree; using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml.MarkupExtensions; using Avalonia.Platform; @@ -132,6 +133,44 @@ namespace Avalonia.Controls.UnitTests popupImpl.Verify(x => x.Show(), Times.Exactly(2)); } } + + [Fact] + public void Context_Menu_Can_Be_Shared_Between_Controls_Even_After_A_Control_Is_Removed_From_Visual_Tree() + { + using (Application()) + { + var sut = new ContextMenu(); + var target1 = new Panel + { + ContextMenu = sut + }; + + var target2 = new Panel + { + ContextMenu = sut + }; + + var sp = new StackPanel { Children = { target1, target2 } }; + var window = new Window { Content = sp }; + + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + + _mouse.Click(target1, MouseButton.Right); + + Assert.True(sut.IsOpen); + + _mouse.Click(target2, MouseButton.Left); + + Assert.False(sut.IsOpen); + + sp.Children.Remove(target1); + + _mouse.Click(target2, MouseButton.Right); + + Assert.True(sut.IsOpen); + } + } [Fact] public void Cancelling_Opening_Does_Not_Show_ContextMenu() diff --git a/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs b/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs deleted file mode 100644 index 1e4aa0a2b8..0000000000 --- a/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Xunit; - -namespace Avalonia.Controls.UnitTests -{ - public class IndexPathTests - { - [Fact] - public void Simple_Index() - { - var a = new IndexPath(1); - - Assert.Equal(1, a.GetSize()); - Assert.Equal(1, a.GetAt(0)); - } - - [Fact] - public void Equal_Paths() - { - var a = new IndexPath(1); - var b = new IndexPath(1); - - Assert.True(a == b); - Assert.False(a != b); - Assert.True(a.Equals(b)); - Assert.Equal(0, a.CompareTo(b)); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - } - - [Fact] - public void Unequal_Paths() - { - var a = new IndexPath(1); - var b = new IndexPath(2); - - Assert.False(a == b); - Assert.True(a != b); - Assert.False(a.Equals(b)); - Assert.Equal(-1, a.CompareTo(b)); - Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); - } - - [Fact] - public void Equal_Null_Path() - { - var a = new IndexPath(null); - var b = new IndexPath(null); - - Assert.True(a == b); - Assert.False(a != b); - Assert.True(a.Equals(b)); - Assert.Equal(0, a.CompareTo(b)); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - } - - [Fact] - public void Unequal_Null_Path() - { - var a = new IndexPath(null); - var b = new IndexPath(2); - - Assert.False(a == b); - Assert.True(a != b); - Assert.False(a.Equals(b)); - Assert.Equal(-1, a.CompareTo(b)); - Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); - } - - [Fact] - public void Default_Is_Null_Path() - { - var a = new IndexPath(null); - var b = default(IndexPath); - - Assert.True(a == b); - Assert.False(a != b); - Assert.True(a.Equals(b)); - Assert.Equal(0, a.CompareTo(b)); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - } - - [Fact] - public void Null_Equality() - { - var a = new IndexPath(null); - var b = new IndexPath(1); - - // Implementing operator == on a struct automatically implements an operator which - // accepts null, so make sure this does something useful. - Assert.True(a == null); - Assert.False(a != null); - Assert.False(b == null); - Assert.True(b != null); - } - } -} diff --git a/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs b/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs deleted file mode 100644 index e01c752658..0000000000 --- a/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs +++ /dev/null @@ -1,389 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Xunit; - -namespace Avalonia.Controls.UnitTests -{ - public class IndexRangeTests - { - [Fact] - public void Add_Should_Add_Range_To_Empty_List() - { - var ranges = new List(); - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected); - - Assert.Equal(5, result); - Assert.Equal(new[] { new IndexRange(0, 4) }, ranges); - Assert.Equal(new[] { new IndexRange(0, 4) }, selected); - } - - [Fact] - public void Add_Should_Add_Non_Intersecting_Range_At_End() - { - var ranges = new List { new IndexRange(0, 4) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected); - - Assert.Equal(3, result); - Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges); - Assert.Equal(new[] { new IndexRange(8, 10) }, selected); - } - - [Fact] - public void Add_Should_Add_Non_Intersecting_Range_At_Beginning() - { - var ranges = new List { new IndexRange(8, 10) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected); - - Assert.Equal(5, result); - Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges); - Assert.Equal(new[] { new IndexRange(0, 4) }, selected); - } - - [Fact] - public void Add_Should_Add_Non_Intersecting_Range_In_Middle() - { - var ranges = new List { new IndexRange(0, 4), new IndexRange(14, 16) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected); - - Assert.Equal(3, result); - Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10), new IndexRange(14, 16) }, ranges); - Assert.Equal(new[] { new IndexRange(8, 10) }, selected); - } - - [Fact] - public void Add_Should_Add_Intersecting_Range_Start() - { - var ranges = new List { new IndexRange(8, 10) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(6, 9), selected); - - Assert.Equal(2, result); - Assert.Equal(new[] { new IndexRange(6, 10) }, ranges); - Assert.Equal(new[] { new IndexRange(6, 7) }, selected); - } - - [Fact] - public void Add_Should_Add_Intersecting_Range_End() - { - var ranges = new List { new IndexRange(8, 10) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(9, 12), selected); - - Assert.Equal(2, result); - Assert.Equal(new[] { new IndexRange(8, 12) }, ranges); - Assert.Equal(new[] { new IndexRange(11, 12) }, selected); - } - - [Fact] - public void Add_Should_Add_Intersecting_Range_Both() - { - var ranges = new List { new IndexRange(8, 10) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(6, 12), selected); - - Assert.Equal(4, result); - Assert.Equal(new[] { new IndexRange(6, 12) }, ranges); - Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 12) }, selected); - } - - [Fact] - public void Add_Should_Join_Two_Intersecting_Ranges() - { - var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(8, 14), selected); - - Assert.Equal(1, result); - Assert.Equal(new[] { new IndexRange(8, 14) }, ranges); - Assert.Equal(new[] { new IndexRange(11, 11) }, selected); - } - - [Fact] - public void Add_Should_Join_Two_Intersecting_Ranges_And_Add_Ranges() - { - var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(6, 18), selected); - - Assert.Equal(7, result); - Assert.Equal(new[] { new IndexRange(6, 18) }, ranges); - Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 11), new IndexRange(15, 18) }, selected); - } - - [Fact] - public void Add_Should_Not_Add_Already_Selected_Range() - { - var ranges = new List { new IndexRange(8, 10) }; - var selected = new List(); - var result = IndexRange.Add(ranges, new IndexRange(9, 10), selected); - - Assert.Equal(0, result); - Assert.Equal(new[] { new IndexRange(8, 10) }, ranges); - Assert.Empty(selected); - } - - [Fact] - public void Intersect_Should_Remove_Items_From_Beginning() - { - var ranges = new List { new IndexRange(0, 10) }; - var removed = new List(); - var result = IndexRange.Intersect(ranges, new IndexRange(2, 12), removed); - - Assert.Equal(2, result); - Assert.Equal(new[] { new IndexRange(2, 10) }, ranges); - Assert.Equal(new[] { new IndexRange(0, 1) }, removed); - } - - [Fact] - public void Intersect_Should_Remove_Items_From_End() - { - var ranges = new List { new IndexRange(0, 10) }; - var removed = new List(); - var result = IndexRange.Intersect(ranges, new IndexRange(0, 8), removed); - - Assert.Equal(2, result); - Assert.Equal(new[] { new IndexRange(0, 8) }, ranges); - Assert.Equal(new[] { new IndexRange(9, 10) }, removed); - } - - [Fact] - public void Intersect_Should_Remove_Entire_Range_Start() - { - var ranges = new List { new IndexRange(0, 5), new IndexRange(6, 10) }; - var removed = new List(); - var result = IndexRange.Intersect(ranges, new IndexRange(6, 10), removed); - - Assert.Equal(6, result); - Assert.Equal(new[] { new IndexRange(6, 10) }, ranges); - Assert.Equal(new[] { new IndexRange(0, 5) }, removed); - } - - [Fact] - public void Intersect_Should_Remove_Entire_Range_End() - { - var ranges = new List { new IndexRange(0, 5), new IndexRange(6, 10) }; - var removed = new List(); - var result = IndexRange.Intersect(ranges, new IndexRange(0, 4), removed); - - Assert.Equal(6, result); - Assert.Equal(new[] { new IndexRange(0, 4) }, ranges); - Assert.Equal(new[] { new IndexRange(5, 10) }, removed); - } - - [Fact] - public void Intersect_Should_Remove_Entire_Range_Start_End() - { - var ranges = new List - { - new IndexRange(0, 2), - new IndexRange(3, 7), - new IndexRange(8, 10) - }; - var removed = new List(); - var result = IndexRange.Intersect(ranges, new IndexRange(3, 7), removed); - - Assert.Equal(6, result); - Assert.Equal(new[] { new IndexRange(3, 7) }, ranges); - Assert.Equal(new[] { new IndexRange(0, 2), new IndexRange(8, 10) }, removed); - } - - [Fact] - public void Intersect_Should_Remove_Entire_And_Partial_Range_Start_End() - { - var ranges = new List - { - new IndexRange(0, 2), - new IndexRange(3, 7), - new IndexRange(8, 10) - }; - var removed = new List(); - var result = IndexRange.Intersect(ranges, new IndexRange(4, 6), removed); - - Assert.Equal(8, result); - Assert.Equal(new[] { new IndexRange(4, 6) }, ranges); - Assert.Equal(new[] { new IndexRange(0, 3), new IndexRange(7, 10) }, removed); - } - - [Fact] - public void Remove_Should_Remove_Entire_Range() - { - var ranges = new List { new IndexRange(8, 10) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected); - - Assert.Equal(3, result); - Assert.Empty(ranges); - Assert.Equal(new[] { new IndexRange(8, 10) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_Start_Of_Range() - { - var ranges = new List { new IndexRange(8, 12) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected); - - Assert.Equal(3, result); - Assert.Equal(new[] { new IndexRange(11, 12) }, ranges); - Assert.Equal(new[] { new IndexRange(8, 10) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_End_Of_Range() - { - var ranges = new List { new IndexRange(8, 12) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(10, 12), deselected); - - Assert.Equal(3, result); - Assert.Equal(new[] { new IndexRange(8, 9) }, ranges); - Assert.Equal(new[] { new IndexRange(10, 12) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_Overlapping_End_Of_Range() - { - var ranges = new List { new IndexRange(8, 12) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(10, 14), deselected); - - Assert.Equal(3, result); - Assert.Equal(new[] { new IndexRange(8, 9) }, ranges); - Assert.Equal(new[] { new IndexRange(10, 12) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_Middle_Of_Range() - { - var ranges = new List { new IndexRange(10, 20) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(12, 16), deselected); - - Assert.Equal(5, result); - Assert.Equal(new[] { new IndexRange(10, 11), new IndexRange(17, 20) }, ranges); - Assert.Equal(new[] { new IndexRange(12, 16) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_Multiple_Ranges() - { - var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(6, 15), deselected); - - Assert.Equal(6, result); - Assert.Equal(new[] { new IndexRange(16, 18) }, ranges); - Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 14) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_Multiple_And_Partial_Ranges_1() - { - var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(9, 15), deselected); - - Assert.Equal(5, result); - Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(16, 18) }, ranges); - Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 14) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_Multiple_And_Partial_Ranges_2() - { - var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(8, 13), deselected); - - Assert.Equal(5, result); - Assert.Equal(new[] { new IndexRange(14, 14), new IndexRange(16, 18) }, ranges); - Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 13) }, deselected); - } - - [Fact] - public void Remove_Should_Remove_Multiple_And_Partial_Ranges_3() - { - var ranges = new List { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(9, 13), deselected); - - Assert.Equal(4, result); - Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(14, 14), new IndexRange(16, 18) }, ranges); - Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 13) }, deselected); - } - - [Fact] - public void Remove_Should_Do_Nothing_For_Unselected_Range() - { - var ranges = new List { new IndexRange(8, 10) }; - var deselected = new List(); - var result = IndexRange.Remove(ranges, new IndexRange(2, 4), deselected); - - Assert.Equal(0, result); - Assert.Equal(new[] { new IndexRange(8, 10) }, ranges); - Assert.Empty(deselected); - } - - [Fact] - public void Stress_Test() - { - const int iterations = 100; - var random = new Random(0); - var selection = new List(); - var expected = new List(); - - IndexRange Generate() - { - var start = random.Next(100); - return new IndexRange(start, start + random.Next(20)); - } - - for (var i = 0; i < iterations; ++i) - { - var toAdd = random.Next(5); - - for (var j = 0; j < toAdd; ++j) - { - var range = Generate(); - IndexRange.Add(selection, range); - - for (var k = range.Begin; k <= range.End; ++k) - { - if (!expected.Contains(k)) - { - expected.Add(k); - } - } - - var actual = IndexRange.EnumerateIndices(selection).ToList(); - expected.Sort(); - Assert.Equal(expected, actual); - } - - var toRemove = random.Next(5); - - for (var j = 0; j < toRemove; ++j) - { - var range = Generate(); - IndexRange.Remove(selection, range); - - for (var k = range.Begin; k <= range.End; ++k) - { - expected.Remove(k); - } - - var actual = IndexRange.EnumerateIndices(selection).ToList(); - Assert.Equal(expected, actual); - } - - selection.Clear(); - expected.Clear(); - } - } - } -} diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index c4346e571b..2e2ccf7326 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -385,7 +385,7 @@ namespace Avalonia.Controls.UnitTests // First an item that is not index 0 must be selected. _mouse.Click(target.Presenter.Panel.Children[1]); - Assert.Equal(new IndexPath(1), target.Selection.AnchorIndex); + Assert.Equal(1, target.Selection.AnchorIndex); // We're going to be clicking on item 9. var item = (ListBoxItem)target.Presenter.Panel.Children[9]; diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 9ef2750ff3..33744949c3 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -7,6 +7,7 @@ using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Selection; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; @@ -19,7 +20,7 @@ using Xunit; namespace Avalonia.Controls.UnitTests.Primitives { - public class SelectingItemsControlTests + public partial class SelectingItemsControlTests { private MouseTestHelper _helper = new MouseTestHelper(); @@ -56,7 +57,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - target.ApplyTemplate(); + Prepare(target); Assert.False(items[0].IsSelected); Assert.False(items[1].IsSelected); @@ -77,8 +78,8 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); + target.SelectedItem = items[1]; Assert.False(items[0].IsSelected); @@ -101,8 +102,7 @@ namespace Avalonia.Controls.UnitTests.Primitives }; target.SelectedItem = items[1]; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); Assert.False(items[0].IsSelected); Assert.True(items[1].IsSelected); @@ -159,6 +159,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Template = Template(); target.EndInit(); + Prepare(target); + Assert.Equal(0, target.SelectedIndex); } @@ -181,11 +183,13 @@ namespace Avalonia.Controls.UnitTests.Primitives listBox.EndInit(); + Prepare(listBox); + Assert.Equal("B", listBox.SelectedItem); } [Fact] - public void Setting_SelectedIndex_Before_Initialize_Should_Retain() + public void Setting_SelectedIndex_Before_Initialize_Should_Retain_Selection() { var listBox = new ListBox { @@ -223,7 +227,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void Setting_SelectedItem_Before_Initialize_Should_Retain() + public void Setting_SelectedItem_Before_Initialize_Should_Retain_Selection() { var listBox = new ListBox { @@ -242,7 +246,7 @@ namespace Avalonia.Controls.UnitTests.Primitives [Fact] - public void Setting_SelectedItems_Before_Initialize_Should_Retain() + public void Setting_SelectedItems_Before_Initialize_Should_Retain_Selection() { var listBox = new ListBox { @@ -290,7 +294,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void Setting_SelectedIndex_Before_Initialize_With_AlwaysSelected_Should_Retain() + public void Setting_SelectedIndex_Before_Initialize_With_AlwaysSelected_Should_Retain_Selection() { var listBox = new ListBox { @@ -324,8 +328,7 @@ namespace Avalonia.Controls.UnitTests.Primitives }; target.SelectedIndex = 1; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); Assert.False(items[0].IsSelected); Assert.True(items[1].IsSelected); @@ -480,8 +483,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); items.Add(new Item { IsSelected = true }); Assert.Equal(2, target.SelectedIndex); @@ -530,8 +532,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); target.SelectedIndex = 1; Assert.Equal(items[1], target.SelectedItem); @@ -568,8 +569,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Template = Template(); target.EndInit(); - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); target.SelectedIndex = 0; Assert.Equal(items[0], target.SelectedItem); @@ -635,8 +635,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); target.SelectedItem = items[1]; Assert.False(items[0].IsSelected); @@ -666,8 +665,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); target.SelectedItem = items[1]; Assert.False(items[0].IsSelected); @@ -757,8 +755,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectedIndex = 1, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); var called = false; @@ -785,6 +782,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Items = items; ((ISupportInitialize)target).EndInit(); + Prepare(target); + Assert.Equal(1, target.SelectedIndex); Assert.Equal("Bar", target.SelectedItem); } @@ -800,6 +799,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Items = items; ((ISupportInitialize)target).EndInit(); + Prepare(target); + Assert.Equal(1, target.SelectedIndex); Assert.Equal("Bar", target.SelectedItem); } @@ -897,8 +898,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Items = new[] { "Foo", "Bar", "Baz " }, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); _helper.Down((Interactive)target.Presenter.Panel.Children[1]); var panel = target.Presenter.Panel; @@ -919,8 +919,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Items = items, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); _helper.Down(target.Presenter.Panel.Children[1]); @@ -1014,8 +1013,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); _helper.Down((Interactive)target.Presenter.Panel.Children[3]); Assert.Equal(3, target.SelectedIndex); @@ -1030,8 +1028,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); _helper.Down((Interactive)target.Presenter.Panel.Children[3]); Assert.Equal(new[] { ":pressed", ":selected" }, target.Presenter.Panel.Children[3].Classes); @@ -1054,8 +1051,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectedIndex = 1, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); items.Insert(0, "Qux"); @@ -1080,8 +1076,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectedIndex = 1, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); items.RemoveAt(0); @@ -1089,6 +1084,65 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal("Bar", target.SelectedItem); } + [Fact] + public void Binding_SelectedIndex_Selects_Correct_Item() + { + // Issue #4496 (part 2) + var items = new ObservableCollection(); + + var other = new ListBox + { + Template = Template(), + Items = items, + SelectionMode = SelectionMode.AlwaysSelected, + }; + + var target = new ListBox + { + Template = Template(), + Items = items, + [!ListBox.SelectedIndexProperty] = other[!ListBox.SelectedIndexProperty], + }; + + Prepare(other); + Prepare(target); + + items.Add("Foo"); + + Assert.Equal(0, other.SelectedIndex); + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Binding_SelectedItem_Selects_Correct_Item() + { + // Issue #4496 (part 2) + var items = new ObservableCollection(); + + var other = new ListBox + { + Template = Template(), + Items = items, + SelectionMode = SelectionMode.AlwaysSelected, + }; + + var target = new ListBox + { + Template = Template(), + Items = items, + [!ListBox.SelectedItemProperty] = other[!ListBox.SelectedItemProperty], + }; + + Prepare(target); + other.ApplyTemplate(); + other.Presenter.ApplyTemplate(); + + items.Add("Foo"); + + Assert.Equal(0, other.SelectedIndex); + Assert.Equal(0, target.SelectedIndex); + } + [Fact] public void Replacing_Selected_Item_Should_Update_SelectedItem() { @@ -1106,8 +1160,7 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectedIndex = 1, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); items[1] = "Qux"; @@ -1131,8 +1184,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Items = items, }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); var raised = false; target.AddHandler(Control.RequestBringIntoViewEvent, (s, e) => raised = true); @@ -1195,6 +1247,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedItem = "Bar"; target.EndInit(); + Prepare(target); + Assert.Equal("Bar", target.SelectedItem); Assert.Equal(1, target.SelectedIndex); Assert.Same(selectedItems, target.SelectedItems); @@ -1261,16 +1315,49 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Items = items; - target.ApplyTemplate(); - - target.Presenter.ApplyTemplate(); + Prepare(target); Assert.Equal(second, target.SelectedItem); Assert.Equal(1, target.SelectedIndex); } - private FuncControlTemplate Template() + [Fact] + public void Setting_SelectionMode_Should_Update_SelectionModel() + { + var target = new TestSelector(); + var model = target.Selection; + + Assert.True(model.SingleSelect); + + target.SelectionMode = SelectionMode.Multiple; + + Assert.False(model.SingleSelect); + } + + private static void Prepare(SelectingItemsControl target) + { + var root = new TestRoot + { + Child = target, + Width = 100, + Height = 100, + Styles = + { + new Style(x => x.Is()) + { + Setters = + { + new Setter(ListBox.TemplateProperty, Template()), + }, + }, + }, + }; + + root.LayoutManager.ExecuteInitialLayoutPass(); + } + + private static FuncControlTemplate Template() { return new FuncControlTemplate((control, scope) => new ItemsPresenter @@ -1328,6 +1415,18 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectionMode = selectionMode; } + public new ISelectionModel Selection + { + get => base.Selection; + set => base.Selection = value; + } + + public new SelectionMode SelectionMode + { + get => base.SelectionMode; + set => base.SelectionMode = value; + } + public new bool MoveSelection(NavigationDirection direction, bool wrap) { return base.MoveSelection(direction, wrap); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index dcf25beb50..6b26d76371 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -6,6 +6,7 @@ using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Selection; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; @@ -367,7 +368,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedIndex = 3; target.SelectRange(1); - Assert.Equal(new[] { "bar", "baz", "qux" }, target.SelectedItems.Cast().ToList()); + Assert.Equal(new[] { "qux", "bar", "baz" }, target.SelectedItems.Cast().ToList()); } [Fact] @@ -516,7 +517,7 @@ namespace Avalonia.Controls.UnitTests.Primitives /// DataContext is in the process of changing. /// [Fact] - public void Should_Not_Write_To_Old_DataContext() + public void Should_Not_Write_SelectedItems_To_Old_DataContext() { var vm = new OldDataContextViewModel(); var target = new TestSelector(); @@ -552,6 +553,46 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Empty(target.SelectedItems); } + /// + /// See . + /// + [Fact] + public void Should_Not_Write_SelectionModel_To_Old_DataContext() + { + var vm = new OldDataContextViewModel(); + var target = new TestSelector(); + + var itemsBinding = new Binding + { + Path = "Items", + Mode = BindingMode.OneWay, + }; + + var selectionBinding = new Binding + { + Path = "Selection", + Mode = BindingMode.OneWay, + }; + + // Bind Items and Selection to the VM. + target.Bind(TestSelector.ItemsProperty, itemsBinding); + target.Bind(TestSelector.SelectionProperty, selectionBinding); + + // Set DataContext and SelectedIndex + target.DataContext = vm; + target.SelectedIndex = 1; + + // Make sure selection is written to selection model + Assert.Equal(1, vm.Selection.SelectedIndex); + + // Clear DataContext and ensure that selection is still set in model. + target.DataContext = null; + Assert.Equal(1, vm.Selection.SelectedIndex); + + // Ensure target's SelectedItems is now clear. + Assert.Empty(target.SelectedItems); + } + [Fact] public void Unbound_SelectedItems_Should_Be_Cleared_When_DataContext_Cleared() { @@ -1259,7 +1300,7 @@ namespace Avalonia.Controls.UnitTests.Primitives }; target.ApplyTemplate(); - target.Selection.Select(1); + target.SelectedItems.Add("bar"); Assert.Equal(1, target.SelectedIndex); } @@ -1290,7 +1331,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - var selection = new SelectionModel { Source = new[] { "baz" } }; + var selection = new SelectionModel { Source = new[] { "baz" } }; Assert.Throws(() => target.Selection = selection); } @@ -1303,7 +1344,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Template = Template(), }; - var selection = new SelectionModel(); + var selection = new SelectionModel(); target.Selection = selection; Assert.Same(target.Items, selection.Source); @@ -1321,7 +1362,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - var selection = new SelectionModel { Source = target.Items }; + var selection = new SelectionModel { SingleSelect = false }; selection.Select(1); target.Selection = selection; @@ -1342,8 +1383,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - var selection = new SelectionModel { Source = target.Items }; - selection.SelectRange(new IndexPath(0), new IndexPath(2)); + var selection = new SelectionModel { SingleSelect = false }; + selection.SelectRange(0, 2); target.Selection = selection; Assert.Equal(0, target.SelectedIndex); @@ -1362,7 +1403,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.ApplyTemplate(); target.Selection.Select(1); - target.Selection = new SelectionModel(); + target.Selection = new SelectionModel(); Assert.Equal(-1, target.SelectedIndex); Assert.Null(target.SelectedItem); @@ -1387,8 +1428,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - var selection = new SelectionModel { Source = items }; - selection.SelectRange(new IndexPath(0), new IndexPath(1)); + var selection = new SelectionModel { SingleSelect = false }; + selection.SelectRange(0, 1); target.Selection = selection; Assert.True(items[0].IsSelected); @@ -1429,15 +1470,13 @@ namespace Avalonia.Controls.UnitTests.Primitives target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - - var selection = new SelectionModel { Source = items }; + var selection = new SelectionModel { Source = items, SingleSelect = false }; selection.Select(0); selection.Select(2); target.Selection = selection; Assert.Equal(2, raised); } - private IEnumerable SelectedContainers(SelectingItemsControl target) { return target.Presenter.Panel.Children @@ -1460,6 +1499,8 @@ namespace Avalonia.Controls.UnitTests.Primitives { public static readonly new AvaloniaProperty SelectedItemsProperty = SelectingItemsControl.SelectedItemsProperty; + public static readonly new DirectProperty SelectionProperty = + SelectingItemsControl.SelectionProperty; public TestSelector() { @@ -1485,7 +1526,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } public void SelectAll() => Selection.SelectAll(); - public void UnselectAll() => Selection.ClearSelection(); + public void UnselectAll() => Selection.Clear(); public void SelectRange(int index) => UpdateSelection(index, true, true); public void Toggle(int index) => UpdateSelection(index, true, false, true); } @@ -1496,10 +1537,12 @@ namespace Avalonia.Controls.UnitTests.Primitives { Items = new List { "foo", "bar" }; SelectedItems = new List(); + Selection = new SelectionModel(); } public List Items { get; } public List SelectedItems { get; } + public SelectionModel Selection { get; } } private class ItemContainer : Control, ISelectable diff --git a/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs new file mode 100644 index 0000000000..3640faf7cb --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs @@ -0,0 +1,1584 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Collections; +using Avalonia.Controls.Selection; +using Xunit; + +#nullable enable + +namespace Avalonia.Controls.UnitTests.Selection +{ + public class SelectionModelTests_Multiple + { + public class No_Source + { + [Fact] + public void Can_Select_Multiple_Items_Before_Source_Assigned() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + var index = raised switch + { + 0 => 5, + 1 => 10, + 2 => 100, + _ => throw new NotSupportedException(), + }; + + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { index }, e.SelectedIndexes); + Assert.Equal(new string?[] { null }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 5; + target.Select(10); + target.Select(100); + + Assert.Equal(5, target.SelectedIndex); + Assert.Equal(new[] { 5, 10, 100 }, target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Equal(new string?[] { null, null, null }, target.SelectedItems); + Assert.Equal(3, raised); + } + + [Fact] + public void Initializing_Source_Retains_Valid_Selection_And_Removes_Invalid() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 1; + target.Select(2); + target.Select(10); + target.Select(100); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 10, 100 }, e.DeselectedIndexes); + Assert.Equal(new string?[] { null, null }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1, 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Initializing_Source_Coerces_SelectedIndex() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 100; + target.Select(2); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("baz", target.SelectedItem); + Assert.Equal(new[] { "baz" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Initializing_Source_Doesnt_Raise_SelectionChanged_If_Selection_Valid() + { + var target = CreateTarget(false); + var raised = 0; + + target.Select(1); + target.Select(2); + + target.SelectionChanged += (s, e) => + { + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(0, raised); + } + + [Fact] + public void Initializing_Source_Respects_Range_SourceItem_Order() + { + var target = CreateTarget(false); + + target.SelectRange(2, 2); + target.SelectedItem = "bar"; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + } + + [Fact] + public void Initializing_Source_Respects_SourceItem_Range_Order() + { + var target = CreateTarget(false); + + target.SelectedItem = "baz"; + target.SelectRange(1, 1); + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + } + } + + public class SelectedIndex + { + [Fact] + public void SelectedIndex_Larger_Than_Source_Clears_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 15; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Negative_SelectedIndex_Is_Coerced_To_Minus_1() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => ++raised; + + target.SelectedIndex = -5; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Setting_SelectedIndex_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void PropertyChanged_Is_Raised() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedIndexes + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndexes)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedItem + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedItems + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItems)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class Select + { + [Fact] + public void Select_Sets_SelectedIndex_If_Previously_Unset() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_Adds_To_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.Select(1); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0, 1 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo", "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_With_Invalid_Index_Does_Nothing() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + + target.Select(15); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(2); + target.SelectionChanged += (s, e) => ++raised; + target.Select(2); + + Assert.Equal(0, raised); + } + } + + public class SelectRange + { + [Fact] + public void SelectRange_Selects_Items() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 1, 2 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar", "baz" }, e.SelectedItems); + ++raised; + }; + + target.SelectRange(1, 2); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1, 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRange_Ignores_Out_Of_Bounds_Items() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 11, 12 }, e.SelectedIndexes); + Assert.Equal(new[] { "xyzzy", "thud" }, e.SelectedItems); + ++raised; + }; + + target.SelectRange(11, 20); + + Assert.Equal(11, target.SelectedIndex); + Assert.Equal(new[] { 11, 12 }, target.SelectedIndexes); + Assert.Equal("xyzzy", target.SelectedItem); + Assert.Equal(new[] { "xyzzy", "thud" }, target.SelectedItems); + Assert.Equal(11, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRange_Does_Nothing_For_Non_Intersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => ++raised; + + target.SelectRange(18, 30); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(0, raised); + } + } + + public class Deselect + { + [Fact] + public void Deselect_Clears_Selected_Item() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + target.Select(1); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Deselect(1); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Deselect_Updates_SelectedItem_To_First_Selected_Item() + { + var target = CreateTarget(); + + target.SelectRange(3, 5); + target.Deselect(3); + + Assert.Equal(4, target.SelectedIndex); + } + } + + public class DeselectRange + { + [Fact] + public void DeselectRange_Clears_Identical_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(1, 2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1, 2 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar", "baz" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.DeselectRange(1, 2); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void DeselectRange_Clears_Intersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(1, 2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.DeselectRange(0, 1); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("baz", target.SelectedItem); + Assert.Equal(new[] { "baz" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void DeselectRange_Does_Nothing_For_Nonintersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + target.SelectionChanged += (s, e) => ++raised; + target.DeselectRange(1, 2); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + } + + public class Clear + { + [Fact] + public void Clear_Raises_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(1); + target.Select(2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1, 2 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar", "baz" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Clear(); + + Assert.Equal(1, raised); + } + } + + public class AnchorIndex + { + [Fact] + public void Setting_SelectedIndex_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_To_Minus_1_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = -1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Select_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRange_Doesnt_Overwrite_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.AnchorIndex = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectRange(1, 2); + + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Deselect_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(0); + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Deselect(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + } + + public class SingleSelect + { + [Fact] + public void Converting_To_Single_Selection_Removes_Multiple_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(1, 3); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 2, 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.SingleSelect = true; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Raises_PropertyChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SingleSelect)) + { + ++raised; + } + }; + + target.SingleSelect = true; + + Assert.Equal(1, raised); + } + } + + public class CollectionChanges + { + [Fact] + public void Adding_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(1, e.Delta); + ++indexesChangedraised; + }; + + data.Insert(0, "new"); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(2, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.Insert(2, "new"); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Adding_Item_At_Beginning_Of_SelectedRange_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectRange(4, 8); + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(4, e.StartIndex); + Assert.Equal(2, e.Delta); + ++indexesChangedraised; + }; + + data.InsertRange(4, new[] { "frank", "tank" }); + + Assert.Equal(6, target.SelectedIndex); + Assert.Equal(new[] { 6, 7, 8, 9, 10 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems); + Assert.Equal(6, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_At_End_Of_SelectedRange_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectRange(4, 8); + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(8, e.StartIndex); + Assert.Equal(2, e.Delta); + ++indexesChangedraised; + }; + + data.InsertRange(8, new[] { "frank", "tank" }); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5, 6, 7, 10 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_In_Middle_Of_SelectedRange_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectRange(4, 8); + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(6, e.StartIndex); + Assert.Equal(2, e.Delta); + ++indexesChangedraised; + }; + + data.InsertRange(6, new[] { "frank", "tank" }); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5, 8, 9, 10 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Removing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveAt(1); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(-1, e.Delta); + ++indexesChangedraised; + }; + + data.RemoveAt(0); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Removing_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.RemoveAt(2); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Removing_Selected_Range_Raises_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(4, 5); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Partial_Selected_Range_Raises_Events_1() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "quux", "corge", "grault" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(0, 7); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0, 1 }, target.SelectedIndexes); + Assert.Equal("garply", target.SelectedItem); + Assert.Equal(new[] { "garply", "waldo" }, target.SelectedItems); + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Partial_Selected_Range_Raises_Events_2() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "garply", "waldo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(7, 3); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5, 6 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(0, selectedIndexRaised); + } + + [Fact] + public void Removing_Partial_Selected_Range_Raises_Events_3() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "corge", "grault", "garply" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(5, 3); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "waldo" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(0, selectedIndexRaised); + } + + [Fact] + public void Replacing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var indexesChangedRaised = 0; + + target.Source = data; + target.SelectRange(1, 4); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.IndexesChanged += (s, e) => ++indexesChangedRaised; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data[1] = "new"; + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2, 3, 4 }, target.SelectedIndexes); + Assert.Equal("baz", target.SelectedItem); + Assert.Equal(new[] { "baz", "qux", "quux" }, target.SelectedItems); + Assert.Equal(2, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(0, indexesChangedRaised); + } + + [Fact] + public void Resetting_Source_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var resetRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + target.SourceReset += (s, e) => ++resetRaised; + + data.Clear(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(0, selectionChangedRaised); + Assert.Equal(1, resetRaised); + Assert.Equal(1, selectedIndexRaised); + } + } + + public class BatchUpdate + { + [Fact] + public void Correctly_Batches_Selects() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Select(2); + target.Select(3); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_SelectRanges() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3, 5, 6 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux", "corge", "grault" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.SelectRange(2, 3); + target.SelectRange(5, 6); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Select_Deselect() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Select(2); + target.Select(3); + target.Select(4); + target.Deselect(4); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Deselect_Select() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 8); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 2, 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Deselect(2); + target.Deselect(3); + target.Deselect(4); + target.Select(4); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Select_Deselect_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.SelectRange(2, 6); + target.DeselectRange(4, 8); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Deselect_Select_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 8); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 2, 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.DeselectRange(2, 6); + target.SelectRange(4, 8); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Clear_Select() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 3); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Clear(); + target.Select(2); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Clear_SelectedIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 3); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Clear(); + target.SelectedIndex = 2; + } + + Assert.Equal(1, raised); + } + } + + public class LostSelection + { + [Fact] + public void LostSelection_Called_On_Clear() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Equal(new[] { 0 }, e.SelectedIndexes); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.LostSelection += (s, e) => + { + target.Select(0); + }; + + target.Clear(); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void LostSelection_Called_When_Selection_Removed() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectRange(1, 3); + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar", "baz", "qux" }, e.DeselectedItems); + Assert.Equal(new[] { 0 }, e.SelectedIndexes); + Assert.Equal(new[] { "quux" }, e.SelectedItems); + ++raised; + }; + + target.LostSelection += (s, e) => + { + target.Select(0); + }; + + data.RemoveRange(0, 4); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(1, raised); + } + } + + public class SourceReset + { + [Fact] + public void Can_Restore_Selection_In_SourceReset_Event() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = CreateTarget(createData: false); + var sourceResetRaised = 0; + var selectionChangedRaised = 0; + + target.Source = data; + target.SelectedIndex = 1; + + target.SourceReset += (s, e) => + { + target.SelectedIndex = data.IndexOf("bar"); + ++sourceResetRaised; + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++selectionChangedRaised; + }; + + data.Reset(new[] { "qux", "foo", "quux", "bar", "baz" }); + + Assert.Equal(3, target.SelectedIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, sourceResetRaised); + } + } + + private static SelectionModel CreateTarget(bool createData = true) + { + var result = new SelectionModel { SingleSelect = false }; + + if (createData) + { + result.Source = new AvaloniaList + { + "foo", + "bar", + "baz", + "qux", + "quux", + "corge", + "grault", + "garply", + "waldo", + "fred", + "plugh", + "xyzzy", + "thud" + }; + } + + return result; + } + + private class ResettingList : List, INotifyCollectionChanged + { + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public void Reset(IEnumerable? items = null) + { + if (items != null) + { + Clear(); + AddRange(items); + } + + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } + + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs new file mode 100644 index 0000000000..345518e729 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs @@ -0,0 +1,1210 @@ +using System; +using System.Collections.Specialized; +using Avalonia.Collections; +using Avalonia.Controls.Selection; +using Avalonia.Controls.Utils; +using Xunit; +using CollectionChangedEventManager = Avalonia.Controls.Utils.CollectionChangedEventManager; + +#nullable enable + +namespace Avalonia.Controls.UnitTests.Selection +{ + public class SelectionModelTests_Single + { + public class Source + { + [Fact] + public void Can_Select_Index_Before_Source_Assigned() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 5 }, e.SelectedIndexes); + Assert.Equal(new string?[] { null }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 5; + + Assert.Equal(5, target.SelectedIndex); + Assert.Equal(new[] { 5 }, target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Equal(new string?[] { null }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Can_Select_Item_Before_Source_Assigned() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectionChanged += (s, e) => ++raised; + target.SelectedItem = "bar"; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new string?[] { "bar" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Initializing_Source_Retains_Valid_Index_Selection() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++raised; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Initializing_Source_Removes_Invalid_Index_Selection() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 5; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 5 }, e.DeselectedIndexes); + Assert.Equal(new string?[] { null }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Initializing_Source_Retains_Valid_Item_Selection() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedItem = "bar"; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new string[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Initializing_Source_Removes_Invalid_Item_Selection() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedItem = "qux"; + target.SelectionChanged += (s, e) => ++raised; + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Initializing_Source_Respects_SourceIndex_SourceItem_Order() + { + var target = CreateTarget(false); + + target.SelectedIndex = 0; + target.SelectedItem = "bar"; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + } + + [Fact] + public void Initializing_Source_Respects_SourceItem_SourceIndex_Order() + { + var target = CreateTarget(false); + + target.SelectedItem = "foo"; + target.SelectedIndex = 1; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + } + + [Fact] + public void Changing_Source_To_Null_Doesnt_Clear_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 2; + + target.SelectionChanged += (s, e) => ++raised; + + target.Source = null; + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Equal(new string?[] { null }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Changing_Source_To_NonNUll_First_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 2; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 2 }, e.DeselectedIndexes); + Assert.Equal(new string?[] { "baz" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "qux", "quux", "corge" }; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Raises_PropertyChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.Source)) + { + ++raised; + } + }; + + target.Source = new[] { "qux", "quux", "corge" }; + + Assert.Equal(1, raised); + } + + [Fact] + public void Can_Assign_ValueType_Collection_To_SelectionModel_Of_Object() + { + var target = (ISelectionModel)new SelectionModel(); + + target.Source = new[] { 1, 2, 3 }; + } + } + + public class SelectedIndex + { + [Fact] + public void SelectedIndex_Larger_Than_Source_Clears_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 5; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Negative_SelectedIndex_Is_Coerced_To_Minus_1() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => ++raised; + + target.SelectedIndex = -5; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Setting_SelectedIndex_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_During_CollectionChanged_Results_In_Correct_Selection() + { + // Issue #4496 + var data = new AvaloniaList(); + var target = CreateTarget(); + var binding = new MockBinding(target, data); + + target.Source = data; + + data.Add("foo"); + + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void PropertyChanged_Is_Raised() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + + private class MockBinding : ICollectionChangedListener + { + private readonly SelectionModel _target; + + public MockBinding(SelectionModel target, AvaloniaList data) + { + _target = target; + CollectionChangedEventManager.Instance.AddListener(data, this); + } + + public void Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + _target.Select(0); + } + + public void PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + } + + public void PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + } + } + } + + public class SelectedItem + { + [Fact] + public void Setting_SelectedItem_To_Valid_Item_Updates_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.SelectedItem = "bar"; + + Assert.Equal(1, raised); + } + + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedIndexes + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndexes)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedItems + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItems)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class Select + { + [Fact] + public void Select_Sets_SelectedIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.Select(1); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_With_Invalid_Index_Does_Nothing() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + + target.Select(5); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(2); + target.SelectionChanged += (s, e) => ++raised; + target.Select(2); + + Assert.Equal(0, raised); + } + } + + public class SelectRange + { + [Fact] + public void SelectRange_Throws() + { + var target = CreateTarget(); + + Assert.Throws(() => target.SelectRange(0, 10)); + } + } + + public class Deselect + { + [Fact] + public void Deselect_Clears_Current_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Deselect(0); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Deselect_Does_Nothing_For_Nonselected_Item() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + target.SelectionChanged += (s, e) => ++raised; + target.Deselect(0); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, raised); + } + } + + public class DeselectRange + { + [Fact] + public void DeselectRange_Clears_Current_Selection_For_Intersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.DeselectRange(0, 2); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void DeselectRange_Does_Nothing_For_Nonintersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + target.SelectionChanged += (s, e) => ++raised; + target.DeselectRange(1, 2); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + } + + public class Clear + { + [Fact] + public void Clear_Raises_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(1); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Clear(); + + Assert.Equal(1, raised); + } + } + + public class AnchorIndex + { + [Fact] + public void Setting_SelectedIndex_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_To_Minus_1_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = -1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Select_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Deselect_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Deselect(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Raises_PropertyChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SingleSelect + { + [Fact] + public void Converting_To_Multiple_Selection_Preserves_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++raised; + + target.SingleSelect = false; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Raises_PropertyChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SingleSelect)) + { + ++raised; + } + }; + + target.SingleSelect = false; + + Assert.Equal(1, raised); + } + } + + public class CollectionChanges + { + [Fact] + public void Adding_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedRaised = 0; + var selectedIndexRaised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(1, e.Delta); + ++indexesChangedRaised; + }; + + data.Insert(0, "new"); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(2, target.AnchorIndex); + Assert.Equal(1, indexesChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.Insert(2, "new"); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Removing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveAt(1); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(-1, e.Delta); + ++indexesChangedraised; + }; + + data.RemoveAt(0); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Removing_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.RemoveAt(2); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Replacing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data[1] = "new"; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Resetting_Source_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var resetRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + target.SourceReset += (s, e) => ++resetRaised; + + data.Clear(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(0, selectionChangedRaised); + Assert.Equal(1, resetRaised); + Assert.Equal(1, selectedIndexRaised); + } + } + + public class BatchUpdate + { + [Fact] + public void Changes_Do_Not_Take_Effect_Until_EndUpdate_Called() + { + var target = CreateTarget(); + + target.BeginBatchUpdate(); + target.Select(0); + + Assert.Equal(-1, target.SelectedIndex); + + target.EndBatchUpdate(); + + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Correctly_Batches_Clear_SelectedIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 2; + target.SelectionChanged += (s, e) => ++raised; + + using (target.BatchUpdate()) + { + target.Clear(); + target.SelectedIndex = 2; + } + + Assert.Equal(0, raised); + } + } + + public class LostSelection + { + [Fact] + public void LostSelection_Called_On_Clear() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Equal(new[] { 0 }, e.SelectedIndexes); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.LostSelection += (s, e) => + { + target.Select(0); + }; + + target.Clear(); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void LostSelection_Called_When_SelectedItem_Removed() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Equal(new[] { 0 }, e.SelectedIndexes); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.LostSelection += (s, e) => + { + target.Select(0); + }; + + data.RemoveAt(1); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void LostSelection_Not_Called_With_Old_Source_When_Changing_Source() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.LostSelection += (s, e) => + { + if (target.Source == data) + { + ++raised; + } + }; + + target.Source = null; + + Assert.Equal(0, raised); + } + } + + public class UntypedInterface + { + [Fact] + public void Raises_Untyped_SelectionChanged_Event() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + ((ISelectionModel)target).SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Equal(new[] { 2 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz" }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 2; + + Assert.Equal(1, raised); + } + } + + private static SelectionModel CreateTarget(bool createData = true) + { + var result = new SelectionModel { SingleSelect = true }; + + if (createData) + { + result.Source = new AvaloniaList { "foo", "bar", "baz" }; + } + + return result; + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs deleted file mode 100644 index 24e82a69d0..0000000000 --- a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs +++ /dev/null @@ -1,2322 +0,0 @@ -// This source file is adapted from the WinUI project. -// (https://github.com/microsoft/microsoft-ui-xaml) -// -// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Linq; -using System.Reactive.Linq; -using Avalonia.Collections; -using Avalonia.Diagnostics; -using ReactiveUI; -using Xunit; -using Xunit.Abstractions; - -namespace Avalonia.Controls.UnitTests -{ - public class SelectionModelTests - { - private readonly ITestOutputHelper _output; - - public SelectionModelTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - public void ValidateOneLevelSingleSelectionNoSource() - { - SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; - _output.WriteLine("No source set."); - Select(selectionModel, 4, true); - ValidateSelection(selectionModel, Path(4)); - Select(selectionModel, 4, false); - ValidateSelection(selectionModel); - } - - [Fact] - public void ValidateOneLevelSingleSelection() - { - SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; - _output.WriteLine("Set the source to 10 items"); - selectionModel.Source = Enumerable.Range(0, 10).ToList(); - Select(selectionModel, 3, true); - ValidateSelection(selectionModel, Path(3)); - Select(selectionModel, 3, false); - ValidateSelection(selectionModel); - } - - [Fact] - public void ValidateSelectionChangedEvent() - { - SelectionModel selectionModel = new SelectionModel(); - selectionModel.Source = Enumerable.Range(0, 10).ToList(); - - int selectionChangedFiredCount = 0; - selectionModel.SelectionChanged += delegate (object sender, SelectionModelSelectionChangedEventArgs args) - { - selectionChangedFiredCount++; - ValidateSelection(selectionModel, Path(4)); - }; - - Select(selectionModel, 4, true); - ValidateSelection(selectionModel, Path(4)); - Assert.Equal(1, selectionChangedFiredCount); - } - - [Fact] - public void ValidateCanSetSelectedIndex() - { - var model = new SelectionModel(); - var ip = IndexPath.CreateFrom(34); - model.SelectedIndex = ip; - Assert.Equal(0, ip.CompareTo(model.SelectedIndex)); - } - - [Fact] - public void ValidateOneLevelMultipleSelection() - { - SelectionModel selectionModel = new SelectionModel(); - selectionModel.Source = Enumerable.Range(0, 10).ToList(); - - Select(selectionModel, 4, true); - ValidateSelection(selectionModel, Path(4)); - SelectRangeFromAnchor(selectionModel, 8, true /* select */); - ValidateSelection(selectionModel, - Path(4), - Path(5), - Path(6), - Path(7), - Path(8)); - - ClearSelection(selectionModel); - SetAnchorIndex(selectionModel, 6); - SelectRangeFromAnchor(selectionModel, 3, true /* select */); - ValidateSelection(selectionModel, - Path(3), - Path(4), - Path(5), - Path(6)); - - SetAnchorIndex(selectionModel, 4); - SelectRangeFromAnchor(selectionModel, 5, false /* select */); - ValidateSelection(selectionModel, - Path(3), - Path(6)); - } - - [Fact] - public void ValidateTwoLevelSingleSelection() - { - SelectionModel selectionModel = new SelectionModel(); - _output.WriteLine("Setting the source"); - selectionModel.Source = CreateNestedData(1 /* levels */ , 2 /* groupsAtLevel */, 2 /* countAtLeaf */); - Select(selectionModel, 1, 1, true); - ValidateSelection(selectionModel, Path(1, 1)); - Select(selectionModel, 1, 1, false); - ValidateSelection(selectionModel); - } - - [Fact] - public void ValidateTwoLevelMultipleSelection() - { - SelectionModel selectionModel = new SelectionModel(); - _output.WriteLine("Setting the source"); - selectionModel.Source = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); - - Select(selectionModel, 1, 2, true); - ValidateSelection(selectionModel, Path(1, 2)); - SelectRangeFromAnchor(selectionModel, 2, 2, true /* select */); - ValidateSelection(selectionModel, - Path(1, 2), - Path(2, 0), - Path(2, 1), - Path(2, 2)); - - ClearSelection(selectionModel); - SetAnchorIndex(selectionModel, 2, 1); - SelectRangeFromAnchor(selectionModel, 0, 1, true /* select */); - ValidateSelection(selectionModel, - Path(0, 1), - Path(0, 2), - Path(1, 0), - Path(1, 1), - Path(1, 2), - Path(2, 0), - Path(2, 1)); - - SetAnchorIndex(selectionModel, 1, 1); - SelectRangeFromAnchor(selectionModel, 2, 0, false /* select */); - ValidateSelection(selectionModel, - Path(0, 1), - Path(0, 2), - Path(1, 0), - Path(2, 1)); - - ClearSelection(selectionModel); - ValidateSelection(selectionModel); - } - - [Fact] - public void ValidateNestedSingleSelection() - { - SelectionModel selectionModel = new SelectionModel() { SingleSelect = true }; - _output.WriteLine("Setting the source"); - selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 2 /* countAtLeaf */); - var path = Path(1, 0, 1, 1); - Select(selectionModel, path, true); - ValidateSelection(selectionModel, path); - Select(selectionModel, Path(0, 0, 1, 0), true); - ValidateSelection(selectionModel, Path(0, 0, 1, 0)); - Select(selectionModel, Path(0, 0, 1, 0), false); - ValidateSelection(selectionModel); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void ValidateNestedMultipleSelection(bool handleChildrenRequested) - { - SelectionModel selectionModel = new SelectionModel(); - List sourcePaths = new List(); - - _output.WriteLine("Setting the source"); - selectionModel.Source = CreateNestedData(3 /* levels */ , 2 /* groupsAtLevel */, 4 /* countAtLeaf */); - if (handleChildrenRequested) - { - selectionModel.ChildrenRequested += (object sender, SelectionModelChildrenRequestedEventArgs args) => - { - _output.WriteLine("ChildrenRequestedIndexPath:" + args.SourceIndex); - sourcePaths.Add(args.SourceIndex); - args.Children = Observable.Return(args.Source as IEnumerable); - }; - } - - var startPath = Path(1, 0, 1, 0); - Select(selectionModel, startPath, true); - ValidateSelection(selectionModel, startPath); - - var endPath = Path(1, 1, 1, 0); - SelectRangeFromAnchor(selectionModel, endPath, true /* select */); - - if (handleChildrenRequested) - { - // Validate SourceIndices. - var expectedSourceIndices = new List() - { - Path(1), - Path(1, 0), - Path(1, 0, 1), - Path(1, 1), - Path(1, 0, 1, 3), - Path(1, 0, 1, 2), - Path(1, 0, 1, 1), - Path(1, 0, 1, 0), - Path(1, 1, 1), - Path(1, 1, 0), - Path(1, 1, 0, 3), - Path(1, 1, 0, 2), - Path(1, 1, 0, 1), - Path(1, 1, 0, 0), - Path(1, 1, 1, 0) - }; - - Assert.Equal(expectedSourceIndices.Count, sourcePaths.Count); - for (int i = 0; i < expectedSourceIndices.Count; i++) - { - Assert.True(AreEqual(expectedSourceIndices[i], sourcePaths[i])); - } - } - - ValidateSelection(selectionModel, - Path(1, 1), - Path(1, 0, 1, 0), - Path(1, 0, 1, 1), - Path(1, 0, 1, 2), - Path(1, 0, 1, 3), - Path(1, 1, 0), - Path(1, 1, 1), - Path(1, 1, 0, 0), - Path(1, 1, 0, 1), - Path(1, 1, 0, 2), - Path(1, 1, 0, 3), - Path(1, 1, 1, 0)); - - ClearSelection(selectionModel); - ValidateSelection(selectionModel); - - startPath = Path(0, 1, 0, 2); - SetAnchorIndex(selectionModel, startPath); - endPath = Path(0, 0, 0, 2); - SelectRangeFromAnchor(selectionModel, endPath, true /* select */); - ValidateSelection(selectionModel, - Path(0, 1), - Path(0, 0, 1), - Path(0, 0, 0, 2), - Path(0, 0, 0, 3), - Path(0, 0, 1, 0), - Path(0, 0, 1, 1), - Path(0, 0, 1, 2), - Path(0, 0, 1, 3), - Path(0, 1, 0), - Path(0, 1, 0, 0), - Path(0, 1, 0, 1), - Path(0, 1, 0, 2)); - - startPath = Path(0, 1, 0, 2); - SetAnchorIndex(selectionModel, startPath); - endPath = Path(0, 0, 0, 2); - SelectRangeFromAnchor(selectionModel, endPath, false /* select */); - ValidateSelection(selectionModel); - } - - [Fact] - public void ValidateInserts() - { - var data = new ObservableCollection(Enumerable.Range(0, 10)); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(3); - selectionModel.Select(4); - selectionModel.Select(5); - ValidateSelection(selectionModel, - Path(3), - Path(4), - Path(5)); - - _output.WriteLine("Insert in selected range: Inserting 3 items at index 4"); - data.Insert(4, 41); - data.Insert(4, 42); - data.Insert(4, 43); - ValidateSelection(selectionModel, - Path(3), - Path(7), - Path(8)); - - _output.WriteLine("Insert before selected range: Inserting 3 items at index 0"); - data.Insert(0, 100); - data.Insert(0, 101); - data.Insert(0, 102); - ValidateSelection(selectionModel, - Path(6), - Path(10), - Path(11)); - - _output.WriteLine("Insert after selected range: Inserting 3 items at index 12"); - data.Insert(12, 1000); - data.Insert(12, 1001); - data.Insert(12, 1002); - ValidateSelection(selectionModel, - Path(6), - Path(10), - Path(11)); - } - - [Fact] - public void ValidateGroupInserts() - { - var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(1, 1); - ValidateSelection(selectionModel, Path(1, 1)); - - _output.WriteLine("Insert before selected range: Inserting item at group index 0"); - data.Insert(0, 100); - ValidateSelection(selectionModel, Path(2, 1)); - - _output.WriteLine("Insert after selected range: Inserting item at group index 3"); - data.Insert(3, 1000); - ValidateSelection(selectionModel, Path(2, 1)); - } - - [Fact] - public void ValidateRemoves() - { - var data = new ObservableCollection(Enumerable.Range(0, 10)); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(6); - selectionModel.Select(7); - selectionModel.Select(8); - ValidateSelection(selectionModel, - Path(6), - Path(7), - Path(8)); - - _output.WriteLine("Remove before selected range: Removing item at index 0"); - data.RemoveAt(0); - ValidateSelection(selectionModel, - Path(5), - Path(6), - Path(7)); - - _output.WriteLine("Remove from before to middle of selected range: Removing items at index 3, 4, 5"); - data.RemoveAt(3); - data.RemoveAt(3); - data.RemoveAt(3); - ValidateSelection(selectionModel, Path(3), Path(4)); - - _output.WriteLine("Remove after selected range: Removing item at index 5"); - data.RemoveAt(5); - ValidateSelection(selectionModel, Path(3), Path(4)); - } - - [Fact] - public void ValidateGroupRemoves() - { - var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(1, 1); - selectionModel.Select(1, 2); - ValidateSelection(selectionModel, Path(1, 1), Path(1, 2)); - - _output.WriteLine("Remove before selected range: Removing item at group index 0"); - data.RemoveAt(0); - ValidateSelection(selectionModel, Path(0, 1), Path(0, 2)); - - _output.WriteLine("Remove after selected range: Removing item at group index 1"); - data.RemoveAt(1); - ValidateSelection(selectionModel, Path(0, 1), Path(0, 2)); - - _output.WriteLine("Remove group containing selected items"); - - var raised = 0; - - selectionModel.SelectionChanged += (s, e) => - { - Assert.Empty(e.DeselectedIndices); - Assert.Equal(new object[] { 4, 5, }, e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - data.RemoveAt(0); - ValidateSelection(selectionModel); - Assert.Equal(1, raised); - } - - [Fact] - public void CanReplaceItem() - { - var data = new ObservableCollection(Enumerable.Range(0, 10)); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(3); - selectionModel.Select(4); - selectionModel.Select(5); - ValidateSelection(selectionModel, Path(3), Path(4), Path(5)); - - data[3] = 300; - data[4] = 400; - ValidateSelection(selectionModel, Path(5)); - } - - [Fact] - public void ValidateGroupReplaceLosesSelection() - { - var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(1, 1); - ValidateSelection(selectionModel, Path(1, 1)); - - data[1] = new ObservableCollection(Enumerable.Range(0, 5)); - ValidateSelection(selectionModel); - } - - [Fact] - public void ValidateClear() - { - var data = new ObservableCollection(Enumerable.Range(0, 10)); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(3); - selectionModel.Select(4); - selectionModel.Select(5); - ValidateSelection(selectionModel, Path(3), Path(4), Path(5)); - - data.Clear(); - ValidateSelection(selectionModel); - } - - [Fact] - public void ValidateGroupClear() - { - var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - - selectionModel.Select(1, 1); - ValidateSelection(selectionModel, Path(1, 1)); - - (data[1] as IList).Clear(); - ValidateSelection(selectionModel); - } - - // In some cases the leaf node might get a collection change that affects an ancestors selection - // state. In this case we were not raising selection changed event. For example, if all elements - // in a group are selected and a new item gets inserted - the parent goes from selected to partially - // selected. In that case we need to raise the selection changed event so that the header containers - // can show the correct visual. - [Fact] - public void ValidateEventWhenInnerNodeChangesSelectionState() - { - bool selectionChangedRaised = false; - var data = CreateNestedData(1 /* levels */ , 3 /* groupsAtLevel */, 3 /* countAtLeaf */); - var selectionModel = new SelectionModel(); - selectionModel.Source = data; - selectionModel.SelectionChanged += (sender, args) => { selectionChangedRaised = true; }; - - selectionModel.Select(1, 0); - selectionModel.Select(1, 1); - selectionModel.Select(1, 2); - ValidateSelection(selectionModel, Path(1, 0), Path(1, 1), Path(1, 2)); - - _output.WriteLine("Inserting 1.0"); - selectionChangedRaised = false; - (data[1] as AvaloniaList).Insert(0, 100); - Assert.True(selectionChangedRaised, "SelectionChanged event was not raised"); - ValidateSelection(selectionModel, Path(1, 1), Path(1, 2), Path(1, 3)); - - _output.WriteLine("Removing 1.0"); - selectionChangedRaised = false; - (data[1] as AvaloniaList).RemoveAt(0); - Assert.True(selectionChangedRaised, "SelectionChanged event was not raised"); - ValidateSelection(selectionModel, - Path(1, 0), - Path(1, 1), - Path(1, 2)); - } - - [Fact] - public void ValidatePropertyChangedEventIsRaised() - { - var selectionModel = new SelectionModel(); - _output.WriteLine("Set the source to 10 items"); - selectionModel.Source = Enumerable.Range(0, 10).ToList(); - - bool selectedIndexChanged = false; - bool selectedIndicesChanged = false; - bool SelectedItemChanged = false; - bool SelectedItemsChanged = false; - bool AnchorIndexChanged = false; - selectionModel.PropertyChanged += (sender, args) => - { - switch (args.PropertyName) - { - case "SelectedIndex": - selectedIndexChanged = true; - break; - case "SelectedIndices": - selectedIndicesChanged = true; - break; - case "SelectedItem": - SelectedItemChanged = true; - break; - case "SelectedItems": - SelectedItemsChanged = true; - break; - case "AnchorIndex": - AnchorIndexChanged = true; - break; - - default: - throw new InvalidOperationException(); - } - }; - - Select(selectionModel, 3, true); - - Assert.True(selectedIndexChanged); - Assert.True(selectedIndicesChanged); - Assert.True(SelectedItemChanged); - Assert.True(SelectedItemsChanged); - Assert.True(AnchorIndexChanged); - } - - [Fact] - public void CanExtendSelectionModelINPC() - { - var selectionModel = new CustomSelectionModel(); - bool intPropertyChanged = false; - selectionModel.PropertyChanged += (sender, args) => - { - if (args.PropertyName == "IntProperty") - { - intPropertyChanged = true; - } - }; - - selectionModel.IntProperty = 5; - Assert.True(intPropertyChanged); - } - - [Fact] - public void SelectRangeRegressionTest() - { - var selectionModel = new SelectionModel() - { - Source = CreateNestedData(1, 2, 3) - }; - - // length of start smaller than end used to cause an out of range error. - selectionModel.SelectRange(IndexPath.CreateFrom(0), IndexPath.CreateFrom(1, 1)); - - ValidateSelection(selectionModel, - Path(0), - Path(1), - Path(0, 0), - Path(0, 1), - Path(0, 2), - Path(1, 0), - Path(1, 1)); - } - - [Fact] - public void SelectRange_Should_Select_Nested_Items_On_Different_Levels() - { - var target = new SelectionModel(); - var data = CreateNestedData(1, 2, 3); - - target.Source = data; - target.AnchorIndex = new IndexPath(0, 1); - target.SelectRange(Path(0, 1), Path(1)); - - Assert.Equal( - new[] - { - Path(1), - Path(0, 1), - Path(0, 2), - }, - target.SelectedIndices); - } - - [Fact] - public void Should_Listen_For_Changes_After_Deselect() - { - var target = new SelectionModel(); - var data = CreateNestedData(1, 2, 3); - - target.Source = data; - target.Select(1, 0); - target.Deselect(1, 0); - target.Select(1, 0); - ((AvaloniaList)data[1]).Insert(0, "foo"); - - Assert.Equal(new IndexPath(1, 1), target.SelectedIndex); - } - - [Fact] - public void Selecting_Item_Raises_SelectionChanged() - { - 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 Clearing_Nested_Selection_Raises_SelectionChanged() - { - var target = new SelectionModel(); - var raised = 0; - - target.Source = CreateNestedData(1, 2, 3); - target.Select(1, 1); - - target.SelectionChanged += (s, e) => - { - Assert.Equal(new[] { new IndexPath(1, 1) }, e.DeselectedIndices); - Assert.Equal(new object[] { 4 }, e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - target.ClearSelection(); - - Assert.Equal(1, raised); - } - - [Fact] - public void Changing_Source_Raises_SelectionChanged() - { - 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[] { new AvaloniaList { 0, 1, 2 }, 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() - { - var data = CreateNestedData(2, 2, 2); - var target = new SelectionModel { Source = data }; - - target.SelectAll(); - VerifyCollectionChangedHandlers(1, data); - - target.Dispose(); - - VerifyCollectionChangedHandlers(0, data); - } - - [Fact] - public void Clearing_Selection_Unhooks_CollectionChanged_Handlers() - { - var data = CreateNestedData(2, 2, 2); - var target = new SelectionModel { Source = data }; - - target.SelectAll(); - VerifyCollectionChangedHandlers(1, data); - - target.ClearSelection(); - - // Root subscription not unhooked until SelectionModel is disposed. - Assert.Equal(1, GetSubscriberCount(data)); - - foreach (AvaloniaList i in data) - { - VerifyCollectionChangedHandlers(0, i); - } - } - - [Fact] - public void Removing_Item_Unhooks_CollectionChanged_Handlers() - { - var data = CreateNestedData(2, 2, 2); - var target = new SelectionModel { Source = data }; - - target.SelectAll(); - - var toRemove = (AvaloniaList)data[1]; - data.Remove(toRemove); - - Assert.Equal(0, GetSubscriberCount(toRemove)); - } - - [Fact] - public void SelectRange_Behaves_The_Same_As_Multiple_Selects() - { - var data = new[] { 1, 2, 3 }; - var target = new SelectionModel { Source = data }; - - target.Select(1); - - Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); - - target.ClearSelection(); - target.SelectRange(new IndexPath(1), new IndexPath(1)); - - Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); - } - - [Fact] - public void SelectRange_Behaves_The_Same_As_Multiple_Selects_Nested() - { - var data = CreateNestedData(3, 2, 2); - var target = new SelectionModel { Source = data }; - - target.Select(1); - - Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); - - target.ClearSelection(); - target.SelectRange(new IndexPath(1), new IndexPath(1)); - - Assert.Equal(new[] { IndexPath.CreateFrom(1) }, target.SelectedIndices); - } - - [Fact] - public void Should_Not_Treat_Strings_As_Nested_Selections() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data }; - - target.SelectAll(); - - Assert.Equal(3, target.SelectedItems.Count); - } - - [Fact] - public void Not_Enumerating_Changes_Does_Not_Prevent_Further_Operations() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data }; - - target.SelectionChanged += (s, e) => { }; - - target.SelectAll(); - target.ClearSelection(); - } - - [Fact] - public void Can_Change_Selection_From_SelectionChanged() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data }; - var raised = 0; - - target.SelectionChanged += (s, e) => - { - if (raised++ == 0) - { - target.ClearSelection(); - } - }; - - target.SelectAll(); - - Assert.Equal(2, raised); - } - - [Fact] - public void Raises_SelectionChanged_With_No_Source() - { - var target = new SelectionModel(); - var raised = 0; - - target.SelectionChanged += (s, e) => - { - Assert.Empty(e.DeselectedIndices); - Assert.Empty(e.DeselectedItems); - Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - target.Select(1); - - Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - Assert.Empty(target.SelectedItems); - } - - [Fact] - public void Raises_SelectionChanged_With_Items_After_Source_Is_Set() - { - var target = new SelectionModel(); - var raised = 0; - - target.Select(1); - target.SelectionChanged += (s, e) => - { - Assert.Empty(e.DeselectedIndices); - Assert.Empty(e.DeselectedItems); - Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); - Assert.Equal(new[] { "bar" }, e.SelectedItems); - ++raised; - }; - - target.Source = new[] { "foo", "bar", "baz" }; - - Assert.Equal(1, raised); - } - - [Fact] - public void RetainSelectionOnReset_Retains_Selection_On_Reset() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - data.Reset(); - - Assert.Equal(new[] { new IndexPath(1), new IndexPath(2) }, target.SelectedIndices); - Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); - } - - [Fact] - public void RetainSelectionOnReset_Retains_Correct_Selection_After_Deselect() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - target.Deselect(2); - data.Reset(); - - Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - Assert.Equal(new[] { "bar" }, target.SelectedItems); - } - - [Fact] - public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove_1() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - data.RemoveAt(2); - data.Reset(new[] { "foo", "bar", "baz" }); - - Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - Assert.Equal(new[] { "bar" }, target.SelectedItems); - } - - [Fact] - public void RetainSelectionOnReset_Retains_Correct_Selection_After_Remove_2() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - data.RemoveAt(0); - data.Reset(new[] { "foo", "bar", "baz" }); - - Assert.Equal(new[] { new IndexPath(1), new IndexPath(2) }, target.SelectedIndices); - Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); - } - - [Fact] - public void RetainSelectionOnReset_Retains_No_Selection_After_Clear() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - target.ClearSelection(); - data.Reset(); - - Assert.Empty(target.SelectedIndices); - Assert.Empty(target.SelectedItems); - } - - [Fact] - public void RetainSelectionOnReset_Retains_Correct_Selection_After_Two_Resets() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - data.Reset(new[] { "foo", "bar" }); - data.Reset(new[] { "foo", "bar", "baz" }); - - Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - Assert.Equal(new[] { "bar", }, target.SelectedItems); - } - - [Fact] - public void RetainSelectionOnReset_Raises_Empty_SelectionChanged_On_Reset_With_No_Changes() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - var raised = 0; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - - target.SelectionChanged += (s, e) => - { - Assert.Empty(e.DeselectedIndices); - Assert.Empty(e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - data.Reset(); - } - - [Fact] - public void RetainSelectionOnReset_Raises_SelectionChanged_On_Reset_With_Removed_Items() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data, RetainSelectionOnReset = true }; - var raised = 0; - - target.SelectRange(new IndexPath(1), new IndexPath(2)); - - target.SelectionChanged += (s, e) => - { - Assert.Empty(e.DeselectedIndices); - Assert.Equal(new[] { "bar" }, e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - data.Reset(new[] { "foo", "baz" }); - - Assert.Equal(1, raised); - } - - [Fact] - public void RetainSelectionOnReset_Handles_Null_Source() - { - var data = new ResettingList { "foo", "bar", "baz" }; - var target = new SelectionModel { RetainSelectionOnReset = true }; - var raised = 0; - - target.SelectionChanged += (s, e) => - { - if (raised == 0) - { - Assert.Empty(e.DeselectedIndices); - Assert.Empty(e.DeselectedItems); - Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); - Assert.Empty(e.SelectedItems); - } - else if (raised == 1) - { - Assert.Empty(e.DeselectedIndices); - Assert.Empty(e.DeselectedItems); - Assert.Equal(new[] { new IndexPath(1) }, e.SelectedIndices); - Assert.Equal(new[] { "bar" }, e.SelectedItems); - } - else if (raised == 3) - { - Assert.Empty(e.DeselectedIndices); - Assert.Empty(e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - } - - ++raised; - }; - - target.Select(1); - Assert.Equal(1, raised); - - target.Source = data; - Assert.Equal(2, raised); - Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - - data.Reset(new[] { "qux", "foo", "bar", "baz" }); - Assert.Equal(3, raised); - Assert.Equal(new[] { new IndexPath(2) }, target.SelectedIndices); - } - - [Fact] - public void Can_Batch_Update() - { - var target = new SelectionModel(); - var raised = 0; - - target.Source = Enumerable.Range(0, 10).ToList(); - target.Select(1); - - target.SelectionChanged += (s, e) => - { - Assert.Equal(new[] { new IndexPath(1) }, e.DeselectedIndices); - Assert.Equal(new object[] { 1 }, e.DeselectedItems); - Assert.Equal(new[] { new IndexPath(4) }, e.SelectedIndices); - Assert.Equal(new object[] { 4 }, e.SelectedItems); - ++raised; - }; - - using (target.Update()) - { - target.Deselect(1); - target.Select(4); - } - - Assert.Equal(1, raised); - } - - [Fact] - public void Batch_Update_Clear_Nested_Data_Raises_SelectionChanged() - { - var target = new SelectionModel(); - var raised = 0; - - target.Source = CreateNestedData(3, 2, 2); - target.SelectRange(new IndexPath(0), new IndexPath(1, 1)); - - Assert.Equal(24, target.SelectedIndices.Count); - - var indices = target.SelectedIndices.ToList(); - var items = target.SelectedItems.ToList(); - - target.SelectionChanged += (s, e) => - { - Assert.Equal(indices, e.DeselectedIndices); - Assert.Equal(items, e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - using (target.Update()) - { - target.ClearSelection(); - } - - Assert.Equal(1, raised); - } - - [Fact] - public void Batch_Update_Does_Not_Raise_PropertyChanged_Until_Operation_Finished() - { - var data = new[] { "foo", "bar", "baz", "qux" }; - var target = new SelectionModel { Source = data }; - var raised = 0; - - target.SelectedIndex = new IndexPath(1); - - Assert.Equal(new IndexPath(1), target.AnchorIndex); - - target.PropertyChanged += (s, e) => ++raised; - - using (target.Update()) - { - target.ClearSelection(); - - Assert.Equal(0, raised); - - target.AnchorIndex = new IndexPath(2); - - Assert.Equal(0, raised); - - target.SelectedIndex = new IndexPath(3); - - Assert.Equal(0, raised); - } - - Assert.Equal(new IndexPath(3), target.AnchorIndex); - Assert.Equal(5, raised); - } - - [Fact] - public void Batch_Update_Does_Not_Raise_PropertyChanged_If_Nothing_Changed() - { - var data = new[] { "foo", "bar", "baz", "qux" }; - var target = new SelectionModel { Source = data }; - var raised = 0; - - target.SelectedIndex = new IndexPath(1); - - Assert.Equal(new IndexPath(1), target.AnchorIndex); - - target.PropertyChanged += (s, e) => ++raised; - - using (target.Update()) - { - target.ClearSelection(); - target.SelectedIndex = new IndexPath(1); - } - - Assert.Equal(0, raised); - } - - [Fact] - public void Batch_Update_Selection_Is_Correct_Throughout() - { - var data = new[] { "foo", "bar", "baz", "qux" }; - var target = new SelectionModel { Source = data }; - var raised = 0; - - using (target.Update()) - { - target.Select(1); - - Assert.Equal(new IndexPath(1), target.SelectedIndex); - Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - Assert.Equal("bar", target.SelectedItem); - Assert.Equal(new[] { "bar" }, target.SelectedItems); - - target.Deselect(1); - - Assert.Equal(new IndexPath(), target.SelectedIndex); - Assert.Empty(target.SelectedIndices); - Assert.Null(target.SelectedItem); - Assert.Empty(target.SelectedItems); - - target.SelectRange(new IndexPath(1), new IndexPath(1)); - - Assert.Equal(new IndexPath(1), target.SelectedIndex); - Assert.Equal(new[] { new IndexPath(1) }, target.SelectedIndices); - Assert.Equal("bar", target.SelectedItem); - Assert.Equal(new[] { "bar" }, target.SelectedItems); - - target.ClearSelection(); - - Assert.Equal(new IndexPath(), target.SelectedIndex); - Assert.Empty(target.SelectedIndices); - Assert.Null(target.SelectedItem); - Assert.Empty(target.SelectedItems); - } - - Assert.Equal(0, raised); - } - - [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); - } - - [Fact] - public void AutoSelect_Is_Applied_At_End_Of_Batch_Update() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { AutoSelect = true, Source = data }; - - using (target.Update()) - { - target.ClearSelection(); - - Assert.Equal(new IndexPath(), target.SelectedIndex); - Assert.Empty(target.SelectedIndices); - Assert.Null(target.SelectedItem); - Assert.Empty(target.SelectedItems); - } - - Assert.Equal(new IndexPath(0), target.SelectedIndex); - Assert.Equal(new[] { new IndexPath(0) }, target.SelectedIndices); - Assert.Equal("foo", target.SelectedItem); - Assert.Equal(new[] { "foo" }, target.SelectedItems); - - Assert.Equal(new IndexPath(0), target.SelectedIndex); - } - - [Fact] - public void Can_Replace_Parent_Children_Collection() - { - var root = new Node("Root"); - var target = new SelectionModel { Source = new[] { root } }; - var raised = 0; - - target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children); - - target.Select(0, 9); - - var selected = (Node)target.SelectedItem; - Assert.Equal("Child 9", selected.Header); - - target.SelectionChanged += (s, e) => - { - Assert.Equal(new[] { Path(0, 9) }, e.DeselectedIndices); - Assert.Equal(new[] { selected }, e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - root.ReplaceChildren(); - - Assert.Null(target.SelectedItem); - Assert.Equal(1, raised); - } - - [Fact] - public void Can_Replace_Grandparent_Children_Collection() - { - var root = new Node("Root"); - var target = new SelectionModel { Source = new[] { root } }; - var raised = 0; - - target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children); - - target.SelectAt(Path(0, 9, 1)); - - var selected = (Node)target.SelectedItem; - Assert.Equal("Child 1", selected.Header); - - target.SelectionChanged += (s, e) => - { - Assert.Equal(new[] { Path(0, 9, 1) }, e.DeselectedIndices); - Assert.Equal(new[] { selected }, e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - ++raised; - }; - - root.ReplaceChildren(); - - Assert.Null(target.SelectedItem); - Assert.Equal(1, raised); - } - - [Fact] - public void Child_Resolver_Is_Unsubscribed_When_Source_Changed() - { - var root = new Node("Root"); - var target = new SelectionModel { Source = new[] { root } }; - target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children); - - target.Select(0, 9); - - Assert.Equal(1, root.PropertyChangedSubscriptions); - - target.Source = null; - - Assert.Equal(0, root.PropertyChangedSubscriptions); - } - - [Fact] - public void Child_Resolver_Is_Unsubscribed_When_Parent_Removed() - { - var root = new Node("Root"); - var target = new SelectionModel { Source = new[] { root } }; - var node = root.Children[1]; - var path = new IndexPath(new[] { 0, 1, 1 }); - - target.ChildrenRequested += (s, e) => e.Children = ((Node)e.Source).WhenAnyValue(x => x.Children); - - target.SelectAt(path); - - Assert.Equal(1, node.PropertyChangedSubscriptions); - - root.ReplaceChildren(); - - Assert.Equal(0, node.PropertyChangedSubscriptions); - } - - [Fact] - public void Setting_SelectedIndex_To_Minus_1_Clears_Selection() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { Source = data }; - target.SelectedIndex = new IndexPath(1); - target.SelectedIndex = new IndexPath(-1); - Assert.Empty(target.SelectedIndices); - } - - [Fact] - public void Assigning_Source_With_Less_Items_Than_Previous_Clears_Selection() - { - var data = new[] { "foo", "bar", "baz", "boo", "hoo" }; - var smallerData = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { RetainSelectionOnReset = true }; - target.Source = data; - target.SelectedIndex = new IndexPath(4); - target.Source = smallerData; - Assert.Empty(target.SelectedIndices); - } - - [Fact] - public void Initializing_Source_With_Less_Items_Than_Selection_Trims_Selection() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel(); - target.SelectedIndex = new IndexPath(4); - target.Source = data; - Assert.Empty(target.SelectedIndices); - } - - [Fact] - public void Initializing_Source_With_Less_Items_Than_Selection_Trims_Selection_RetainSelection() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { RetainSelectionOnReset = true }; - target.SelectedIndex = new IndexPath(4); - target.Source = data; - Assert.Empty(target.SelectedIndices); - } - - [Fact] - public void Initializing_Source_With_Less_Items_Than_Multiple_Selection_Trims_Selection() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel { RetainSelectionOnReset = true }; - target.Select(4); - target.Select(2); - target.Source = data; - Assert.Equal(1, target.SelectedIndices.Count); - Assert.Equal(new IndexPath(2), target.SelectedIndices.First()); - } - - [Fact] - public void Initializing_Source_With_Less_Items_Than_Selection_Raises_SelectionChanged() - { - var data = new[] { "foo", "bar", "baz" }; - var target = new SelectionModel(); - var raised = 0; - - target.SelectedIndex = new IndexPath(4); - - target.SelectionChanged += (s, e) => - { - if (raised == 0) - { - Assert.Equal(new[] { Path(4) }, e.DeselectedIndices); - Assert.Equal(new object[] { null }, e.DeselectedItems); - Assert.Empty(e.SelectedIndices); - Assert.Empty(e.SelectedItems); - } - - ++raised; - }; - - target.Source = data; - - Assert.Equal(2, raised); - } - - private int GetSubscriberCount(AvaloniaList list) - { - return ((INotifyCollectionChangedDebug)list).GetCollectionChangedSubscribers()?.Length ?? 0; - } - - private void VerifyCollectionChangedHandlers(int expected, AvaloniaList list) - { - var count = GetSubscriberCount(list); - - Assert.Equal(expected, count); - - foreach (var i in list) - { - if (i is AvaloniaList l) - { - VerifyCollectionChangedHandlers(expected, l); - } - } - } - - private void Select(SelectionModel manager, int index, bool select) - { - _output.WriteLine((select ? "Selecting " : "DeSelecting ") + index); - if (select) - { - manager.Select(index); - } - else - { - manager.Deselect(index); - } - } - - private void Select(SelectionModel manager, int groupIndex, int itemIndex, bool select) - { - _output.WriteLine((select ? "Selecting " : "DeSelecting ") + groupIndex + "." + itemIndex); - if (select) - { - manager.Select(groupIndex, itemIndex); - } - else - { - manager.Deselect(groupIndex, itemIndex); - } - } - - private void Select(SelectionModel manager, IndexPath index, bool select) - { - _output.WriteLine((select ? "Selecting " : "DeSelecting ") + index); - if (select) - { - manager.SelectAt(index); - } - else - { - manager.DeselectAt(index); - } - } - - private void SelectRangeFromAnchor(SelectionModel manager, int index, bool select) - { - _output.WriteLine("SelectRangeFromAnchor " + index + " select: " + select.ToString()); - if (select) - { - manager.SelectRangeFromAnchor(index); - } - else - { - manager.DeselectRangeFromAnchor(index); - } - } - - private void SelectRangeFromAnchor(SelectionModel manager, int groupIndex, int itemIndex, bool select) - { - _output.WriteLine("SelectRangeFromAnchor " + groupIndex + "." + itemIndex + " select:" + select.ToString()); - if (select) - { - manager.SelectRangeFromAnchor(groupIndex, itemIndex); - } - else - { - manager.DeselectRangeFromAnchor(groupIndex, itemIndex); - } - } - - private void SelectRangeFromAnchor(SelectionModel manager, IndexPath index, bool select) - { - _output.WriteLine("SelectRangeFromAnchor " + index + " select: " + select.ToString()); - if (select) - { - manager.SelectRangeFromAnchorTo(index); - } - else - { - manager.DeselectRangeFromAnchorTo(index); - } - } - - private void ClearSelection(SelectionModel manager) - { - _output.WriteLine("ClearSelection"); - manager.ClearSelection(); - } - - private void SetAnchorIndex(SelectionModel manager, int index) - { - _output.WriteLine("SetAnchorIndex " + index); - manager.SetAnchorIndex(index); - } - - private void SetAnchorIndex(SelectionModel manager, int groupIndex, int itemIndex) - { - _output.WriteLine("SetAnchor " + groupIndex + "." + itemIndex); - manager.SetAnchorIndex(groupIndex, itemIndex); - } - - private void SetAnchorIndex(SelectionModel manager, IndexPath index) - { - _output.WriteLine("SetAnchor " + index); - manager.AnchorIndex = index; - } - - private void ValidateSelection( - SelectionModel selectionModel, - params IndexPath[] expectedSelected) - { - Assert.Equal(expectedSelected, selectionModel.SelectedIndices); - } - - private object GetData(SelectionModel selectionModel, IndexPath indexPath) - { - var data = selectionModel.Source; - for (int i = 0; i < indexPath.GetSize(); i++) - { - var listData = data as IList; - data = listData[indexPath.GetAt(i)]; - } - - return data; - } - - private bool AreEqual(IndexPath a, IndexPath b) - { - if (a.GetSize() != b.GetSize()) - { - return false; - } - - for (int i = 0; i < a.GetSize(); i++) - { - if (a.GetAt(i) != b.GetAt(i)) - { - return false; - } - } - - return true; - } - - private List GetIndexPathsInSource(object source) - { - List paths = new List(); - Traverse(source, (TreeWalkNodeInfo node) => - { - if (!paths.Contains(node.Path)) - { - paths.Add(node.Path); - } - }); - - _output.WriteLine("All Paths in source.."); - foreach (var path in paths) - { - _output.WriteLine(path.ToString()); - } - _output.WriteLine("done."); - - return paths; - } - - private static void Traverse(object root, Action nodeAction) - { - var pendingNodes = new Stack(); - IndexPath current = Path(null); - pendingNodes.Push(new TreeWalkNodeInfo() { Current = root, Path = current }); - - while (pendingNodes.Count > 0) - { - var currentNode = pendingNodes.Pop(); - var currentObject = currentNode.Current as IList; - - if (currentObject != null) - { - for (int i = currentObject.Count - 1; i >= 0; i--) - { - var child = currentObject[i]; - List path = new List(); - for (int idx = 0; idx < currentNode.Path.GetSize(); idx++) - { - path.Add(currentNode.Path.GetAt(idx)); - } - - path.Add(i); - var childPath = IndexPath.CreateFromIndices(path); - if (child != null) - { - pendingNodes.Push(new TreeWalkNodeInfo() { Current = child, Path = childPath }); - } - } - } - - nodeAction(currentNode); - } - } - - private bool Contains(List list, IndexPath index) - { - bool contains = false; - foreach (var item in list) - { - if (item.CompareTo(index) == 0) - { - contains = true; - break; - } - } - - return contains; - } - - public static AvaloniaList CreateNestedData(int levels = 3, int groupsAtLevel = 5, int countAtLeaf = 10) - { - var nextData = 0; - return CreateNestedData(levels, groupsAtLevel, countAtLeaf, ref nextData); - } - - public static AvaloniaList CreateNestedData( - int levels, - int groupsAtLevel, - int countAtLeaf, - ref int nextData) - { - var data = new AvaloniaList(); - if (levels != 0) - { - for (int i = 0; i < groupsAtLevel; i++) - { - data.Add(CreateNestedData(levels - 1, groupsAtLevel, countAtLeaf, ref nextData)); - } - } - else - { - for (int i = 0; i < countAtLeaf; i++) - { - data.Add(nextData++); - } - } - - return data; - } - - static IndexPath Path(params int[] path) - { - return IndexPath.CreateFromIndices(path); - } - - private static int _nextData = 0; - private struct TreeWalkNodeInfo - { - public object Current { get; set; } - - public IndexPath Path { get; set; } - } - - private class ResettingList : List, INotifyCollectionChanged - { - public event NotifyCollectionChangedEventHandler CollectionChanged; - - public new void RemoveAt(int index) - { - var item = this[index]; - base.RemoveAt(index); - CollectionChanged?.Invoke( - this, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { item }, index)); - } - - public void Reset(IEnumerable items = null) - { - if (items != null) - { - Clear(); - AddRange(items); - } - - CollectionChanged?.Invoke( - this, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - } - - private class Node : INotifyPropertyChanged - { - private ObservableCollection _children; - private PropertyChangedEventHandler _propertyChanged; - - public Node(string header) - { - Header = header; - } - - public string Header { get; } - - public ObservableCollection Children - { - get => _children ??= CreateChildren(10); - private set - { - _children = value; - _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Children))); - } - } - - public event PropertyChangedEventHandler PropertyChanged - { - add - { - _propertyChanged += value; - ++PropertyChangedSubscriptions; - } - - remove - { - _propertyChanged -= value; - --PropertyChangedSubscriptions; - } - } - - public int PropertyChangedSubscriptions { get; private set; } - - public void ReplaceChildren() - { - Children = CreateChildren(5); - } - - private ObservableCollection CreateChildren(int count) - { - return new ObservableCollection( - Enumerable.Range(0, count).Select(x => new Node("Child " + x))); - } - } - } - - class CustomSelectionModel : SelectionModel - { - public int IntProperty - { - get { return _intProperty; } - set - { - _intProperty = value; - OnPropertyChanged("IntProperty"); - } - } - - private int _intProperty; - } -} diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index c25ad19027..7022fbf4c1 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -255,12 +255,12 @@ namespace Avalonia.Controls.UnitTests ClickContainer(item2Container, KeyModifiers.Control); Assert.True(item2Container.IsSelected); - Assert.Equal(new[] { item1, item2 }, target.Selection.SelectedItems.OfType()); + Assert.Equal(new[] { item1, item2 }, target.SelectedItems.OfType()); ClickContainer(item1Container, KeyModifiers.Control); Assert.False(item1Container.IsSelected); - Assert.DoesNotContain(item1, target.Selection.SelectedItems.OfType()); + Assert.DoesNotContain(item1, target.SelectedItems.OfType()); } } @@ -785,11 +785,11 @@ namespace Avalonia.Controls.UnitTests target.SelectAll(); AssertChildrenSelected(target, tree[0]); - Assert.Equal(5, target.Selection.SelectedItems.Count); + Assert.Equal(5, target.SelectedItems.Count); _mouse.Click((Interactive)target.Presenter.Panel.Children[0], MouseButton.Right); - Assert.Equal(5, target.Selection.SelectedItems.Count); + Assert.Equal(5, target.SelectedItems.Count); } [Fact] @@ -823,11 +823,11 @@ namespace Avalonia.Controls.UnitTests ClickContainer(fromContainer, KeyModifiers.None); ClickContainer(toContainer, KeyModifiers.Shift); - Assert.Equal(2, target.Selection.SelectedItems.Count); + Assert.Equal(2, target.SelectedItems.Count); _mouse.Click(thenContainer, MouseButton.Right); - Assert.Equal(1, target.Selection.SelectedItems.Count); + Assert.Equal(1, target.SelectedItems.Count); } } @@ -860,7 +860,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: KeyModifiers.Shift); - Assert.Equal(1, target.Selection.SelectedItems.Count); + Assert.Equal(1, target.SelectedItems.Count); } } @@ -893,7 +893,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: KeyModifiers.Control); - Assert.Equal(1, target.Selection.SelectedItems.Count); + Assert.Equal(1, target.SelectedItems.Count); } } diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs index 4aa7e24aa7..3899d9dfbf 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -1,7 +1,8 @@ using System; -using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; using Avalonia.Collections; +using Avalonia.Controls.Selection; using Avalonia.Controls.Utils; using Xunit; @@ -13,7 +14,7 @@ namespace Avalonia.Controls.UnitTests.Utils public void Initial_Items_Are_From_Model() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; Assert.Equal(new[] { "bar", "baz" }, items); } @@ -22,9 +23,9 @@ namespace Avalonia.Controls.UnitTests.Utils public void Selecting_On_Model_Adds_Item() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; - target.Model.Select(0); + target.SelectionModel.Select(0); Assert.Equal(new[] { "bar", "baz", "foo" }, items); } @@ -33,9 +34,9 @@ namespace Avalonia.Controls.UnitTests.Utils public void Selecting_Duplicate_On_Model_Adds_Item() { var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; - target.Model.Select(4); + target.SelectionModel.Select(4); Assert.Equal(new[] { "bar", "baz", "bar" }, items); } @@ -44,9 +45,9 @@ namespace Avalonia.Controls.UnitTests.Utils public void Deselecting_On_Model_Removes_Item() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; - target.Model.Deselect(1); + target.SelectionModel.Deselect(1); Assert.Equal(new[] { "baz" }, items); } @@ -55,10 +56,10 @@ namespace Avalonia.Controls.UnitTests.Utils public void Deselecting_Duplicate_On_Model_Removes_Item() { var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; - target.Model.Select(4); - target.Model.Deselect(4); + target.SelectionModel.Select(4); + target.SelectionModel.Deselect(4); Assert.Equal(new[] { "baz", "bar" }, items); } @@ -67,13 +68,18 @@ namespace Avalonia.Controls.UnitTests.Utils public void Reassigning_Model_Resets_Items() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; + + var newModel = new SelectionModel + { + Source = (string[])target.SelectionModel.Source, + SingleSelect = false + }; - var newModel = new SelectionModel { Source = target.Model.Source }; newModel.Select(0); newModel.Select(1); - target.SetModel(newModel); + target.SelectionModel = newModel; Assert.Equal(new[] { "foo", "bar" }, items); } @@ -82,10 +88,15 @@ namespace Avalonia.Controls.UnitTests.Utils public void Reassigning_Model_Tracks_New_Model() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; + + var newModel = new SelectionModel + { + Source = (string[])target.SelectionModel.Source, + SingleSelect = false + }; - var newModel = new SelectionModel { Source = target.Model.Source }; - target.SetModel(newModel); + target.SelectionModel = newModel; newModel.Select(0); newModel.Select(1); @@ -97,13 +108,11 @@ namespace Avalonia.Controls.UnitTests.Utils public void Adding_To_Items_Selects_On_Model() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; items.Add("foo"); - Assert.Equal( - new[] { new IndexPath(0), new IndexPath(1), new IndexPath(2) }, - target.Model.SelectedIndices); + Assert.Equal(new[] { 0, 1, 2 }, target.SelectionModel.SelectedIndexes); Assert.Equal(new[] { "bar", "baz", "foo" }, items); } @@ -111,11 +120,11 @@ namespace Avalonia.Controls.UnitTests.Utils public void Removing_From_Items_Deselects_On_Model() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; items.Remove("baz"); - Assert.Equal(new[] { new IndexPath(1) }, target.Model.SelectedIndices); + Assert.Equal(new[] { 1 }, target.SelectionModel.SelectedIndexes); Assert.Equal(new[] { "bar" }, items); } @@ -123,11 +132,11 @@ namespace Avalonia.Controls.UnitTests.Utils public void Replacing_Item_Updates_Model() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; items[0] = "foo"; - Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices); + Assert.Equal(new[] { 0, 2 }, target.SelectionModel.SelectedIndexes); Assert.Equal(new[] { "foo", "baz" }, items); } @@ -135,25 +144,25 @@ namespace Avalonia.Controls.UnitTests.Utils public void Clearing_Items_Updates_Model() { var target = CreateTarget(); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; items.Clear(); - Assert.Empty(target.Model.SelectedIndices); + Assert.Empty(target.SelectionModel.SelectedIndexes); } [Fact] public void Setting_Items_Updates_Model() { var target = CreateTarget(); - var oldItems = target.GetOrCreateItems(); + var oldItems = target.SelectedItems; var newItems = new AvaloniaList { "foo", "baz" }; - target.SetItems(newItems); + target.SelectedItems = newItems; - Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices); - Assert.Same(newItems, target.GetOrCreateItems()); - Assert.NotSame(oldItems, target.GetOrCreateItems()); + Assert.Equal(new[] { 0, 2 }, target.SelectionModel.SelectedIndexes); + Assert.Same(newItems, target.SelectedItems); + Assert.NotSame(oldItems, target.SelectedItems); Assert.Equal(new[] { "foo", "baz" }, newItems); } @@ -163,8 +172,8 @@ namespace Avalonia.Controls.UnitTests.Utils var target = CreateTarget(); var items = new AvaloniaList { "foo", "baz" }; - target.SetItems(items); - target.Model.Select(1); + target.SelectedItems = items; + target.SelectionModel.Select(1); Assert.Equal(new[] { "foo", "baz", "bar" }, items); } @@ -173,11 +182,11 @@ namespace Avalonia.Controls.UnitTests.Utils public void Setting_Items_To_Null_Creates_Empty_Items() { var target = CreateTarget(); - var oldItems = target.GetOrCreateItems(); + var oldItems = target.SelectedItems; - target.SetItems(null); + target.SelectedItems = null; - var newItems = Assert.IsType>(target.GetOrCreateItems()); + var newItems = Assert.IsType>(target.SelectedItems); Assert.NotSame(oldItems, newItems); } @@ -185,11 +194,11 @@ namespace Avalonia.Controls.UnitTests.Utils [Fact] public void Handles_Null_Model_Source() { - var model = new SelectionModel(); + var model = new SelectionModel { SingleSelect = false }; model.Select(1); var target = new SelectedItemsSync(model); - var items = target.GetOrCreateItems(); + var items = target.SelectedItems; Assert.Empty(items); @@ -205,21 +214,34 @@ namespace Avalonia.Controls.UnitTests.Utils var target = CreateTarget(); Assert.Throws(() => - target.SetItems(new[] { "foo", "bar", "baz" })); + target.SelectedItems = new[] { "foo", "bar", "baz" }); } [Fact] public void Selected_Items_Can_Be_Set_Before_SelectionModel_Source() { - var model = new SelectionModel(); + var model = new SelectionModel(); var target = new SelectedItemsSync(model); var items = new AvaloniaList { "foo", "bar", "baz" }; var selectedItems = new AvaloniaList { "bar" }; - target.SetItems(selectedItems); + target.SelectedItems = selectedItems; model.Source = items; - Assert.Equal(new IndexPath(1), model.SelectedIndex); + 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( @@ -227,11 +249,30 @@ namespace Avalonia.Controls.UnitTests.Utils { items ??= new[] { "foo", "bar", "baz" }; - var model = new SelectionModel { Source = items }; - model.SelectRange(new IndexPath(1), new IndexPath(2)); + 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; + } } }