diff --git a/samples/BindingDemo/MainWindow.xaml b/samples/BindingDemo/MainWindow.xaml index b57a9a0a9e..26a62ebca6 100644 --- a/samples/BindingDemo/MainWindow.xaml +++ b/samples/BindingDemo/MainWindow.xaml @@ -74,11 +74,11 @@ - + - + diff --git a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs index 22d01e0765..a66038ff3e 100644 --- a/samples/BindingDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/BindingDemo/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,7 @@ using System.Reactive.Linq; using System.Threading.Tasks; using System.Threading; using ReactiveUI; +using Avalonia.Controls; namespace BindingDemo.ViewModels { @@ -27,7 +28,7 @@ namespace BindingDemo.ViewModels Detail = "Item " + x + " details", })); - SelectedItems = new ObservableCollection(); + Selection = new SelectionModel(); ShuffleItems = ReactiveCommand.Create(() => { @@ -56,7 +57,7 @@ namespace BindingDemo.ViewModels } public ObservableCollection Items { get; } - public ObservableCollection SelectedItems { get; } + public SelectionModel Selection { get; } public ReactiveCommand ShuffleItems { get; } public string BooleanString diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index b1b3112e60..47b4ce7151 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -10,7 +10,7 @@ HorizontalAlignment="Center" Spacing="16"> - + diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml b/samples/ControlCatalog/Pages/TreeViewPage.xaml index 3a81e2ed02..c9e3fafb6d 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/Pages/TreeViewPage.xaml.cs b/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs index 1f35f05f1d..5893796b8b 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml.cs @@ -28,21 +28,22 @@ namespace ControlCatalog.Pages { Node root = new Node(); Items = root.Children; - SelectedItems = new ObservableCollection(); + Selection = new SelectionModel(); AddItemCommand = ReactiveCommand.Create(() => { - Node parentItem = SelectedItems.Count > 0 ? SelectedItems[0] : root; + Node parentItem = Selection.SelectedItems.Count > 0 ? + (Node)Selection.SelectedItems[0] : root; parentItem.AddNewItem(); }); RemoveItemCommand = ReactiveCommand.Create(() => { - while (SelectedItems.Count > 0) + while (Selection.SelectedItems.Count > 0) { - Node lastItem = SelectedItems[0]; + Node lastItem = (Node)Selection.SelectedItems[0]; RecursiveRemove(Items, lastItem); - SelectedItems.Remove(lastItem); + Selection.DeselectAt(Selection.SelectedIndices[0]); } bool RecursiveRemove(ObservableCollection items, Node selectedItem) @@ -67,7 +68,7 @@ namespace ControlCatalog.Pages public ObservableCollection Items { get; } - public ObservableCollection SelectedItems { get; } + public SelectionModel Selection { get; } public ReactiveCommand AddItemCommand { get; } @@ -78,7 +79,7 @@ namespace ControlCatalog.Pages get => _selectionMode; set { - SelectedItems.Clear(); + Selection.ClearSelection(); this.RaiseAndSetIfChanged(ref _selectionMode, value); } } @@ -109,7 +110,7 @@ namespace ControlCatalog.Pages public override string ToString() => Header; - private Node CreateNewNode() => new Node {Header = $"Item {_counter++}"}; + private Node CreateNewNode() => new Node { Header = $"Item {_counter++}" }; } } } diff --git a/samples/VirtualizationDemo/MainWindow.xaml b/samples/VirtualizationDemo/MainWindow.xaml index 12137cd03d..4bd657bf93 100644 --- a/samples/VirtualizationDemo/MainWindow.xaml +++ b/samples/VirtualizationDemo/MainWindow.xaml @@ -45,7 +45,7 @@ SelectedItems { get; } - = new AvaloniaList(); + public SelectionModel Selection { get; } = new SelectionModel(); public AvaloniaList Items { @@ -138,9 +137,9 @@ namespace VirtualizationDemo.ViewModels { var index = Items.Count; - if (SelectedItems.Count > 0) + if (Selection.SelectedIndices.Count > 0) { - index = Items.IndexOf(SelectedItems[0]); + index = Selection.SelectedIndex.GetAt(0); } Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString)); @@ -148,9 +147,9 @@ namespace VirtualizationDemo.ViewModels private void Remove() { - if (SelectedItems.Count > 0) + if (Selection.SelectedItems.Count > 0) { - Items.RemoveAll(SelectedItems); + Items.RemoveAll(Selection.SelectedItems.Cast().ToList()); } } @@ -164,8 +163,7 @@ namespace VirtualizationDemo.ViewModels private void SelectItem(int index) { - SelectedItems.Clear(); - SelectedItems.Add(Items[index]); + Selection.SelectedIndex = new IndexPath(index); } } } diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 67ef6cd1e9..50f2067df6 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -306,9 +306,9 @@ namespace Avalonia.Controls { var container = ItemContainerGenerator.ContainerFromIndex(selectedIndex); - if (container == null && SelectedItems.Count > 0) + if (container == null && SelectedIndex != -1) { - ScrollIntoView(SelectedItems[0]); + ScrollIntoView(Selection.SelectedIndex); container = ItemContainerGenerator.ContainerFromIndex(selectedIndex); } diff --git a/src/Avalonia.Controls/Generators/TreeContainerIndex.cs b/src/Avalonia.Controls/Generators/TreeContainerIndex.cs index 1660d6b1ad..f0da370f73 100644 --- a/src/Avalonia.Controls/Generators/TreeContainerIndex.cs +++ b/src/Avalonia.Controls/Generators/TreeContainerIndex.cs @@ -94,9 +94,13 @@ namespace Avalonia.Controls.Generators /// The container, or null of not found. public IControl ContainerFromItem(object item) { - IControl result; - _itemToContainer.TryGetValue(item, out result); - return result; + if (item != null) + { + _itemToContainer.TryGetValue(item, out var result); + return result; + } + + return null; } /// @@ -106,9 +110,13 @@ namespace Avalonia.Controls.Generators /// The item, or null of not found. public object ItemFromContainer(IControl container) { - object result; - _containerToItem.TryGetValue(container, out result); - return result; + if (container != null) + { + _containerToItem.TryGetValue(container, out var result); + return result; + } + + return null; } } } diff --git a/src/Avalonia.Controls/ISelectionModel.cs b/src/Avalonia.Controls/ISelectionModel.cs new file mode 100644 index 0000000000..6570921c03 --- /dev/null +++ b/src/Avalonia.Controls/ISelectionModel.cs @@ -0,0 +1,249 @@ +// 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 new file mode 100644 index 0000000000..6c5aaf7ad1 --- /dev/null +++ b/src/Avalonia.Controls/IndexPath.cs @@ -0,0 +1,180 @@ +// 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 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/IndexRange.cs b/src/Avalonia.Controls/IndexRange.cs new file mode 100644 index 0000000000..1dc161c699 --- /dev/null +++ b/src/Avalonia.Controls/IndexRange.cs @@ -0,0 +1,232 @@ +// 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 +{ + internal readonly struct IndexRange : IEquatable + { + private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue); + + public IndexRange(int begin, int end) + { + // Accept out of order begin/end pairs, just swap them. + if (begin > end) + { + int temp = begin; + begin = end; + end = temp; + } + + Begin = begin; + End = end; + } + + public int Begin { get; } + public int End { get; } + public int Count => (End - Begin) + 1; + + public bool Contains(int index) => index >= Begin && index <= End; + + public bool Split(int splitIndex, out IndexRange before, out IndexRange after) + { + bool afterIsValid; + + before = new IndexRange(Begin, splitIndex); + + if (splitIndex < End) + { + after = new IndexRange(splitIndex + 1, End); + afterIsValid = true; + } + else + { + after = new IndexRange(); + afterIsValid = false; + } + + return afterIsValid; + } + + public bool Intersects(IndexRange other) + { + return (Begin <= other.End) && (End >= other.Begin); + } + + public bool Adjacent(IndexRange other) + { + return Begin == other.End + 1 || End == other.Begin - 1; + } + + public override bool Equals(object? obj) + { + return obj is IndexRange range && Equals(range); + } + + public bool Equals(IndexRange other) + { + return Begin == other.Begin && End == other.End; + } + + public override int GetHashCode() + { + var hashCode = 1903003160; + hashCode = hashCode * -1521134295 + Begin.GetHashCode(); + hashCode = hashCode * -1521134295 + End.GetHashCode(); + return hashCode; + } + + public override string ToString() => $"[{Begin}..{End}]"; + + public static bool operator ==(IndexRange left, IndexRange right) => left.Equals(right); + public static bool operator !=(IndexRange left, IndexRange right) => !(left == right); + + public static int Add( + IList ranges, + IndexRange range, + IList? added = null) + { + var result = 0; + + for (var i = 0; i < ranges.Count && range != s_invalid; ++i) + { + var existing = ranges[i]; + + if (range.Intersects(existing) || range.Adjacent(existing)) + { + if (range.Begin < existing.Begin) + { + var add = new IndexRange(range.Begin, existing.Begin - 1); + ranges[i] = new IndexRange(range.Begin, existing.End); + added?.Add(add); + result += add.Count; + } + + range = range.End <= existing.End ? + s_invalid : + new IndexRange(existing.End + 1, range.End); + } + else if (range.End < existing.Begin) + { + ranges.Insert(i, range); + added?.Add(range); + result += range.Count; + range = s_invalid; + } + } + + if (range != s_invalid) + { + ranges.Add(range); + added?.Add(range); + result += range.Count; + } + + MergeRanges(ranges); + return result; + } + + public static int Remove( + IList ranges, + IndexRange range, + IList? removed = null) + { + var result = 0; + + for (var i = 0; i < ranges.Count; ++i) + { + var existing = ranges[i]; + + if (range.Intersects(existing)) + { + if (range.Begin <= existing.Begin && range.End >= existing.End) + { + ranges.RemoveAt(i--); + removed?.Add(existing); + result += existing.Count; + } + else if (range.Begin > existing.Begin && range.End >= existing.End) + { + ranges[i] = new IndexRange(existing.Begin, range.Begin - 1); + removed?.Add(new IndexRange(range.Begin, existing.End)); + result += existing.End - (range.Begin - 1); + } + else if (range.Begin > existing.Begin && range.End < existing.End) + { + ranges[i] = new IndexRange(existing.Begin, range.Begin - 1); + ranges.Insert(++i, new IndexRange(range.End + 1, existing.End)); + removed?.Add(range); + result += range.Count; + } + else if (range.End <= existing.End) + { + var remove = new IndexRange(existing.Begin, range.End); + ranges[i] = new IndexRange(range.End + 1, existing.End); + removed?.Add(remove); + result += remove.Count; + } + } + } + + return result; + } + + public static IEnumerable Subtract( + IndexRange lhs, + IEnumerable rhs) + { + var result = new List { lhs }; + + foreach (var range in rhs) + { + Remove(result, range); + } + + return result; + } + + public static IEnumerable EnumerateIndices(IEnumerable ranges) + { + foreach (var range in ranges) + { + for (var i = range.Begin; i <= range.End; ++i) + { + yield return i; + } + } + } + + public static int GetCount(IEnumerable ranges) + { + var result = 0; + + foreach (var range in ranges) + { + result += (range.End - range.Begin) + 1; + } + + return result; + } + + private static void MergeRanges(IList ranges) + { + for (var i = ranges.Count - 2; i >= 0; --i) + { + var r = ranges[i]; + var r1 = ranges[i + 1]; + + if (r.Intersects(r1) || r.End == r1.Begin - 1) + { + ranges[i] = new IndexRange(r.Begin, r1.End); + ranges.RemoveAt(i + 1); + } + } + } + } +} diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index a9349dadd9..fb92ca9c99 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -31,6 +31,12 @@ namespace Avalonia.Controls public static readonly new DirectProperty SelectedItemsProperty = SelectingItemsControl.SelectedItemsProperty; + /// + /// Defines the property. + /// + public static readonly new DirectProperty SelectionProperty = + SelectingItemsControl.SelectionProperty; + /// /// Defines the property. /// @@ -70,6 +76,15 @@ namespace Avalonia.Controls set => base.SelectedItems = value; } + /// + /// Gets or sets a model holding the current selection. + /// + public new ISelectionModel Selection + { + get => base.Selection; + set => base.Selection = value; + } + /// /// Gets or sets the selection mode. /// @@ -95,12 +110,12 @@ namespace Avalonia.Controls /// /// Selects all items in the . /// - public new void SelectAll() => base.SelectAll(); + public void SelectAll() => Selection.SelectAll(); /// /// Deselects all items in the . /// - public new void UnselectAll() => base.UnselectAll(); + public void UnselectAll() => Selection.ClearSelection(); /// protected override IItemContainerGenerator CreateItemContainerGenerator() diff --git a/src/Avalonia.Controls/Presenters/IItemsPresenter.cs b/src/Avalonia.Controls/Presenters/IItemsPresenter.cs index d8cf7a35de..873f0d756f 100644 --- a/src/Avalonia.Controls/Presenters/IItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/IItemsPresenter.cs @@ -11,6 +11,6 @@ namespace Avalonia.Controls.Presenters void ItemsChanged(NotifyCollectionChangedEventArgs e); - void ScrollIntoView(object item); + void ScrollIntoView(int index); } } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs index 5ba6b6b9a3..be2b1e86f0 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs @@ -275,8 +275,8 @@ namespace Avalonia.Controls.Presenters /// /// Scrolls the specified item into view. /// - /// The item. - public virtual void ScrollIntoView(object item) + /// The index of the item. + public virtual void ScrollIntoView(int index) { } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs index 3acb78edb8..275f4d418e 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs @@ -64,18 +64,13 @@ namespace Avalonia.Controls.Presenters /// /// Scrolls the specified item into view. /// - /// The item. - public override void ScrollIntoView(object item) + /// The index of the item. + public override void ScrollIntoView(int index) { - if (Items != null) + if (index != -1) { - var index = Items.IndexOf(item); - - if (index != -1) - { - var container = Owner.ItemContainerGenerator.ContainerFromIndex(index); - container?.BringIntoView(); - } + var container = Owner.ItemContainerGenerator.ContainerFromIndex(index); + container?.BringIntoView(); } } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs index b8d338741a..83ce63f240 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs @@ -286,20 +286,15 @@ namespace Avalonia.Controls.Presenters break; } - return ScrollIntoView(newItemIndex); + return ScrollIntoViewCore(newItemIndex); } /// - public override void ScrollIntoView(object item) + public override void ScrollIntoView(int index) { - if (Items != null) + if (index != -1) { - var index = Items.IndexOf(item); - - if (index != -1) - { - ScrollIntoView(index); - } + ScrollIntoViewCore(index); } } @@ -511,7 +506,7 @@ namespace Avalonia.Controls.Presenters /// /// The item index. /// The container that was brought into view. - private IControl ScrollIntoView(int index) + private IControl ScrollIntoViewCore(int index) { var panel = VirtualizingPanel; var generator = Owner.ItemContainerGenerator; diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index 26be85beb3..80c9e972d5 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -128,9 +128,9 @@ namespace Avalonia.Controls.Presenters _scrollInvalidated?.Invoke(this, e); } - public override void ScrollIntoView(object item) + public override void ScrollIntoView(int index) { - Virtualizer?.ScrollIntoView(item); + Virtualizer?.ScrollIntoView(index); } /// diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index 3a0e6b67c9..23846bcd2e 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -139,7 +139,7 @@ namespace Avalonia.Controls.Presenters } /// - public virtual void ScrollIntoView(object item) + public virtual void ScrollIntoView(int index) { } diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index e39cbdc016..8fc1a55e68 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -2,15 +2,15 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.ComponentModel; using System.Diagnostics; using System.Linq; -using Avalonia.Collections; using Avalonia.Controls.Generators; +using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; -using Avalonia.Logging; using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives @@ -23,9 +23,9 @@ namespace Avalonia.Controls.Primitives /// provides a base class for s /// that maintain a selection (single or multiple). By default only its /// and properties are visible; the - /// current multiple selection together with the - /// properties are protected, however a derived class can expose - /// these if it wishes to support multiple selection. + /// current multiple and together with the + /// and properties are protected, however a derived class can + /// expose these if it wishes to support multiple selection. /// /// /// maintains a selection respecting the current @@ -74,6 +74,15 @@ namespace Avalonia.Controls.Primitives o => o.SelectedItems, (o, v) => o.SelectedItems = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty SelectionProperty = + AvaloniaProperty.RegisterDirect( + nameof(Selection), + o => o.Selection, + (o, v) => o.Selection = v); + /// /// Defines the property. /// @@ -100,17 +109,22 @@ namespace Avalonia.Controls.Primitives RoutingStrategies.Bubble); private static readonly IList Empty = Array.Empty(); - - private readonly Selection _selection = new Selection(); + private readonly SelectedItemsSync _selectedItems; + private ISelectionModel _selection; private int _selectedIndex = -1; private object _selectedItem; - private IList _selectedItems; private bool _ignoreContainerSelectionChanged; - private bool _syncingSelectedItems; 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. /// @@ -142,17 +156,15 @@ namespace Avalonia.Controls.Primitives /// public int SelectedIndex { - get - { - return _selectedIndex; - } - + get => Selection.SelectedIndex != default ? Selection.SelectedIndex.GetAt(0) : -1; set { if (_updateCount == 0) { - var effective = (value >= 0 && value < ItemCount) ? value : -1; - UpdateSelectedItem(effective); + if (value != SelectedIndex) + { + Selection.SelectedIndex = new IndexPath(value); + } } else { @@ -167,16 +179,12 @@ namespace Avalonia.Controls.Primitives /// public object SelectedItem { - get - { - return _selectedItem; - } - + get => Selection.SelectedItem; set { if (_updateCount == 0) { - UpdateSelectedItem(IndexOf(Items, value)); + SelectedIndex = IndexOf(Items, value); } else { @@ -187,32 +195,110 @@ namespace Avalonia.Controls.Primitives } /// - /// Gets the selected items. + /// Gets or sets the selected items. /// protected IList SelectedItems { - get - { - if (_selectedItems == null) - { - _selectedItems = new AvaloniaList(); - SubscribeToSelectedItems(); - } - - return _selectedItems; - } + get => _selectedItems.GetOrCreateItems(); + set => _selectedItems.SetItems(value); + } + /// + /// Gets or sets a model holding the current selection. + /// + protected ISelectionModel Selection + { + get => _selection; set { - if (value?.IsFixedSize == true || value?.IsReadOnly == true) + value ??= new SelectionModel { - throw new NotSupportedException( - "Cannot use a fixed size or read-only collection as SelectedItems."); - } + 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; + MarkContainersUnselected(); + } + + _selection = value; + + if (oldSelection?.Count > 0) + { + RaiseEvent(new SelectionChangedEventArgs( + SelectionChangedEvent, + oldSelection, + 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; - UnsubscribeFromSelectedItems(); - _selectedItems = value ?? new AvaloniaList(); - SubscribeToSelectedItems(); + 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())); + } + } + } } } @@ -250,11 +336,17 @@ namespace Avalonia.Controls.Primitives base.EndInit(); } + /// + /// Scrolls the specified item into view. + /// + /// The index of the item. + public void ScrollIntoView(int index) => Presenter?.ScrollIntoView(index); + /// /// Scrolls the specified item into view. /// /// The item. - public void ScrollIntoView(object item) => Presenter?.ScrollIntoView(item); + public void ScrollIntoView(object item) => ScrollIntoView(IndexOf(Items, item)); /// /// Tries to get the container that was the source of an event. @@ -282,81 +374,18 @@ namespace Avalonia.Controls.Primitives /// protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) { - base.ItemsChanged(e); - if (_updateCount == 0) { - var newIndex = -1; - - if (SelectedIndex != -1) - { - newIndex = IndexOf((IEnumerable)e.NewValue, SelectedItem); - } - - if (AlwaysSelected && Items != null && Items.Cast().Any()) - { - newIndex = 0; - } - - SelectedIndex = newIndex; + Selection.Source = e.NewValue; } + + base.ItemsChanged(e); } /// protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { - if (_updateCount > 0) - { - base.ItemsCollectionChanged(sender, e); - return; - } - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - _selection.ItemsInserted(e.NewStartingIndex, e.NewItems.Count); - break; - case NotifyCollectionChangedAction.Remove: - _selection.ItemsRemoved(e.OldStartingIndex, e.OldItems.Count); - break; - } - base.ItemsCollectionChanged(sender, e); - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - if (AlwaysSelected && SelectedIndex == -1) - { - SelectedIndex = 0; - } - else - { - UpdateSelectedItem(_selection.First(), false); - } - - break; - - case NotifyCollectionChangedAction.Remove: - UpdateSelectedItem(_selection.First(), false); - ResetSelectedItems(); - break; - - case NotifyCollectionChangedAction.Replace: - UpdateSelectedItem(SelectedIndex, false); - ResetSelectedItems(); - break; - - case NotifyCollectionChangedAction.Move: - case NotifyCollectionChangedAction.Reset: - SelectedIndex = IndexOf(Items, SelectedItem); - - if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0) - { - SelectedIndex = 0; - } - break; - } } /// @@ -364,36 +393,18 @@ namespace Avalonia.Controls.Primitives { base.OnContainersMaterialized(e); - var resetSelectedItems = false; - foreach (var container in e.Containers) { if ((container.ContainerControl as ISelectable)?.IsSelected == true) { - if (SelectionMode.HasFlag(SelectionMode.Multiple)) - { - if (_selection.Add(container.Index)) - { - resetSelectedItems = true; - } - } - else - { - SelectedIndex = container.Index; - } - + Selection.Select(container.Index); MarkContainerSelected(container.ContainerControl, true); } - else if (_selection.Contains(container.Index)) + else if (Selection.IsSelected(container.Index) == true) { MarkContainerSelected(container.ContainerControl, true); } } - - if (resetSelectedItems) - { - ResetSelectedItems(); - } } /// @@ -422,7 +433,7 @@ namespace Avalonia.Controls.Primitives { if (i.ContainerControl != null && i.Item != null) { - bool selected = _selection.Contains(i.Index); + bool selected = Selection.IsSelected(i.Index) == true; MarkContainerSelected(i.ContainerControl, selected); } } @@ -444,6 +455,18 @@ namespace Avalonia.Controls.Primitives InternalEndInit(); } + protected override void OnPropertyChanged(AvaloniaProperty property, Optional oldValue, BindingValue newValue, BindingPriority priority) + { + base.OnPropertyChanged(property, oldValue, newValue, priority); + + if (property == SelectionModeProperty) + { + var mode = newValue.GetValueOrDefault(); + Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple); + Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected); + } + } + protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); @@ -458,7 +481,7 @@ namespace Avalonia.Controls.Primitives (((SelectionMode & SelectionMode.Multiple) != 0) || (SelectionMode & SelectionMode.Toggle) != 0)) { - SelectAll(); + Selection.SelectAll(); e.Handled = true; } } @@ -500,36 +523,6 @@ namespace Avalonia.Controls.Primitives return false; } - /// - /// Selects all items in the control. - /// - protected void SelectAll() - { - UpdateSelectedItems(() => - { - _selection.Clear(); - - for (var i = 0; i < ItemCount; ++i) - { - _selection.Add(i); - } - - UpdateSelectedItem(0, false); - - foreach (var container in ItemContainerGenerator.Containers) - { - MarkItemSelected(container.Index, true); - } - - ResetSelectedItems(); - }); - } - - /// - /// Deselects all items in the control. - /// - protected void UnselectAll() => UpdateSelectedItem(-1); - /// /// Updates the selection for an item based on user interaction. /// @@ -556,63 +549,35 @@ namespace Avalonia.Controls.Primitives if (rightButton) { - if (!_selection.Contains(index)) + if (Selection.IsSelected(index) == false) { - UpdateSelectedItem(index); + SelectedIndex = index; } } else if (range) { - UpdateSelectedItems(() => - { - var start = SelectedIndex != -1 ? SelectedIndex : 0; - var step = start < index ? 1 : -1; - - _selection.Clear(); - - for (var i = start; i != index; i += step) - { - _selection.Add(i); - } - - _selection.Add(index); + using var operation = Selection.Update(); + var anchor = Selection.AnchorIndex; - var first = Math.Min(start, index); - var last = Math.Max(start, index); - - foreach (var container in ItemContainerGenerator.Containers) - { - MarkItemSelected( - container.Index, - container.Index >= first && container.Index <= last); - } + if (anchor.GetSize() == 0) + { + anchor = new IndexPath(0); + } - ResetSelectedItems(); - }); + Selection.ClearSelection(); + Selection.AnchorIndex = anchor; + Selection.SelectRangeFromAnchor(index); } else if (multi && toggle) { - UpdateSelectedItems(() => + if (Selection.IsSelected(index) == true) { - if (!_selection.Contains(index)) - { - _selection.Add(index); - MarkItemSelected(index, true); - SelectedItems.Add(ElementAt(Items, index)); - } - else - { - _selection.Remove(index); - MarkItemSelected(index, false); - - if (index == _selectedIndex) - { - UpdateSelectedItem(_selection.First(), false); - } - - SelectedItems.Remove(ElementAt(Items, index)); - } - }); + Selection.Deselect(index); + } + else + { + Selection.Select(index); + } } else if (toggle) { @@ -620,7 +585,9 @@ namespace Avalonia.Controls.Primitives } else { - UpdateSelectedItem(index); + using var operation = Selection.Update(); + Selection.ClearSelection(); + Selection.Select(index); } if (Presenter?.Panel != null) @@ -693,25 +660,68 @@ namespace Avalonia.Controls.Primitives } /// - /// Gets a range of items from an IEnumerable. + /// Called when is raised. /// - /// The items. - /// The index of the first item. - /// The index of the last item. - /// The items. - private static List GetRange(IEnumerable items, int first, int last) + /// The sender. + /// The event args. + private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) { - var list = (items as IList) ?? items.Cast().ToList(); - var step = first > last ? -1 : 1; - var result = new List(); + if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem) + { + if (Selection.AnchorIndex.GetSize() > 0) + { + ScrollIntoView(Selection.AnchorIndex.GetAt(0)); + } + } + } + + /// + /// Called when is raised. + /// + /// The sender. + /// The event args. + private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + void Mark(int index, bool selected) + { + var container = ItemContainerGenerator.ContainerFromIndex(index); + + if (container != null) + { + MarkContainerSelected(container, selected); + } + } + + foreach (var i in e.SelectedIndices) + { + Mark(i.GetAt(0), true); + } + + foreach (var i in e.DeselectedIndices) + { + Mark(i.GetAt(0), false); + } + + var newSelectedIndex = SelectedIndex; + var newSelectedItem = SelectedItem; - for (int i = first; i != last; i += step) + if (newSelectedIndex != _selectedIndex) { - result.Add(list[i]); + RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, newSelectedIndex); + _selectedIndex = newSelectedIndex; } - result.Add(list[last]); - return result; + if (newSelectedItem != _selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem); + _selectedItem = newSelectedItem; + } + + var ev = new SelectionChangedEventArgs( + SelectionChangedEvent, + e.DeselectedItems.ToList(), + e.SelectedItems.ToList()); + RaiseEvent(ev); } /// @@ -791,301 +801,43 @@ namespace Avalonia.Controls.Primitives } } - /// - /// Sets an item container's 'selected' class or . - /// - /// The index of the item. - /// Whether the item should be selected or deselected. - private void MarkItemSelected(int index, bool selected) + private void MarkContainersUnselected() { - var container = ItemContainerGenerator?.ContainerFromIndex(index); - - if (container != null) + foreach (var container in ItemContainerGenerator.Containers) { - MarkContainerSelected(container, selected); + MarkContainerSelected(container.ContainerControl, false); } } - /// - /// 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) + private void UpdateContainerSelection() { - var index = IndexOf(Items, item); - - if (index != -1) + foreach (var container in ItemContainerGenerator.Containers) { - MarkItemSelected(index, selected); + MarkContainerSelected( + container.ContainerControl, + Selection.IsSelected(container.Index) != false); } - - return index; - } - - private void ResetSelectedItems() - { - UpdateSelectedItems(() => - { - SelectedItems.Clear(); - - foreach (var i in _selection) - { - SelectedItems.Add(ElementAt(Items, i)); - } - }); } /// - /// Called when the CollectionChanged event is raised. - /// - /// The event sender. - /// The event args. - private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) - { - if (_syncingSelectedItems) - { - return; - } - - void Add(IList newItems, IList addedItems = null) - { - foreach (var item in newItems) - { - var index = MarkItemSelected(item, true); - - if (index != -1 && _selection.Add(index) && addedItems != null) - { - addedItems.Add(item); - } - } - } - - void UpdateSelection() - { - if ((SelectedIndex != -1 && !_selection.Contains(SelectedIndex)) || - (SelectedIndex == -1 && _selection.HasItems)) - { - _selectedIndex = _selection.First(); - _selectedItem = ElementAt(Items, _selectedIndex); - RaisePropertyChanged(SelectedIndexProperty, -1, _selectedIndex, BindingPriority.LocalValue); - RaisePropertyChanged(SelectedItemProperty, null, _selectedItem, BindingPriority.LocalValue); - } - } - - IList added = null; - IList removed = null; - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - { - Add(e.NewItems); - UpdateSelection(); - added = e.NewItems; - } - - break; - - case NotifyCollectionChangedAction.Remove: - if (SelectedItems.Count == 0) - { - SelectedIndex = -1; - } - - foreach (var item in e.OldItems) - { - var index = MarkItemSelected(item, false); - _selection.Remove(index); - } - - removed = e.OldItems; - break; - - case NotifyCollectionChangedAction.Replace: - throw new NotSupportedException("Replacing items in a SelectedItems collection is not supported."); - - case NotifyCollectionChangedAction.Move: - throw new NotSupportedException("Moving items in a SelectedItems collection is not supported."); - - case NotifyCollectionChangedAction.Reset: - { - removed = new List(); - added = new List(); - - foreach (var index in _selection.ToList()) - { - var item = ElementAt(Items, index); - - if (!SelectedItems.Contains(item)) - { - MarkItemSelected(index, false); - removed.Add(item); - _selection.Remove(index); - } - } - - Add(SelectedItems, added); - UpdateSelection(); - } - - break; - } - - if (added?.Count > 0 || removed?.Count > 0) - { - var changed = new SelectionChangedEventArgs( - SelectionChangedEvent, - removed ?? Empty, - added ?? Empty); - RaiseEvent(changed); - } - } - - /// - /// Subscribes to the CollectionChanged event, if any. - /// - private void SubscribeToSelectedItems() - { - var incc = _selectedItems as INotifyCollectionChanged; - - if (incc != null) - { - incc.CollectionChanged += SelectedItemsCollectionChanged; - } - - SelectedItemsCollectionChanged( - _selectedItems, - new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - /// - /// Unsubscribes from the CollectionChanged event, if any. - /// - private void UnsubscribeFromSelectedItems() - { - var incc = _selectedItems as INotifyCollectionChanged; - - if (incc != null) - { - incc.CollectionChanged -= SelectedItemsCollectionChanged; - } - } - - /// - /// Updates the selection due to a change to or - /// . + /// Sets an item container's 'selected' class or . /// - /// The new selected index. - /// Whether to clear existing selection. - private void UpdateSelectedItem(int index, bool clear = true) + /// The index of the item. + /// Whether the item should be selected or deselected. + private void MarkItemSelected(int index, bool selected) { - var oldIndex = _selectedIndex; - var oldItem = _selectedItem; - - if (index == -1 && AlwaysSelected) - { - index = Math.Min(SelectedIndex, ItemCount - 1); - } - - var item = ElementAt(Items, index); - var itemChanged = !Equals(item, oldItem); - var added = -1; - HashSet removed = null; - - _selectedIndex = index; - _selectedItem = item; - - if (oldIndex != index || itemChanged || _selection.HasMultiple) - { - if (clear) - { - removed = _selection.Clear(); - } - - if (index != -1) - { - if (_selection.Add(index)) - { - added = index; - } - - if (removed?.Contains(index) == true) - { - removed.Remove(index); - added = -1; - } - } - - if (removed != null) - { - foreach (var i in removed) - { - MarkItemSelected(i, false); - } - } - - MarkItemSelected(index, true); - - RaisePropertyChanged( - SelectedIndexProperty, - oldIndex, - index); - } - - if (itemChanged) - { - RaisePropertyChanged( - SelectedItemProperty, - oldItem, - item); - } - - if (removed != null && index != -1) - { - removed.Remove(index); - } - - if (added != -1 || removed?.Count > 0) - { - ResetSelectedItems(); - - var e = new SelectionChangedEventArgs( - SelectionChangedEvent, - removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty(), - added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty()); - RaiseEvent(e); - } - - if (AutoScrollToSelectedItem && _selectedIndex != -1) - { - ScrollIntoView(_selectedItem); - } - } + var container = ItemContainerGenerator?.ContainerFromIndex(index); - private void UpdateSelectedItems(Action action) - { - try - { - _syncingSelectedItems = true; - action(); - } - catch (Exception ex) - { - Logger.TryGet(LogEventLevel.Error)?.Log( - LogArea.Property, - this, - "Error thrown updating SelectedItems: {Error}", - ex); - } - finally + if (container != null) { - _syncingSelectedItems = false; + MarkContainerSelected(container, selected); } } private void UpdateFinished() { + Selection.Source = Items; + if (_updateSelectedItem != null) { SelectedItem = _updateSelectedItem; @@ -1130,104 +882,5 @@ namespace Avalonia.Controls.Primitives UpdateFinished(); } } - - private class Selection : IEnumerable - { - private readonly List _list = new List(); - private HashSet _set = new HashSet(); - - public bool HasItems => _set.Count > 0; - public bool HasMultiple => _set.Count > 1; - - public bool Add(int index) - { - if (index == -1) - { - throw new ArgumentException("Invalid index", "index"); - } - - if (_set.Add(index)) - { - _list.Add(index); - return true; - } - - return false; - } - - public bool Remove(int index) - { - if (_set.Remove(index)) - { - _list.RemoveAll(x => x == index); - return true; - } - - return false; - } - - public HashSet Clear() - { - var result = _set; - _list.Clear(); - _set = new HashSet(); - return result; - } - - public void ItemsInserted(int index, int count) - { - _set = new HashSet(); - - for (var i = 0; i < _list.Count; ++i) - { - var ix = _list[i]; - - if (ix >= index) - { - var newIndex = ix + count; - _list[i] = newIndex; - _set.Add(newIndex); - } - else - { - _set.Add(ix); - } - } - } - - public void ItemsRemoved(int index, int count) - { - var last = (index + count) - 1; - - _set = new HashSet(); - - for (var i = 0; i < _list.Count; ++i) - { - var ix = _list[i]; - - if (ix >= index && ix <= last) - { - _list.RemoveAt(i--); - } - else if (ix > last) - { - var newIndex = ix - count; - _list[i] = newIndex; - _set.Add(newIndex); - } - else - { - _set.Add(ix); - } - } - } - - public bool Contains(int index) => _set.Contains(index); - - public int First() => HasItems ? _list[0] : -1; - - public IEnumerator GetEnumerator() => _set.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } } } diff --git a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs index 02ead7ef36..ecf8abc13f 100644 --- a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs +++ b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs @@ -96,6 +96,8 @@ namespace Avalonia.Controls /// the item. public object GetAt(int index) => _inner[index]; + public int IndexOf(object item) => _inner.IndexOf(item); + /// /// Retrieves the index of the item that has the specified unique identifier (key). /// diff --git a/src/Avalonia.Controls/SelectedItems.cs b/src/Avalonia.Controls/SelectedItems.cs new file mode 100644 index 0000000000..a3acb48765 --- /dev/null +++ b/src/Avalonia.Controls/SelectedItems.cs @@ -0,0 +1,49 @@ +// 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/SelectionModel.cs b/src/Avalonia.Controls/SelectionModel.cs new file mode 100644 index 0000000000..d930edc529 --- /dev/null +++ b/src/Avalonia.Controls/SelectionModel.cs @@ -0,0 +1,848 @@ +// 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 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) + { + if (_rootNode.Source != null) + { + using (var operation = new Operation(this)) + { + ClearSelection(resetAnchor: true); + } + } + } + + _rootNode.Source = value; + ApplyAutoSelect(); + + 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(); + } + } + } + + 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); + } + + anchor = new IndexPath(path); + } + + return anchor; + } + set + { + if (value != null) + { + SelectionTreeHelper.TraverseIndexPath( + _rootNode, + value, + realizeChildren: true, + (currentNode, path, depth, childIndex) => currentNode.AnchorIndex = path.GetAt(depth)); + } + else + { + _rootNode.AnchorIndex = -1; + } + + RaisePropertyChanged("AnchorIndex"); + } + } + + public IndexPath SelectedIndex + { + get + { + IndexPath selectedIndex = default; + var selectedIndices = SelectedIndices; + + if (selectedIndices?.Count > 0) + { + selectedIndex = selectedIndices[0]; + } + + return selectedIndex; + } + set + { + var isSelected = IsSelectedWithPartialAt(value); + + if (!IsSelectedAt(value) || SelectedItems.Count > 1) + { + using var operation = new Operation(this); + ClearSelection(resetAnchor: true); + SelectWithPathImpl(value, select: true); + ApplyAutoSelect(); + } + } + } + + 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); + ApplyAutoSelect(); + } + + public void Deselect(int groupIndex, int itemIndex) + { + using var operation = new Operation(this); + SelectWithGroupImpl(groupIndex, itemIndex, select: false); + ApplyAutoSelect(); + } + + public void DeselectAt(IndexPath index) + { + using var operation = new Operation(this); + SelectWithPathImpl(index, select: false); + ApplyAutoSelect(); + } + + public bool IsSelected(int index) => _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, realizeChild: false); + + 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, realizeChild: false); + + 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, realizeChild: false); + + 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); + ApplyAutoSelect(); + } + + 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(); + } + + internal IObservable? ResolvePath(object data, IndexPath dataIndexPath) + { + IObservable? resolved = null; + + // Raise ChildrenRequested event if there is a handler + if (ChildrenRequested != null) + { + if (_childrenRequestedEventArgs == null) + { + _childrenRequestedEventArgs = new SelectionModelChildrenRequestedEventArgs(data, dataIndexPath, false); + } + else + { + _childrenRequestedEventArgs.Initialize(data, dataIndexPath, 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, true); + } + + return resolved; + } + + private void ClearSelection(bool resetAnchor) + { + SelectionTreeHelper.Traverse( + _rootNode, + realizeChildren: false, + info => info.Node.Clear()); + + if (resetAnchor) + { + AnchorIndex = default; + } + } + + private void OnSelectionChanged(SelectionModelSelectionChangedEventArgs? e = null) + { + _selectedIndicesCached = null; + _selectedItemsCached = null; + + // Raise SelectionChanged event + 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); + } + } + + private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select) + { + if (_singleSelect) + { + ClearSelection(resetAnchor: true); + } + + var childNode = _rootNode.GetAt(groupIndex, realizeChild: true); + var selected = childNode!.Select(itemIndex, select); + + if (selected) + { + AnchorIndex = new IndexPath(groupIndex, itemIndex); + } + } + + 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; + } + } + + 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); + } + + 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, realizeChild: true)!; + int startIndex = groupIdx == startGroupIndex ? startItemIndex : 0; + int endIndex = groupIdx == endGroupIndex ? endItemIndex : groupNode.DataCount - 1; + groupNode.SelectRange(new IndexRange(startIndex, endIndex), select); + } + } + + 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 => + { + info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select); + }); + } + + private void BeginOperation() + { + if (_operationCount++ == 0) + { + _rootNode.BeginOperation(); + } + } + + private void EndOperation() + { + if (_operationCount == 0) + { + throw new AvaloniaInternalException("No selection operation in progress."); + } + + SelectionModelSelectionChangedEventArgs? e = null; + + if (--_operationCount == 0) + { + var changes = new List(); + _rootNode.EndOperation(changes); + + if (changes.Count > 0) + { + var changeSet = new SelectionModelChangeSet(changes); + e = changeSet.CreateEventArgs(); + } + } + + OnSelectionChanged(e); + _rootNode.Cleanup(); + } + + private void ApplyAutoSelect() + { + if (AutoSelect) + { + _selectedIndicesCached = null; + + if (SelectedIndex == default && _rootNode.ItemsSourceView?.Count > 0) + { + using var operation = new Operation(this); + SelectImpl(0, true); + } + } + } + + internal class SelectedItemInfo : ISelectedItemInfo + { + public SelectedItemInfo(SelectionNode node, IndexPath path) + { + 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 new file mode 100644 index 0000000000..6e77dc5755 --- /dev/null +++ b/src/Avalonia.Controls/SelectionModelChangeSet.cs @@ -0,0 +1,170 @@ +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?.GetAt(targetIndex); + 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 new file mode 100644 index 0000000000..974da0cf71 --- /dev/null +++ b/src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs @@ -0,0 +1,83 @@ +// 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 bool _throwOnAccess; + + internal SelectionModelChildrenRequestedEventArgs( + object source, + IndexPath sourceIndexPath, + bool throwOnAccess) + { + source = source ?? throw new ArgumentNullException(nameof(source)); + Initialize(source, sourceIndexPath, 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; + } + } + + internal void Initialize( + object? source, + IndexPath sourceIndexPath, + bool throwOnAccess) + { + if (!throwOnAccess && source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + _source = source; + _sourceIndexPath = sourceIndexPath; + _throwOnAccess = throwOnAccess; + } + } +} diff --git a/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs new file mode 100644 index 0000000000..5e2efdf331 --- /dev/null +++ b/src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs @@ -0,0 +1,47 @@ +// 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 new file mode 100644 index 0000000000..e25f88ff29 --- /dev/null +++ b/src/Avalonia.Controls/SelectionNode.cs @@ -0,0 +1,966 @@ +// 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; + +#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; + + PopulateSelectedItemsFromSelectedIndices(); + HookupCollectionChangedHandler(); + OnSelectionChanged(); + } + } + } + + 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) + { + 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); + } + + 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 => 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() + { + foreach (var child in _childrenNodes) + { + if (child != null && child != _manager.SharedLeafNode) + { + child.Dispose(); + } + } + + 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; + + // Remove the items from the selection for leaf + if (ItemsSourceView!.Count > 0) + { + bool 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; + } + } + else + { + // No more items in the list, clear + ClearSelection(); + RealizedChildrenNodeCount = 0; + selectionInvalidated = true; + } + + // Check if removing a node invalidated an ancestors + // selection state. For example if parent was partially selected before + // removing an item, it could be selected now. + if (!selectionInvalidated) + { + var parent = _parent; + + while (parent != null) + { + var isSelected = parent.IsSelectedWithPartial(); + // If a parent is partially selected, then it will become selected. + // If it is selected or not selected - there is no change. + if (!isSelected.HasValue) + { + selectionInvalidated = true; + break; + } + + parent = parent._parent; + } + } + + 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, realizeChild: false); + + 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 new file mode 100644 index 0000000000..9622a52f00 --- /dev/null +++ b/src/Avalonia.Controls/SelectionNodeOperation.cs @@ -0,0 +1,110 @@ +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 89d72566e2..da71078439 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -2,11 +2,12 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; -using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Utils; +using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; @@ -42,15 +43,29 @@ 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(); - private static readonly IList Empty = Array.Empty(); + /// + /// Defines the property. + /// + public static RoutedEvent SelectionChangedEvent = + SelectingItemsControl.SelectionChangedEvent; + private object _selectedItem; - private IList _selectedItems; + private ISelectionModel _selection; + private readonly SelectedItemsSync _selectedItems; /// /// Initializes static members of the class. @@ -60,6 +75,13 @@ 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. /// @@ -84,8 +106,6 @@ namespace Avalonia.Controls set => SetValue(AutoScrollToSelectedItemProperty, value); } - private bool _syncingSelectedItems; - /// /// Gets or sets the selection mode. /// @@ -95,61 +115,102 @@ namespace Avalonia.Controls set => SetValue(SelectionModeProperty, value); } + /// + /// Gets or sets the selected item. + /// /// /// Gets or sets the selected item. /// public object SelectedItem { - get => _selectedItem; - set - { - var selectedItems = SelectedItems; - - SetAndRaise(SelectedItemProperty, ref _selectedItem, value); + get => Selection.SelectedItem; + set => Selection.SelectedIndex = IndexFromItem(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 the selected items. + /// + protected IList SelectedItems + { + get => _selectedItems.GetOrCreateItems(); + set => _selectedItems.SetItems(value); } /// - /// Gets the selected items. + /// Gets or sets a model holding the current selection. /// - public IList SelectedItems + public ISelectionModel Selection { - get + get => _selection; + set { - if (_selectedItems == null) + value ??= new SelectionModel { - _selectedItems = new AvaloniaList(); - SubscribeToSelectedItems(); - } - - return _selectedItems; - } + SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), + AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected), + RetainSelectionOnReset = true, + }; - set - { - if (value?.IsFixedSize == true || value?.IsReadOnly == true) + if (_selection != value) { - throw new NotSupportedException( - "Cannot use a fixed size or read-only collection as SelectedItems."); - } + 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; - UnsubscribeFromSelectedItems(); - _selectedItems = value ?? new AvaloniaList(); - SubscribeToSelectedItems(); + if (_selection.SingleSelect) + { + SelectionMode &= ~SelectionMode.Multiple; + } + else + { + SelectionMode |= SelectionMode.Multiple; + } + + if (_selection.AutoSelect) + { + SelectionMode |= SelectionMode.AlwaysSelected; + } + else + { + SelectionMode &= ~SelectionMode.AlwaysSelected; + } + + UpdateContainerSelection(); + + var selectedItem = SelectedItem; + + if (_selectedItem != selectedItem) + { + RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem); + _selectedItem = selectedItem; + } + } + } } } @@ -182,186 +243,12 @@ 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() - { - SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items); - } + public void SelectAll() => Selection.SelectAll(); /// /// Deselects all items in the . /// - 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; - } - } + public void UnselectAll() => Selection.ClearSelection(); (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction) @@ -403,7 +290,7 @@ namespace Avalonia.Controls e.Handled = UpdateSelectionFromEventSource( e.Source, true, - (e.KeyModifiers & KeyModifiers.Shift) != 0); + (e.InputModifiers & InputModifiers.Shift) != 0); } } @@ -445,6 +332,72 @@ 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) + { + container.BringIntoView(); + } + } + } + + /// + /// 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 ItemsControl; + e.Children = container.GetObservable(ItemsProperty); + } + private TreeViewItem GetContainerInDirection( TreeViewItem from, NavigationDirection direction, @@ -498,6 +451,12 @@ namespace Avalonia.Controls return result; } + protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e) + { + Selection.Source = Items; + base.ItemsChanged(e); + } + /// protected override void OnPointerPressed(PointerPressedEventArgs e) { @@ -519,6 +478,18 @@ namespace Avalonia.Controls } } + protected override void OnPropertyChanged(AvaloniaProperty property, Optional oldValue, BindingValue newValue, BindingPriority priority) + { + base.OnPropertyChanged(property, oldValue, newValue, priority); + + if (property == SelectionModeProperty) + { + var mode = newValue.GetValueOrDefault(); + Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple); + Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected); + } + } + /// /// Updates the selection for an item based on user interaction. /// @@ -534,9 +505,9 @@ namespace Avalonia.Controls bool toggleModifier = false, bool rightButton = false) { - var item = ItemContainerGenerator.Index.ItemFromContainer(container); + var index = IndexFromContainer((TreeViewItem)container); - if (item == null) + if (index.GetSize() == 0) { return; } @@ -553,41 +524,48 @@ namespace Avalonia.Controls var multi = (mode & SelectionMode.Multiple) != 0; var range = multi && selectedContainer != null && rangeModifier; - if (rightButton) + if (!select) + { + Selection.DeselectAt(index); + } + else if (rightButton) { - if (!SelectedItems.Contains(item)) + if (!Selection.IsSelectedAt(index)) { - SelectSingleItem(item); + Selection.SelectedIndex = index; } } else if (!toggle && !range) { - SelectSingleItem(item); + Selection.SelectedIndex = index; } else if (multi && range) { - SynchronizeItems( - SelectedItems, - GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem)); + 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); } else { - var i = SelectedItems.IndexOf(item); - - if (i != -1) + if (Selection.IsSelectedAt(index)) { - SelectedItems.Remove(item); + Selection.DeselectAt(index); + } + else if (multi) + { + Selection.SelectAt(index); } else { - if (multi) - { - SelectedItems.Add(item); - } - else - { - SelectedItem = item; - } + Selection.SelectedIndex = index; } } } @@ -610,117 +588,6 @@ 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. @@ -826,26 +693,90 @@ namespace Avalonia.Controls } } - /// - /// 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) + private void MarkContainersUnselected() { - var list = items.Cast().ToList(); - var toRemove = list.Except(desired).ToList(); - var toAdd = desired.Except(list).ToList(); + 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) + { + var container = ItemContainerGenerator.Index.ContainerFromItem(item) as TreeViewItem; - foreach (var i in toRemove) + if (container != null) { - items.Remove(i); + return IndexFromContainer(container); } - foreach (var i in toAdd) + return default; + } + + private TreeViewItem ContainerFromIndex(IndexPath index) + { + TreeViewItem treeViewItem = null; + + for (var i = 0; i < index.GetSize(); ++i) { - items.Add(i); + var generator = treeViewItem?.ItemContainerGenerator ?? ItemContainerGenerator; + treeViewItem = generator.ContainerFromIndex(index.GetAt(i)) as TreeViewItem; + + if (treeViewItem == null) + { + return null; + } } + + return treeViewItem; } } } diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs new file mode 100644 index 0000000000..c127771990 --- /dev/null +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections; +using System.Collections.Specialized; +using System.Linq; +using Avalonia.Collections; + +#nullable enable + +namespace Avalonia.Controls.Utils +{ + /// + /// Synchronizes an with a list of SelectedItems. + /// + internal class SelectedItemsSync + { + private IList? _items; + private bool _updatingItems; + private bool _updatingModel; + + public SelectedItemsSync(ISelectionModel model) + { + model = model ?? throw new ArgumentNullException(nameof(model)); + Model = model; + } + + public ISelectionModel Model { get; private set; } + + public IList GetOrCreateItems() + { + if (_items == null) + { + var items = new AvaloniaList(Model.SelectedItems); + items.CollectionChanged += ItemsCollectionChanged; + Model.SelectionChanged += SelectionModelSelectionChanged; + _items = items; + } + + return _items; + } + + public void SetItems(IList? items) + { + items ??= new AvaloniaList(); + + if (items.IsFixedSize) + { + throw new NotSupportedException( + "Cannot assign fixed size selection to SelectedItems."); + } + + if (_items is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= ItemsCollectionChanged; + } + + if (_items == null) + { + Model.SelectionChanged += SelectionModelSelectionChanged; + } + + try + { + _updatingModel = true; + _items = items; + + using (Model.Update()) + { + Model.ClearSelection(); + Add(items); + } + + if (_items is INotifyCollectionChanged incc2) + { + incc2.CollectionChanged += ItemsCollectionChanged; + } + } + finally + { + _updatingModel = false; + } + } + + public void SetModel(ISelectionModel model) + { + model = model ?? throw new ArgumentNullException(nameof(model)); + + if (_items != null) + { + Model.SelectionChanged -= SelectionModelSelectionChanged; + Model = model; + Model.SelectionChanged += SelectionModelSelectionChanged; + + try + { + _updatingItems = true; + _items.Clear(); + + foreach (var i in model.SelectedItems) + { + _items.Add(i); + } + } + finally + { + _updatingItems = false; + } + } + } + + private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (_updatingItems) + { + return; + } + + if (_items == null) + { + throw new AvaloniaInternalException("CollectionChanged raised but we don't have items."); + } + + void Remove() + { + foreach (var i in e.OldItems) + { + var index = IndexOf(Model.Source, i); + + if (index != -1) + { + Model.Deselect(index); + } + } + } + + try + { + using var operation = Model.Update(); + + _updatingModel = true; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Add(e.NewItems); + break; + case NotifyCollectionChangedAction.Remove: + Remove(); + break; + case NotifyCollectionChangedAction.Replace: + Remove(); + Add(e.NewItems); + break; + case NotifyCollectionChangedAction.Reset: + Model.ClearSelection(); + Add(_items); + break; + } + } + finally + { + _updatingModel = false; + } + } + + private void Add(IList newItems) + { + foreach (var i in newItems) + { + var index = IndexOf(Model.Source, i); + + if (index != -1) + { + Model.Select(index); + } + } + } + + private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) + { + if (_updatingModel) + { + return; + } + + if (_items == null) + { + throw new AvaloniaInternalException("SelectionModelChanged raised but we don't have items."); + } + + try + { + var deselected = e.DeselectedItems.ToList(); + var selected = e.SelectedItems.ToList(); + + _updatingItems = true; + + foreach (var i in deselected) + { + _items.Remove(i); + } + + foreach (var i in selected) + { + _items.Add(i); + } + } + finally + { + _updatingItems = false; + } + } + + private static int IndexOf(object source, object item) + { + if (source is IList l) + { + return l.IndexOf(item); + } + else if (source is ItemsSourceView v) + { + return v.IndexOf(item); + } + + return -1; + } + } +} diff --git a/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs new file mode 100644 index 0000000000..430ecabbb8 --- /dev/null +++ b/src/Avalonia.Controls/Utils/SelectionTreeHelper.cs @@ -0,0 +1,189 @@ +// 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)!; + } + } + } + + 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); + 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, realizeChild: true); + 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, realizeChild: true); + 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.Dialogs/ManagedFileChooser.xaml.cs b/src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs index 616f260397..7f29407ed5 100644 --- a/src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs +++ b/src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs @@ -81,7 +81,7 @@ namespace Avalonia.Dialogs if (indexOfPreselected > 1) { - _filesView.ScrollIntoView(model.Items[indexOfPreselected - 1]); + _filesView.ScrollIntoView(indexOfPreselected - 1); } } } diff --git a/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs b/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs index fae08d37b7..b5004bc6a5 100644 --- a/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Xunit; -using Avalonia.Controls.UnitTests; +using Avalonia.Controls.UnitTests; using Avalonia.Platform; -using Avalonia.UnitTests; +using Xunit; [assembly: ExportAvaloniaModule("DefaultModule", typeof(AppBuilderTests.DefaultModule))] [assembly: ExportAvaloniaModule("RenderingModule", typeof(AppBuilderTests.Direct2DModule), ForRenderingSubsystem = "Direct2D1")] @@ -16,6 +10,7 @@ using Avalonia.UnitTests; namespace Avalonia.Controls.UnitTests { + using AppBuilder = Avalonia.UnitTests.AppBuilder; public class AppBuilderTests { diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj index 373b1bc82f..2ca93dcf56 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs index 994804e9e1..7ad0e480c6 100644 --- a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs @@ -9,6 +9,7 @@ using Avalonia.UnitTests; using Avalonia.VisualTree; using Moq; using Xunit; +using MouseButton = Avalonia.Input.MouseButton; namespace Avalonia.Controls.UnitTests { diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs index 32f3b06791..a292910fae 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs @@ -265,7 +265,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Selected_Item_Changes_To_NextAvailable_Item_If_SelectedItem_Is_Removed_From_Middle() + public void Selected_Item_Changes_To_First_Item_If_SelectedItem_Is_Removed_From_Middle() { var items = new ObservableCollection { @@ -288,8 +288,8 @@ namespace Avalonia.Controls.UnitTests items.RemoveAt(1); - Assert.Equal(1, target.SelectedIndex); - Assert.Equal("FooBar", target.SelectedItem); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Foo", target.SelectedItem); } private Control CreateTemplate(Carousel control, INameScope scope) diff --git a/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs b/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs new file mode 100644 index 0000000000..1e4aa0a2b8 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/IndexPathTests.cs @@ -0,0 +1,95 @@ +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 new file mode 100644 index 0000000000..e0f46d9fa9 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs @@ -0,0 +1,307 @@ +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 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/Presenters/ItemsPresenterTests_Virtualization_Simple.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs index 37630f9766..5a2cb60a56 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs @@ -573,7 +573,7 @@ namespace Avalonia.Controls.UnitTests.Presenters target.Arrange(Rect.Empty); // Check for issue #591: this should not throw. - target.ScrollIntoView(items[0]); + target.ScrollIntoView(0); } } @@ -727,7 +727,7 @@ namespace Avalonia.Controls.UnitTests.Presenters var last = (target.Items as IList)[10]; - target.ScrollIntoView(last); + target.ScrollIntoView(10); Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); Assert.Same(target.Panel.Children[9].DataContext, last); @@ -744,12 +744,12 @@ namespace Avalonia.Controls.UnitTests.Presenters var last = (target.Items as IList)[10]; - target.ScrollIntoView(last); + target.ScrollIntoView(10); Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); Assert.Same(target.Panel.Children[9].DataContext, last); - target.ScrollIntoView(last); + target.ScrollIntoView(10); Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); Assert.Same(target.Panel.Children[9].DataContext, last); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index cd1efd2fd4..04d0ac0a7a 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -536,37 +536,19 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(items[1], target.SelectedItem); Assert.Equal(1, target.SelectedIndex); - items.RemoveAt(1); - - Assert.Null(target.SelectedItem); - Assert.Equal(-1, target.SelectedIndex); - } - - [Fact] - public void Moving_Selected_Item_Should_Update_Selection() - { - var items = new AvaloniaList - { - new Item(), - new Item(), - }; - - var target = new SelectingItemsControl - { - Items = items, - Template = Template(), - }; + SelectionChangedEventArgs receivedArgs = null; - target.ApplyTemplate(); - target.SelectedIndex = 0; + target.SelectionChanged += (_, args) => receivedArgs = args; - Assert.Equal(items[0], target.SelectedItem); - Assert.Equal(0, target.SelectedIndex); + var removed = items[1]; - items.Move(0, 1); + items.RemoveAt(1); - Assert.Equal(items[1], target.SelectedItem); - Assert.Equal(1, target.SelectedIndex); + Assert.Null(target.SelectedItem); + Assert.Equal(-1, target.SelectedIndex); + Assert.NotNull(receivedArgs); + Assert.Empty(receivedArgs.AddedItems); + Assert.Equal(new[] { removed }, receivedArgs.RemovedItems); } [Fact] @@ -1089,8 +1071,8 @@ namespace Avalonia.Controls.UnitTests.Primitives items[1] = "Qux"; - Assert.Equal(1, target.SelectedIndex); - Assert.Equal("Qux", target.SelectedItem); + Assert.Equal(-1, target.SelectedIndex); + Assert.Null(target.SelectedItem); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs index a1a143750e..7b7e651cc9 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs @@ -75,8 +75,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedIndex = 2; items.RemoveAt(2); - Assert.Equal(2, target.SelectedIndex); - Assert.Equal("qux", target.SelectedItem); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("foo", target.SelectedItem); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index cd73e906c3..817c5183ca 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -70,8 +70,6 @@ namespace Avalonia.Controls.UnitTests.Primitives [Fact] public void Assigning_Multiple_SelectedItems_Should_Set_SelectedIndex() { - // Note that we don't need SelectionMode = Multiple here. Multiple selections can always - // be made in code. var target = new TestSelector { Items = new[] { "foo", "bar", "baz" }, @@ -337,7 +335,6 @@ namespace Avalonia.Controls.UnitTests.Primitives "qiz", "lol", }, - SelectionMode = SelectionMode.Multiple, Template = Template(), }; @@ -370,7 +367,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectedIndex = 3; target.SelectRange(1); - Assert.Equal(new[] { "qux", "baz", "bar" }, target.SelectedItems.Cast().ToList()); + Assert.Equal(new[] { "bar", "baz", "qux" }, target.SelectedItems.Cast().ToList()); } [Fact] @@ -680,6 +677,57 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); } + [Fact] + public void Ctrl_Selecting_Raises_SelectionChanged_Events() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Qux" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + SelectionChangedEventArgs receivedArgs = null; + + target.SelectionChanged += (_, args) => receivedArgs = args; + + void VerifyAdded(string selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(new[] { selection }, receivedArgs.AddedItems); + Assert.Empty(receivedArgs.RemovedItems); + } + + void VerifyRemoved(string selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(new[] { selection }, receivedArgs.RemovedItems); + Assert.Empty(receivedArgs.AddedItems); + } + + _helper.Click((Interactive)target.Presenter.Panel.Children[1]); + + VerifyAdded("Bar"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control); + + VerifyAdded("Baz"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control); + + VerifyAdded("Qux"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control); + + VerifyRemoved("Bar"); + } + [Fact] public void Ctrl_Selecting_SelectedItem_With_Multiple_Selection_Active_Sets_SelectedItem_To_Next_Selection() { @@ -794,6 +842,52 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target)); } + [Fact] + public void Shift_Selecting_Raises_SelectionChanged_Events() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Qux" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + SelectionChangedEventArgs receivedArgs = null; + + target.SelectionChanged += (_, args) => receivedArgs = args; + + void VerifyAdded(params string[] selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(selection, receivedArgs.AddedItems); + Assert.Empty(receivedArgs.RemovedItems); + } + + void VerifyRemoved(string selection) + { + Assert.NotNull(receivedArgs); + Assert.Equal(new[] { selection }, receivedArgs.RemovedItems); + Assert.Empty(receivedArgs.AddedItems); + } + + _helper.Click((Interactive)target.Presenter.Panel.Children[1]); + + VerifyAdded("Bar"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Shift); + + VerifyAdded("Baz" ,"Qux"); + + receivedArgs = null; + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Shift); + + VerifyRemoved("Qux"); + } + [Fact] public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order() { @@ -842,6 +936,30 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal("Foo", target.SelectedItem); } + [Fact] + public void SelectAll_Raises_SelectionChanged_Event() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + SelectionChangedEventArgs receivedArgs = null; + + target.SelectionChanged += (_, args) => receivedArgs = args; + + target.SelectAll(); + + Assert.NotNull(receivedArgs); + Assert.Equal(target.Items, receivedArgs.AddedItems); + Assert.Empty(receivedArgs.RemovedItems); + } + [Fact] public void UnselectAll_Clears_SelectedIndex_And_SelectedItem() { @@ -993,7 +1111,7 @@ namespace Avalonia.Controls.UnitTests.Primitives target.SelectAll(); items[1] = "Qux"; - Assert.Equal(new[] { "Foo", "Qux", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { "Foo", "Baz" }, target.SelectedItems); } [Fact] @@ -1131,6 +1249,195 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(1, target.SelectedItems.Count); } + [Fact] + public void Adding_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Selection.Select(1); + + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Assigning_Null_To_Selection_Should_Create_New_SelectionModel() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var oldSelection = target.Selection; + + target.Selection = null; + + Assert.NotNull(target.Selection); + Assert.NotSame(oldSelection, target.Selection); + } + + [Fact] + public void Assigning_SelectionModel_With_Different_Source_To_Selection_Should_Fail() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var selection = new SelectionModel { Source = new[] { "baz" } }; + Assert.Throws(() => target.Selection = selection); + } + + [Fact] + public void Assigning_SelectionModel_With_Null_Source_To_Selection_Should_Set_Source() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + var selection = new SelectionModel(); + target.Selection = selection; + + Assert.Same(target.Items, selection.Source); + } + + [Fact] + public void Assigning_Single_Selected_Item_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = target.Items }; + selection.Select(1); + target.Selection = selection; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { "bar" }, target.Selection.SelectedItems); + Assert.Equal(new[] { 1 }, SelectedContainers(target)); + } + + [Fact] + public void Assigning_Multiple_Selected_Items_To_Selection_Should_Set_SelectedIndex() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar", "baz" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = target.Items }; + selection.SelectRange(new IndexPath(0), new IndexPath(2)); + target.Selection = selection; + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { "foo", "bar", "baz" }, target.Selection.SelectedItems); + Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); + } + + [Fact] + public void Reassigning_Selection_Should_Clear_Selection() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Selection.Select(1); + target.Selection = new SelectionModel(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Null(target.SelectedItem); + } + + [Fact] + public void Assigning_Selection_Should_Set_Item_IsSelected() + { + var items = new[] + { + new ListBoxItem(), + new ListBoxItem(), + new ListBoxItem(), + }; + + var target = new TestSelector + { + Items = items, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var selection = new SelectionModel { Source = items }; + selection.SelectRange(new IndexPath(0), new IndexPath(1)); + target.Selection = selection; + + Assert.True(items[0].IsSelected); + Assert.True(items[1].IsSelected); + Assert.False(items[2].IsSelected); + } + + [Fact] + public void Assigning_Selection_Should_Raise_SelectionChanged() + { + var items = new[] { "foo", "bar", "baz" }; + + var target = new TestSelector + { + Items = items, + Template = Template(), + SelectedItem = "bar", + }; + + var raised = 0; + + target.SelectionChanged += (s, e) => + { + if (raised == 0) + { + Assert.Empty(e.AddedItems.Cast()); + Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast()); + } + else + { + Assert.Equal(new[] { "foo", "baz" }, e.AddedItems.Cast()); + Assert.Empty(e.RemovedItems.Cast()); + } + + ++raised; + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + + var selection = new SelectionModel { Source = items }; + selection.Select(0); + selection.Select(2); + target.Selection = selection; + + Assert.Equal(2, raised); + } + private IEnumerable SelectedContainers(SelectingItemsControl target) { return target.Presenter.Panel.Children @@ -1154,20 +1461,31 @@ namespace Avalonia.Controls.UnitTests.Primitives public static readonly new AvaloniaProperty SelectedItemsProperty = SelectingItemsControl.SelectedItemsProperty; + public TestSelector() + { + SelectionMode = SelectionMode.Multiple; + } + public new IList SelectedItems { get { return base.SelectedItems; } set { base.SelectedItems = value; } } + public new ISelectionModel Selection + { + get => base.Selection; + set => base.Selection = value; + } + public new SelectionMode SelectionMode { get { return base.SelectionMode; } set { base.SelectionMode = value; } } - public new void SelectAll() => base.SelectAll(); - public new void UnselectAll() => base.UnselectAll(); + public void SelectAll() => Selection.SelectAll(); + public void UnselectAll() => Selection.ClearSelection(); public void SelectRange(int index) => UpdateSelection(index, true, true); public void Toggle(int index) => UpdateSelection(index, true, false, true); } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs index a8b16a7ebf..b1e18dc587 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TabStripTests.cs @@ -67,7 +67,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void Removing_Selected_Should_Select_Next() + public void Removing_Selected_Should_Select_First() { var items = new ObservableCollection() { @@ -96,10 +96,9 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Same(items[1], target.SelectedItem); items.RemoveAt(1); - // Assert for former element [2] now [1] == "3rd" - Assert.Equal(1, target.SelectedIndex); - Assert.Same(items[1], target.SelectedItem); - Assert.Same("3rd", ((TabItem)target.SelectedItem).Name); + Assert.Equal(0, target.SelectedIndex); + Assert.Same(items[0], target.SelectedItem); + Assert.Same("first", ((TabItem)target.SelectedItem).Name); } private Control CreateTabStripTemplate(TabStrip parent, INameScope scope) diff --git a/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs new file mode 100644 index 0000000000..c4a682cc54 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/SelectionModelTests.cs @@ -0,0 +1,2384 @@ +// 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, new List() { Path(4) }); + Select(selectionModel, 4, false); + ValidateSelection(selectionModel, new List() { }); + } + + [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, new List() { Path(3) }, new List() { Path() }); + Select(selectionModel, 3, false); + ValidateSelection(selectionModel, new List() { }); + } + + [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, new List() { Path(4) }, new List() { Path() }); + }; + + Select(selectionModel, 4, true); + ValidateSelection(selectionModel, new List() { Path(4) }, new List() { Path() }); + 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, new List() { Path(4) }, new List() { Path() }); + SelectRangeFromAnchor(selectionModel, 8, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(4), + Path(5), + Path(6), + Path(7), + Path(8) + }, + new List() { Path() }); + + ClearSelection(selectionModel); + SetAnchorIndex(selectionModel, 6); + SelectRangeFromAnchor(selectionModel, 3, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4), + Path(5), + Path(6) + }, + new List() { Path() }); + + SetAnchorIndex(selectionModel, 4); + SelectRangeFromAnchor(selectionModel, 5, false /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(6) + }, + new List() { Path() }); + } + + [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, + new List() { Path(1, 1) }, new List() { Path(), Path(1) }); + Select(selectionModel, 1, 1, false); + ValidateSelection(selectionModel, new List() { }); + } + + [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, new List() { Path(1, 2) }, new List() { Path(), Path(1) }); + SelectRangeFromAnchor(selectionModel, 2, 2, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(1, 2), + Path(2), // Inner node should be selected since everything 2.* is selected + Path(2, 0), + Path(2, 1), + Path(2, 2) + }, + new List() + { + Path(), + Path(1) + }, + 1 /* selectedInnerNodes */); + + ClearSelection(selectionModel); + SetAnchorIndex(selectionModel, 2, 1); + SelectRangeFromAnchor(selectionModel, 0, 1, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2), + Path(1, 0), + Path(1, 1), + Path(1, 2), + Path(1), + Path(2, 0), + Path(2, 1) + }, + new List() + { + Path(), + Path(0), + Path(2), + }, + 1 /* selectedInnerNodes */); + + SetAnchorIndex(selectionModel, 1, 1); + SelectRangeFromAnchor(selectionModel, 2, 0, false /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2), + Path(1, 0), + Path(2, 1) + }, + new List() + { + Path(), + Path(1), + Path(0), + Path(2), + }, + 0 /* selectedInnerNodes */); + + ClearSelection(selectionModel); + ValidateSelection(selectionModel, new List() { }); + } + + [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, + new List() { path }, + new List() + { + Path(), + Path(1), + Path(1, 0), + Path(1, 0, 1), + }); + Select(selectionModel, Path(0, 0, 1, 0), true); + ValidateSelection(selectionModel, + new List() + { + Path(0, 0, 1, 0) + }, + new List() + { + Path(), + Path(0), + Path(0, 0), + Path(0, 0, 1) + }); + Select(selectionModel, Path(0, 0, 1, 0), false); + ValidateSelection(selectionModel, new List() { }); + } + + [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, + new List() { startPath }, + new List() + { + Path(), + Path(1), + Path(1, 0), + Path(1, 0, 1) + }); + + 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, + new List() + { + Path(1, 0), + Path(1, 1), + Path(1, 0, 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), + }, + new List() + { + Path(), + Path(1), + Path(1, 0), + Path(1, 1), + Path(1, 1, 1), + }); + + ClearSelection(selectionModel); + ValidateSelection(selectionModel, new List() { }); + + startPath = Path(0, 1, 0, 2); + SetAnchorIndex(selectionModel, startPath); + endPath = Path(0, 0, 0, 2); + SelectRangeFromAnchor(selectionModel, endPath, true /* select */); + ValidateSelection(selectionModel, + new List() + { + Path(0, 0), + Path(0, 1), + Path(0, 0, 0), + 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), + }, + new List() + { + Path(), + Path(0), + Path(0, 0), + Path(0, 0, 0), + Path(0, 1), + Path(0, 1, 0), + }); + + startPath = Path(0, 1, 0, 2); + SetAnchorIndex(selectionModel, startPath); + endPath = Path(0, 0, 0, 2); + SelectRangeFromAnchor(selectionModel, endPath, false /* select */); + ValidateSelection(selectionModel, new List() { }); + } + + [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, + new List() + { + Path(3), + Path(4), + Path(5), + }, + new List() + { + Path() + }); + + _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, + new List() + { + Path(3), + Path(7), + Path(8), + }, + new List() + { + Path() + }); + + _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, + new List() + { + Path(6), + Path(10), + Path(11), + }, + new List() + { + Path() + }); + + _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, + new List() + { + Path(6), + Path(10), + Path(11), + }, + new List() + { + Path() + }); + } + + [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, + new List() + { + Path(1, 1), + }, + new List() + { + Path(), + Path(1), + }); + + _output.WriteLine("Insert before selected range: Inserting item at group index 0"); + data.Insert(0, 100); + ValidateSelection(selectionModel, + new List() + { + Path(2, 1) + }, + new List() + { + Path(), + Path(2), + }); + + _output.WriteLine("Insert after selected range: Inserting item at group index 3"); + data.Insert(3, 1000); + ValidateSelection(selectionModel, + new List() + { + Path(2, 1) + }, + new List() + { + Path(), + Path(2), + }); + } + + [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, + new List() + { + Path(6), + Path(7), + Path(8) + }, + new List() + { + Path() + }); + + _output.WriteLine("Remove before selected range: Removing item at index 0"); + data.RemoveAt(0); + ValidateSelection(selectionModel, + new List() + { + Path(5), + Path(6), + Path(7) + }, + new List() + { + Path() + }); + + _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, + new List() + { + Path(3), + Path(4) + }, + new List() + { + Path() + }); + + _output.WriteLine("Remove after selected range: Removing item at index 5"); + data.RemoveAt(5); + ValidateSelection(selectionModel, + new List() + { + Path(3), + Path(4) + }, + new List() + { + Path() + }); + } + + [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, + new List() + { + Path(1, 1), + Path(1, 2) + }, + new List() + { + Path(), + Path(1), + }); + + _output.WriteLine("Remove before selected range: Removing item at group index 0"); + data.RemoveAt(0); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2) + }, + new List() + { + Path(), + Path(0), + }); + + _output.WriteLine("Remove after selected range: Removing item at group index 1"); + data.RemoveAt(1); + ValidateSelection(selectionModel, + new List() + { + Path(0, 1), + Path(0, 2) + }, + new List() + { + Path(), + Path(0), + }); + + _output.WriteLine("Remove group containing selected items"); + data.RemoveAt(0); + ValidateSelection(selectionModel, new List()); + } + + [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, + new List() + { + Path(3), + Path(4), + Path(5), + }, + new List() + { + Path() + }); + + data[3] = 300; + data[4] = 400; + ValidateSelection(selectionModel, + new List() + { + Path(5), + }, + new List() + { + Path() + }); + } + + [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, + new List() + { + Path(1, 1) + }, + new List() + { + Path(), + Path(1) + }); + + data[1] = new ObservableCollection(Enumerable.Range(0, 5)); + ValidateSelection(selectionModel, new List()); + } + + [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, + new List() + { + Path(3), + Path(4), + Path(5), + }, + new List() + { + Path() + }); + + data.Clear(); + ValidateSelection(selectionModel, new List()); + } + + [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, + new List() + { + Path(1, 1) + }, + new List() + { + Path(), + Path(1) + }); + + (data[1] as IList).Clear(); + ValidateSelection(selectionModel, new List()); + } + + // 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, + new List() + { + Path(1, 0), + Path(1, 1), + Path(1, 2), + Path(1) + }, + new List() + { + Path(), + }, + 1 /* selectedInnerNodes */); + + _output.WriteLine("Inserting 1.0"); + selectionChangedRaised = false; + (data[1] as AvaloniaList).Insert(0, 100); + Assert.True(selectionChangedRaised, "SelectionChanged event was not raised"); + ValidateSelection(selectionModel, + new List() + { + Path(1, 1), + Path(1, 2), + Path(1, 3), + }, + new List() + { + Path(), + Path(1), + }); + + _output.WriteLine("Removing 1.0"); + selectionChangedRaised = false; + (data[1] as AvaloniaList).RemoveAt(0); + Assert.True(selectionChangedRaised, "SelectionChanged event was not raised"); + ValidateSelection(selectionModel, + new List() + { + Path(1, 0), + Path(1, 1), + Path(1, 2), + Path(1) + }, + new List() + { + Path(), + }, + 1 /* selectedInnerNodes */); + } + + [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, + new List() + { + Path(0), + Path(1), + Path(0, 0), + Path(0, 1), + Path(0, 2), + Path(1, 0), + Path(1, 1) + }, + new List() + { + Path(), + Path(1) + }); + } + + [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 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 Can_Replace_Children_Collection() + { + 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("Child 9", ((Node)target.SelectedItem).Header); + + root.ReplaceChildren(); + + Assert.Null(target.SelectedItem); + } + + [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); + } + + 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))); + } + } + + 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, + List expectedSelected, + List expectedPartialSelected = null, + int selectedInnerNodes = 0) + { + _output.WriteLine("Validating Selection..."); + + _output.WriteLine("Selection contains indices:"); + foreach (var index in selectionModel.SelectedIndices) + { + _output.WriteLine(" " + index.ToString()); + } + + _output.WriteLine("Selection contains items:"); + foreach (var item in selectionModel.SelectedItems) + { + _output.WriteLine(" " + item.ToString()); + } + + if (selectionModel.Source != null) + { + List allIndices = GetIndexPathsInSource(selectionModel.Source); + foreach (var index in allIndices) + { + bool? isSelected = selectionModel.IsSelectedWithPartialAt(index); + if (Contains(expectedSelected, index) && !Contains(expectedPartialSelected, index)) + { + Assert.True(isSelected.Value, index + " is Selected"); + } + else if (expectedPartialSelected != null && Contains(expectedPartialSelected, index)) + { + Assert.True(isSelected == null, index + " is partially Selected"); + } + else + { + if (isSelected == null) + { + _output.WriteLine("*************" + index + " is null"); + Assert.True(false, "Expected false but got null");; + } + else + { + Assert.False(isSelected.Value, index + " is not Selected"); + } + } + } + } + else + { + foreach (var index in expectedSelected) + { + Assert.True(selectionModel.IsSelectedWithPartialAt(index), index + " is Selected"); + } + } + if (expectedSelected.Count > 0) + { + _output.WriteLine("SelectedIndex is " + selectionModel.SelectedIndex); + Assert.Equal(expectedSelected[0], selectionModel.SelectedIndex); + if (selectionModel.Source != null) + { + Assert.Equal(selectionModel.SelectedItem, GetData(selectionModel, expectedSelected[0])); + } + + int itemsCount = selectionModel.SelectedItems.Count(); + Assert.Equal(selectionModel.Source != null ? expectedSelected.Count - selectedInnerNodes : 0, itemsCount); + int indicesCount = selectionModel.SelectedIndices.Count(); + Assert.Equal(expectedSelected.Count - selectedInnerNodes, indicesCount); + } + + _output.WriteLine("Validating Selection... done"); + } + + 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)); + } + } + } + + class CustomSelectionModel : SelectionModel + { + public int IntProperty + { + get { return _intProperty; } + set + { + _intProperty = value; + OnPropertyChanged("IntProperty"); + } + } + + private int _intProperty; + } +} diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index ece005d7b8..24aacd4000 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -92,7 +92,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Removal_Should_Set_Next_Tab() + public void Removal_Should_Set_First_Tab() { var collection = new ObservableCollection() { @@ -123,11 +123,9 @@ namespace Avalonia.Controls.UnitTests target.SelectedItem = collection[1]; collection.RemoveAt(1); - // compare with former [2] now [1] == "3rd" - Assert.Same(collection[1], target.SelectedItem); + Assert.Same(collection[0], target.SelectedItem); } - [Fact] public void TabItem_Templates_Should_Be_Set_Before_TabItem_ApplyTemplate() { diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index bd303a81cd..740ff4c492 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -7,7 +7,6 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Data.Core; -using Avalonia.Diagnostics; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; @@ -241,12 +240,12 @@ namespace Avalonia.Controls.UnitTests ClickContainer(item2Container, InputModifiers.Control); Assert.True(item2Container.IsSelected); - Assert.Equal(new[] {item1, item2}, target.SelectedItems.OfType()); + Assert.Equal(new[] {item1, item2}, target.Selection.SelectedItems.OfType()); ClickContainer(item1Container, InputModifiers.Control); Assert.False(item1Container.IsSelected); - Assert.DoesNotContain(item1, target.SelectedItems.OfType()); + Assert.DoesNotContain(item1, target.Selection.SelectedItems.OfType()); } [Fact] @@ -746,11 +745,11 @@ namespace Avalonia.Controls.UnitTests target.SelectAll(); AssertChildrenSelected(target, tree[0]); - Assert.Equal(5, target.SelectedItems.Count); + Assert.Equal(5, target.Selection.SelectedItems.Count); _mouse.Click((Interactive)target.Presenter.Panel.Children[0], MouseButton.Right); - Assert.Equal(5, target.SelectedItems.Count); + Assert.Equal(5, target.Selection.SelectedItems.Count); } [Fact] @@ -782,11 +781,11 @@ namespace Avalonia.Controls.UnitTests ClickContainer(fromContainer, InputModifiers.None); ClickContainer(toContainer, InputModifiers.Shift); - Assert.Equal(2, target.SelectedItems.Count); + Assert.Equal(2, target.Selection.SelectedItems.Count); _mouse.Click(thenContainer, MouseButton.Right); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] @@ -816,7 +815,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Shift); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] @@ -846,7 +845,7 @@ namespace Avalonia.Controls.UnitTests _mouse.Click(fromContainer); _mouse.Click(toContainer, MouseButton.Right, modifiers: InputModifiers.Control); - Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(1, target.Selection.SelectedItems.Count); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs new file mode 100644 index 0000000000..3ab5950974 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Avalonia.Collections; +using Avalonia.Controls.Utils; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Utils +{ + public class SelectedItemsSyncTests + { + [Fact] + public void Initial_Items_Are_From_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + Assert.Equal(new[] { "bar", "baz" }, items); + } + + [Fact] + public void Selecting_On_Model_Adds_Item() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + target.Model.Select(0); + + Assert.Equal(new[] { "bar", "baz", "foo" }, items); + } + + [Fact] + public void Selecting_Duplicate_On_Model_Adds_Item() + { + var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + var items = target.GetOrCreateItems(); + + target.Model.Select(4); + + Assert.Equal(new[] { "bar", "baz", "bar" }, items); + } + + [Fact] + public void Deselecting_On_Model_Removes_Item() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + target.Model.Deselect(1); + + Assert.Equal(new[] { "baz" }, items); + } + + [Fact] + public void Deselecting_Duplicate_On_Model_Removes_Item() + { + var target = CreateTarget(new[] { "foo", "bar", "baz", "foo", "bar", "baz" }); + var items = target.GetOrCreateItems(); + + target.Model.Select(4); + target.Model.Deselect(4); + + Assert.Equal(new[] { "baz", "bar" }, items); + } + + [Fact] + public void Reassigning_Model_Resets_Items() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + var newModel = new SelectionModel { Source = target.Model.Source }; + newModel.Select(0); + newModel.Select(1); + + target.SetModel(newModel); + + Assert.Equal(new[] { "foo", "bar" }, items); + } + + [Fact] + public void Reassigning_Model_Tracks_New_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + var newModel = new SelectionModel { Source = target.Model.Source }; + target.SetModel(newModel); + + newModel.Select(0); + newModel.Select(1); + + Assert.Equal(new[] { "foo", "bar" }, items); + } + + [Fact] + public void Adding_To_Items_Selects_On_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Add("foo"); + + Assert.Equal( + new[] { new IndexPath(0), new IndexPath(1), new IndexPath(2) }, + target.Model.SelectedIndices); + Assert.Equal(new[] { "bar", "baz", "foo" }, items); + } + + [Fact] + public void Removing_From_Items_Deselects_On_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Remove("baz"); + + Assert.Equal(new[] { new IndexPath(1) }, target.Model.SelectedIndices); + Assert.Equal(new[] { "bar" }, items); + } + + [Fact] + public void Replacing_Item_Updates_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items[0] = "foo"; + + Assert.Equal(new[] { new IndexPath(0), new IndexPath(2) }, target.Model.SelectedIndices); + Assert.Equal(new[] { "foo", "baz" }, items); + } + + [Fact] + public void Clearing_Items_Updates_Model() + { + var target = CreateTarget(); + var items = target.GetOrCreateItems(); + + items.Clear(); + + Assert.Empty(target.Model.SelectedIndices); + } + + [Fact] + public void Setting_Items_Updates_Model() + { + var target = CreateTarget(); + var oldItems = target.GetOrCreateItems(); + + var newItems = new AvaloniaList { "foo", "baz" }; + target.SetItems(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[] { "foo", "baz" }, newItems); + } + + [Fact] + public void Setting_Items_Subscribes_To_Model() + { + var target = CreateTarget(); + var items = new AvaloniaList { "foo", "baz" }; + + target.SetItems(items); + target.Model.Select(1); + + Assert.Equal(new[] { "foo", "baz", "bar" }, items); + } + + [Fact] + public void Setting_Items_To_Null_Creates_Empty_Items() + { + var target = CreateTarget(); + var oldItems = target.GetOrCreateItems(); + + target.SetItems(null); + + var newItems = Assert.IsType>(target.GetOrCreateItems()); + + Assert.NotSame(oldItems, newItems); + } + + [Fact] + public void Handles_Null_Model_Source() + { + var model = new SelectionModel(); + model.Select(1); + + var target = new SelectedItemsSync(model); + var items = target.GetOrCreateItems(); + + Assert.Empty(items); + + model.Select(2); + model.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(new[] { "bar", "baz" }, items); + } + + [Fact] + public void Does_Not_Accept_Fixed_Size_Items() + { + var target = CreateTarget(); + + Assert.Throws(() => + target.SetItems(new[] { "foo", "bar", "baz" })); + } + + private static SelectedItemsSync CreateTarget( + IEnumerable items = null) + { + items ??= new[] { "foo", "bar", "baz" }; + + var model = new SelectionModel { Source = items }; + model.SelectRange(new IndexPath(1), new IndexPath(2)); + + var target = new SelectedItemsSync(model); + return target; + } + } +}