From e62bacab7eb2fdfd3637391ac0d61d3b4a58a56f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 20 Aug 2020 13:32:45 +0200 Subject: [PATCH] Reimplemented SelectionModel. Handles only list selections, not nested selections. --- .../Avalonia.Controls.csproj | 3 + .../Repeater/ItemsSourceView.cs | 89 +- .../Selection/ISelectionModel.cs | 48 + src/Avalonia.Controls/Selection/IndexRange.cs | 343 ++++ .../Selection/SelectedIndexes.cs | 82 + .../Selection/SelectedItems.cs | 121 ++ .../Selection/SelectionModel.cs | 632 +++++++ .../SelectionModelIndexesChangedEventArgs.cs | 18 + ...SelectionModelSelectionChangedEventArgs.cs | 85 + .../Selection/SelectionNodeBase.cs | 286 ++++ .../Utils/CollectionChangedEventManager.cs | 135 ++ .../Selection/SelectionModelTests_Multiple.cs | 1474 +++++++++++++++++ .../Selection/SelectionModelTests_Single.cs | 1021 ++++++++++++ 13 files changed, 4324 insertions(+), 13 deletions(-) create mode 100644 src/Avalonia.Controls/Selection/ISelectionModel.cs create mode 100644 src/Avalonia.Controls/Selection/IndexRange.cs create mode 100644 src/Avalonia.Controls/Selection/SelectedIndexes.cs create mode 100644 src/Avalonia.Controls/Selection/SelectedItems.cs create mode 100644 src/Avalonia.Controls/Selection/SelectionModel.cs create mode 100644 src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs create mode 100644 src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs create mode 100644 src/Avalonia.Controls/Selection/SelectionNodeBase.cs create mode 100644 src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs diff --git a/src/Avalonia.Controls/Avalonia.Controls.csproj b/src/Avalonia.Controls/Avalonia.Controls.csproj index 480dcfcb85..7f1f4bc8f3 100644 --- a/src/Avalonia.Controls/Avalonia.Controls.csproj +++ b/src/Avalonia.Controls/Avalonia.Controls.csproj @@ -2,6 +2,9 @@ netstandard2.0 + + + diff --git a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs index def9301e2d..e84d97784a 100644 --- a/src/Avalonia.Controls/Repeater/ItemsSourceView.cs +++ b/src/Avalonia.Controls/Repeater/ItemsSourceView.cs @@ -8,6 +8,9 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using Avalonia.Controls.Utils; + +#nullable enable namespace Avalonia.Controls { @@ -21,30 +24,40 @@ namespace Avalonia.Controls /// view of the Items. That way, each component does not need to know if the source is an /// IEnumerable, an IList, or something else. /// - public class ItemsSourceView : INotifyCollectionChanged, IDisposable + public class ItemsSourceView : INotifyCollectionChanged, IDisposable, IReadOnlyList { - private readonly IList _inner; - private INotifyCollectionChanged _notifyCollectionChanged; + /// + /// Gets an empty + /// + public static ItemsSourceView Empty { get; } = new ItemsSourceView(Array.Empty()); + + private readonly IList _inner; + private INotifyCollectionChanged? _notifyCollectionChanged; /// /// Initializes a new instance of the ItemsSourceView class for the specified data source. /// /// The data source. - public ItemsSourceView(IEnumerable source) + public ItemsSourceView(IEnumerable source) + : this((IEnumerable)source) { - Contract.Requires(source != null); + } - if (source is IList list) + private protected ItemsSourceView(IEnumerable source) + { + source = source ?? throw new ArgumentNullException(nameof(source)); + + if (source is IList list) { _inner = list; } - else if (source is IEnumerable objectEnumerable) + else if (source is IEnumerable objectEnumerable) { - _inner = new List(objectEnumerable); + _inner = new List(objectEnumerable); } else { - _inner = new List(source.Cast()); + _inner = new List(source.Cast()); } ListenToCollectionChanges(); @@ -63,10 +76,17 @@ namespace Avalonia.Controls /// public bool HasKeyIndexMapping => false; + /// + /// Retrieves the item at the specified index. + /// + /// The index. + /// The item. + public T this[int index] => GetAt(index); + /// /// Occurs when the collection has changed to indicate the reason for the change and which items changed. /// - public event NotifyCollectionChangedEventHandler CollectionChanged; + public event NotifyCollectionChangedEventHandler? CollectionChanged; /// public void Dispose() @@ -81,10 +101,26 @@ namespace Avalonia.Controls /// Retrieves the item at the specified index. /// /// The index. - /// the item. - public object GetAt(int index) => _inner[index]; + /// The item. + public T GetAt(int index) => _inner[index]; + + public int IndexOf(T item) => _inner.IndexOf(item); - public int IndexOf(object item) => _inner.IndexOf(item); + public static ItemsSourceView GetOrCreate(IEnumerable? items) + { + if (items is ItemsSourceView isv) + { + return isv; + } + else if (items is null) + { + return Empty; + } + else + { + return new ItemsSourceView(items); + } + } /// /// Retrieves the index of the item that has the specified unique identifier (key). @@ -112,6 +148,25 @@ namespace Avalonia.Controls throw new NotImplementedException(); } + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _inner.GetEnumerator(); + + internal void AddListener(ICollectionChangedListener listener) + { + if (_inner is INotifyCollectionChanged incc) + { + CollectionChangedEventManager.Instance.AddListener(incc, listener); + } + } + + internal void RemoveListener(ICollectionChangedListener listener) + { + if (_inner is INotifyCollectionChanged incc) + { + CollectionChangedEventManager.Instance.RemoveListener(incc, listener); + } + } + protected void OnItemsSourceChanged(NotifyCollectionChangedEventArgs args) { CollectionChanged?.Invoke(this, args); @@ -131,4 +186,12 @@ namespace Avalonia.Controls OnItemsSourceChanged(e); } } + + public class ItemsSourceView : ItemsSourceView + { + public ItemsSourceView(IEnumerable source) + : base(source) + { + } + } } diff --git a/src/Avalonia.Controls/Selection/ISelectionModel.cs b/src/Avalonia.Controls/Selection/ISelectionModel.cs new file mode 100644 index 0000000000..8635b7f6e2 --- /dev/null +++ b/src/Avalonia.Controls/Selection/ISelectionModel.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public interface ISelectionModel : INotifyPropertyChanged + { + IEnumerable? Source { get; set; } + bool SingleSelect { get; set; } + int SelectedIndex { get; set; } + IReadOnlyList SelectedIndexes { get; } + object? SelectedItem { get; } + IReadOnlyList SelectedItems { get; } + int AnchorIndex { get; set; } + int Count { get; } + + public event EventHandler? IndexesChanged; + public event EventHandler? SelectionChanged; + public event EventHandler? LostSelection; + public event EventHandler? SourceReset; + + public void BeginBatchUpdate(); + public void EndBatchUpdate(); + bool IsSelected(int index); + void Select(int index); + void Deselect(int index); + void SelectRange(int start, int end); + void DeselectRange(int start, int end); + void Clear(); + } + + public static class SelectionModelExtensions + { + public static void SelectAll(this ISelectionModel model) + { + model.SelectRange(0, int.MaxValue); + } + + public static void SelectRangeFromAnchor(this ISelectionModel model, int to) + { + model.SelectRange(model.AnchorIndex, to); + } + } +} diff --git a/src/Avalonia.Controls/Selection/IndexRange.cs b/src/Avalonia.Controls/Selection/IndexRange.cs new file mode 100644 index 0000000000..fa7b44faea --- /dev/null +++ b/src/Avalonia.Controls/Selection/IndexRange.cs @@ -0,0 +1,343 @@ +// 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.Selection +{ + internal readonly struct IndexRange : IEquatable + { + private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue); + + public IndexRange(int index) + { + Begin = index; + End = index; + } + + public IndexRange(int begin, int end) + { + // Accept out of order begin/end pairs, just swap them. + 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 bool Contains(IReadOnlyList? ranges, int index) + { + if (ranges is null || index < 0) + { + return false; + } + + foreach (var range in ranges) + { + if (range.Contains(index)) + { + return true; + } + } + + return false; + } + + public static int GetAt(IReadOnlyList ranges, int index) + { + var currentIndex = 0; + + foreach (var range in ranges) + { + var currentCount = range.Count; + + if (index >= currentIndex && index < currentIndex + currentCount) + { + return range.Begin + (index - currentIndex); + } + + currentIndex += currentCount; + } + + throw new IndexOutOfRangeException("The index was out of range."); + } + + public static int Add( + IList ranges, + IndexRange range, + 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 Add( + IList destination, + IReadOnlyList source, + IList? added = null) + { + var result = 0; + + foreach (var range in source) + { + result += Add(destination, range, added); + } + + return result; + } + + public static int Intersect( + IList ranges, + IndexRange range, + IList? removed = null) + { + var result = 0; + + for (var i = 0; i < ranges.Count && range != s_invalid; ++i) + { + var existing = ranges[i]; + + if (existing.End < range.Begin || existing.Begin > range.End) + { + removed?.Add(existing); + ranges.RemoveAt(i--); + result += existing.Count; + } + else + { + if (existing.Begin < range.Begin) + { + var except = new IndexRange(existing.Begin, range.Begin - 1); + removed?.Add(except); + ranges[i] = existing = new IndexRange(range.Begin, existing.End); + result += except.Count; + } + + if (existing.End > range.End) + { + var except = new IndexRange(range.End + 1, existing.End); + removed?.Add(except); + ranges[i] = new IndexRange(existing.Begin, range.End); + result += except.Count; + } + } + } + + MergeRanges(ranges); + + if (removed is object) + { + MergeRanges(removed); + } + + return result; + } + + public static int Remove( + IList? ranges, + IndexRange range, + IList? removed = null) + { + if (ranges is null) + { + return 0; + } + + 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 int Remove( + IList destination, + IReadOnlyList source, + IList? added = null) + { + var result = 0; + + foreach (var range in source) + { + result += Remove(destination, range, added); + } + + 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/Selection/SelectedIndexes.cs b/src/Avalonia.Controls/Selection/SelectedIndexes.cs new file mode 100644 index 0000000000..36df175ed2 --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectedIndexes.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + internal class SelectedIndexes : IReadOnlyList + { + private readonly SelectionModel? _owner; + private readonly IReadOnlyList? _ranges; + + public SelectedIndexes(SelectionModel owner) => _owner = owner; + public SelectedIndexes(IReadOnlyList ranges) => _ranges = ranges; + + public int this[int index] + { + get + { + if (index >= Count) + { + throw new IndexOutOfRangeException("The index was out of range."); + } + + if (_owner?.SingleSelect == true) + { + return _owner.SelectedIndex; + } + else + { + return IndexRange.GetAt(Ranges!, index); + } + } + } + + public int Count + { + get + { + if (_owner?.SingleSelect == true) + { + return _owner.SelectedIndex == -1 ? 0 : 1; + } + else + { + return IndexRange.GetCount(Ranges!); + } + } + } + + private IReadOnlyList Ranges => _ranges ?? _owner!.Ranges!; + + public IEnumerator GetEnumerator() + { + IEnumerator SingleSelect() + { + if (_owner.SelectedIndex >= 0) + { + yield return _owner.SelectedIndex; + } + } + + if (_owner?.SingleSelect == true) + { + return SingleSelect(); + } + else + { + return IndexRange.EnumerateIndices(Ranges).GetEnumerator(); + } + } + + public static SelectedIndexes? Create(IReadOnlyList? ranges) + { + return ranges is object ? new SelectedIndexes(ranges) : null; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Avalonia.Controls/Selection/SelectedItems.cs b/src/Avalonia.Controls/Selection/SelectedItems.cs new file mode 100644 index 0000000000..92781fd54a --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectedItems.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + internal class SelectedItems : IReadOnlyList + { + private readonly SelectionModel? _owner; + private readonly ItemsSourceView? _items; + private readonly IReadOnlyList? _ranges; + + public SelectedItems(SelectionModel owner) => _owner = owner; + + public SelectedItems(IReadOnlyList ranges, ItemsSourceView? items) + { + _ranges = ranges ?? throw new ArgumentNullException(nameof(ranges)); + _items = items; + } + + [MaybeNull] + public T this[int index] + { +#pragma warning disable CS8766 + get +#pragma warning restore CS8766 + { + if (index >= Count) + { + throw new IndexOutOfRangeException("The index was out of range."); + } + + if (_owner?.SingleSelect == true) + { + return _owner.SelectedItem; + } + else if (Items is object) + { + return Items[index]; + } + else + { + return default; + } + } + } + + public int Count + { + get + { + if (_owner?.SingleSelect == true) + { + return _owner.SelectedIndex == -1 ? 0 : 1; + } + else + { + return Ranges is object ? IndexRange.GetCount(Ranges) : 0; + } + } + } + + private ItemsSourceView? Items => _items ?? _owner?.ItemsView; + private IReadOnlyList? Ranges => _ranges ?? _owner!.Ranges; + + public IEnumerator GetEnumerator() + { + if (_owner?.SingleSelect == true) + { + if (_owner.SelectedIndex >= 0) + { +#pragma warning disable CS8603 + yield return _owner.SelectedItem; +#pragma warning restore CS8603 + } + } + else + { + var items = Items; + + foreach (var range in Ranges!) + { + for (var i = range.Begin; i <= range.End; ++i) + { +#pragma warning disable CS8603 + yield return items is object ? items[i] : default; +#pragma warning restore CS8603 + } + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public static SelectedItems? Create( + IReadOnlyList? ranges, + ItemsSourceView? items) + { + return ranges is object ? new SelectedItems(ranges, items) : null; + } + + public class Untyped : IReadOnlyList + { + private readonly IReadOnlyList _source; + public Untyped(IReadOnlyList source) => _source = source; + public object? this[int index] => _source[index]; + public int Count => _source.Count; + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public IEnumerator GetEnumerator() + { + foreach (var i in _source) + { + yield return i; + } + } + } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionModel.cs b/src/Avalonia.Controls/Selection/SelectionModel.cs new file mode 100644 index 0000000000..d6af813107 --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionModel.cs @@ -0,0 +1,632 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public class SelectionModel : SelectionNodeBase, ISelectionModel + { + private bool _singleSelect = true; + private int _anchorIndex = -1; + private int _selectedIndex = -1; + private Operation? _operation; + private SelectedIndexes? _selectedIndexes; + private SelectedItems? _selectedItems; + private SelectedItems.Untyped? _selectedItemsUntyped; + private EventHandler? _untypedSelectionChanged; + + public SelectionModel() + { + } + + public SelectionModel(IEnumerable? source) + { + Source = source; + } + + public override IEnumerable? Source + { + get => base.Source; + set + { + if (base.Source != value) + { + if (_operation is object) + { + throw new InvalidOperationException("Cannot change source while update is in progress."); + } + + if (base.Source is object) + { + Clear(); + } + + base.Source = value; + + using var update = BatchUpdate(); + update.Operation.IsSourceUpdate = true; + TrimInvalidSelections(update.Operation); + RaisePropertyChanged(nameof(Source)); + } + } + } + + public bool SingleSelect + { + get => _singleSelect; + set + { + if (_singleSelect != value) + { + _singleSelect = value; + RangesEnabled = !value; + + if (RangesEnabled && _selectedIndex >= 0) + { + CommitSelect(new IndexRange(_selectedIndex)); + } + + RaisePropertyChanged(nameof(SingleSelect)); + } + } + } + + public int SelectedIndex + { + get => _selectedIndex; + set + { + using var update = BatchUpdate(); + Clear(); + Select(value); + } + } + + public IReadOnlyList SelectedIndexes => _selectedIndexes ??= new SelectedIndexes(this); + + [MaybeNull] + public T SelectedItem => GetItemAt(_selectedIndex); + + public IReadOnlyList SelectedItems => _selectedItems ??= new SelectedItems(this); + + public int AnchorIndex + { + get => _anchorIndex; + set + { + using var update = BatchUpdate(); + var index = CoerceIndex(value); + update.Operation.AnchorIndex = index; + } + } + + public int Count + { + get + { + if (SingleSelect) + { + return _selectedIndex >= 0 ? 1 : 0; + } + else + { + return IndexRange.GetCount(Ranges); + } + } + } + + IEnumerable? ISelectionModel.Source + { + get => Source; + set => Source = (IEnumerable?)value; + } + + object? ISelectionModel.SelectedItem => SelectedItem; + + IReadOnlyList ISelectionModel.SelectedItems + { + get => _selectedItemsUntyped ??= new SelectedItems.Untyped(SelectedItems); + } + + public event EventHandler? IndexesChanged; + public event EventHandler>? SelectionChanged; + public event EventHandler? LostSelection; + public event EventHandler? SourceReset; + public event PropertyChangedEventHandler? PropertyChanged; + + event EventHandler? ISelectionModel.SelectionChanged + { + add => _untypedSelectionChanged += value; + remove => _untypedSelectionChanged -= value; + } + + public BatchUpdateOperation BatchUpdate() => new BatchUpdateOperation(this); + + public void BeginBatchUpdate() + { + _operation ??= new Operation(this); + ++_operation.UpdateCount; + } + + public void EndBatchUpdate() + { + if (_operation is null || _operation.UpdateCount == 0) + { + throw new InvalidOperationException("No batch update in progress."); + } + + if (--_operation.UpdateCount == 0) + { + // If the collection is currently changing, commit the update when the + // collection change finishes. + if (!IsSourceCollectionChanging) + { + CommitOperation(_operation); + } + } + } + + public bool IsSelected(int index) + { + if (index < 0) + { + return false; + } + else if (SingleSelect) + { + return _selectedIndex == index; + } + else + { + return IndexRange.Contains(Ranges, index); + } + } + + public void Select(int index) => SelectRange(index, index, false, true); + + public void Deselect(int index) => DeselectRange(index, index); + + public void SelectRange(int start, int end) => SelectRange(start, end, false, false); + + public void DeselectRange(int start, int end) + { + using var update = BatchUpdate(); + var o = update.Operation; + var range = CoerceRange(start, end); + + if (range.Begin == -1) + { + return; + } + + if (RangesEnabled) + { + var selected = Ranges.ToList(); + var deselected = new List(); + var operationDeselected = new List(); + + o.DeselectedRanges ??= new List(); + IndexRange.Remove(o.SelectedRanges, range, operationDeselected); + IndexRange.Remove(selected, range, deselected); + IndexRange.Add(o.DeselectedRanges, deselected); + + if (IndexRange.Contains(deselected, o.SelectedIndex) || + IndexRange.Contains(operationDeselected, o.SelectedIndex)) + { + o.SelectedIndex = GetFirstSelectedIndexFromRanges(except: deselected); + } + } + else if(range.Contains(_selectedIndex)) + { + o.SelectedIndex = -1; + } + } + + public void Clear() => DeselectRange(0, int.MaxValue); + + protected void RaisePropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private protected override void OnIndexesChanged(int shiftIndex, int shiftDelta) + { + IndexesChanged?.Invoke(this, new SelectionModelIndexesChangedEventArgs(shiftIndex, shiftDelta)); + } + + private protected override void OnSourceReset() + { + _selectedIndex = _anchorIndex = -1; + CommitDeselect(new IndexRange(0, int.MaxValue)); + + if (SourceReset is object) + { + SourceReset.Invoke(this, EventArgs.Empty); + } + else + { + //Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log( + // this, + // "SelectionModel received Reset but no SourceReset handler was registered to handle it. " + + // "Selection may be out of sync.", + // typeof(SelectionModel)); + } + } + + private protected override void OnSelectionChanged(IReadOnlyList deselectedItems) + { + if (SelectionChanged is object || _untypedSelectionChanged is object) + { + var e = new SelectionModelSelectionChangedEventArgs(deselectedItems: deselectedItems); + SelectionChanged?.Invoke(this, e); + _untypedSelectionChanged?.Invoke(this, e); + } + } + + private protected override CollectionChangeState OnItemsAdded(int index, IList items) + { + var count = items.Count; + var shifted = SelectedIndex >= index; + var shiftCount = shifted ? count : 0; + + _selectedIndex += shiftCount; + _anchorIndex += shiftCount; + + var baseResult = base.OnItemsAdded(index, items); + shifted |= baseResult.ShiftDelta != 0; + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? count : 0, + }; + } + + private protected override CollectionChangeState OnItemsRemoved(int index, IList items) + { + var count = items.Count; + var removedRange = new IndexRange(index, index + count - 1); + var shifted = false; + List? removed; + + var baseResult = base.OnItemsRemoved(index, items); + shifted |= baseResult.ShiftDelta != 0; + removed = baseResult.RemovedItems; + + if (removedRange.Contains(SelectedIndex)) + { + if (SingleSelect) + { +#pragma warning disable CS8604 + removed = new List { (T)items[SelectedIndex - index] }; +#pragma warning restore CS8604 + } + + _selectedIndex = GetFirstSelectedIndexFromRanges(); + } + else if (SelectedIndex >= index) + { + _selectedIndex -= count; + shifted = true; + } + + if (removedRange.Contains(AnchorIndex)) + { + _anchorIndex = GetFirstSelectedIndexFromRanges(); + } + else if (AnchorIndex >= index) + { + _anchorIndex -= count; + shifted = true; + } + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? -count : 0, + RemovedItems = removed, + }; + } + + private protected override void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e) + { + if (_operation?.UpdateCount > 0) + { + throw new InvalidOperationException("Source collection was modified during selection update."); + } + + var oldAnchorIndex = _anchorIndex; + var oldSelectedIndex = _selectedIndex; + + base.OnSourceCollectionChanged(e); + + if (oldSelectedIndex != _selectedIndex) + { + RaisePropertyChanged(nameof(SelectedIndex)); + } + + if (oldAnchorIndex != _anchorIndex) + { + RaisePropertyChanged(nameof(AnchorIndex)); + } + } + + protected override void OnSourceCollectionChangeFinished() + { + if (_operation is object) + { + CommitOperation(_operation); + } + } + + private int GetFirstSelectedIndexFromRanges(List? except = null) + { + if (RangesEnabled) + { + var count = IndexRange.GetCount(Ranges); + var index = 0; + + while (index < count) + { + var result = IndexRange.GetAt(Ranges, index++); + + if (!IndexRange.Contains(except, result)) + { + return result; + } + } + } + + return -1; + } + + private void SelectRange( + int start, + int end, + bool forceSelectedIndex, + bool forceAnchorIndex) + { + if (SingleSelect && start != end) + { + throw new InvalidOperationException("Cannot select range with single selection."); + } + + var range = CoerceRange(start, end); + + if (range.Begin == -1) + { + return; + } + + using var update = BatchUpdate(); + var o = update.Operation; + var selected = new List(); + + if (RangesEnabled) + { + o.SelectedRanges ??= new List(); + IndexRange.Remove(o.DeselectedRanges, range); + IndexRange.Add(o.SelectedRanges, range); + IndexRange.Remove(o.SelectedRanges, Ranges); + + if (o.SelectedIndex == -1 || forceSelectedIndex) + { + o.SelectedIndex = range.Begin; + } + + if (o.AnchorIndex == -1 || forceAnchorIndex) + { + o.AnchorIndex = range.Begin; + } + } + else + { + o.SelectedIndex = o.AnchorIndex = start; + } + } + + [return: MaybeNull] + private T GetItemAt(int index) + { + if (ItemsView is null || index < 0 || index >= ItemsView.Count) + { + return default; + } + + return ItemsView.GetAt(index); + } + + private int CoerceIndex(int index) + { + index = Math.Max(index, -1); + + if (ItemsView is object && index >= ItemsView.Count) + { + index = -1; + } + + return index; + } + + private IndexRange CoerceRange(int start, int end) + { + var max = ItemsView is object ? ItemsView.Count - 1 : int.MaxValue; + + if (start > max || (start < 0 && end < 0)) + { + return new IndexRange(-1); + } + + start = Math.Max(start, 0); + end = Math.Min(end, max); + + return new IndexRange(start, end); + } + + private void TrimInvalidSelections(Operation operation) + { + if (ItemsView is null) + { + return; + } + + var max = ItemsView.Count - 1; + + if (operation.SelectedIndex > max) + { + operation.SelectedIndex = GetFirstSelectedIndexFromRanges(); + } + + if (operation.AnchorIndex > max) + { + operation.AnchorIndex = GetFirstSelectedIndexFromRanges(); + } + + if (RangesEnabled && Ranges.Count > 0) + { + var selected = Ranges.ToList(); + + if (max < 0) + { + operation.DeselectedRanges = selected; + } + else + { + var valid = new IndexRange(0, max); + var removed = new List(); + IndexRange.Intersect(selected, valid, removed); + operation.DeselectedRanges = removed; + } + } + } + + private void CommitOperation(Operation operation) + { + try + { + var oldAnchorIndex = _anchorIndex; + var oldSelectedIndex = _selectedIndex; + var indexesChanged = false; + + if (operation.SelectedIndex == -1 && LostSelection is object) + { + operation.UpdateCount++; + LostSelection?.Invoke(this, EventArgs.Empty); + } + + _selectedIndex = operation.SelectedIndex; + _anchorIndex = operation.AnchorIndex; + + if (operation.SelectedRanges is object) + { + indexesChanged |= CommitSelect(operation.SelectedRanges) > 0; + } + + if (operation.DeselectedRanges is object) + { + indexesChanged |= CommitDeselect(operation.DeselectedRanges) > 0; + } + + if (SelectionChanged is object || _untypedSelectionChanged is object) + { + IReadOnlyList? deselected = operation.DeselectedRanges; + IReadOnlyList? selected = operation.SelectedRanges; + + if (SingleSelect && oldSelectedIndex != _selectedIndex) + { + if (oldSelectedIndex != -1) + { + deselected = new[] { new IndexRange(oldSelectedIndex) }; + } + + if (_selectedIndex != -1) + { + selected = new[] { new IndexRange(_selectedIndex) }; + } + } + + if (deselected?.Count > 0 || selected?.Count > 0) + { + var deselectedSource = operation.IsSourceUpdate ? null : ItemsView; + var e = new SelectionModelSelectionChangedEventArgs( + SelectedIndexes.Create(deselected), + SelectedIndexes.Create(selected), + SelectedItems.Create(deselected, deselectedSource), + SelectedItems.Create(selected, ItemsView)); + SelectionChanged?.Invoke(this, e); + _untypedSelectionChanged?.Invoke(this, e); + } + } + + if (oldSelectedIndex != _selectedIndex) + { + indexesChanged = true; + RaisePropertyChanged(nameof(SelectedIndex)); + RaisePropertyChanged(nameof(SelectedItem)); + } + + if (oldAnchorIndex != _anchorIndex) + { + indexesChanged = true; + RaisePropertyChanged(nameof(AnchorIndex)); + } + + if (indexesChanged) + { + RaisePropertyChanged(nameof(SelectedIndexes)); + RaisePropertyChanged(nameof(SelectedItems)); + } + } + finally + { + _operation = null; + } + } + + public struct BatchUpdateOperation : IDisposable + { + private readonly SelectionModel _owner; + private bool _isDisposed; + + public BatchUpdateOperation(SelectionModel owner) + { + _owner = owner; + _isDisposed = false; + owner.BeginBatchUpdate(); + } + + internal Operation Operation => _owner._operation!; + + public void Dispose() + { + if (!_isDisposed) + { + _owner?.EndBatchUpdate(); + _isDisposed = true; + } + } + } + + internal class Operation + { + public Operation(SelectionModel owner) + { + AnchorIndex = owner.AnchorIndex; + SelectedIndex = owner.SelectedIndex; + } + + public int UpdateCount { get; set; } + public bool IsSourceUpdate { get; set; } + public int AnchorIndex { get; set; } + public int SelectedIndex { get; set; } + public List? SelectedRanges { get; set; } + public List? DeselectedRanges { get; set; } + } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs b/src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs new file mode 100644 index 0000000000..a1fef578a2 --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs @@ -0,0 +1,18 @@ +using System; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public class SelectionModelIndexesChangedEventArgs : EventArgs + { + public SelectionModelIndexesChangedEventArgs(int startIndex, int delta) + { + StartIndex = startIndex; + Delta = delta; + } + + public int StartIndex { get; } + public int Delta { get; } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs b/src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs new file mode 100644 index 0000000000..396943592d --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Avalonia.Controls.Selection; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public abstract class SelectionModelSelectionChangedEventArgs : EventArgs + { + /// + /// Gets the indexes of the items that were removed from the selection. + /// + public abstract IReadOnlyList DeselectedIndexes { get; } + + /// + /// Gets the indexes of the items that were added to the selection. + /// + public abstract IReadOnlyList SelectedIndexes { get; } + + /// + /// Gets the items that were removed from the selection. + /// + public IReadOnlyList DeselectedItems => GetUntypedDeselectedItems(); + + /// + /// Gets the items that were added to the selection. + /// + public IReadOnlyList SelectedItems => GetUntypedSelectedItems(); + + protected abstract IReadOnlyList GetUntypedDeselectedItems(); + protected abstract IReadOnlyList GetUntypedSelectedItems(); + } + + public class SelectionModelSelectionChangedEventArgs : SelectionModelSelectionChangedEventArgs + { + private IReadOnlyList? _deselectedItems; + private IReadOnlyList? _selectedItems; + + public SelectionModelSelectionChangedEventArgs( + IReadOnlyList? deselectedIndices = null, + IReadOnlyList? selectedIndices = null, + IReadOnlyList? deselectedItems = null, + IReadOnlyList? selectedItems = null) + { + DeselectedIndexes = deselectedIndices ?? Array.Empty(); + SelectedIndexes = selectedIndices ?? Array.Empty(); + DeselectedItems = deselectedItems ?? Array.Empty(); + SelectedItems = selectedItems ?? Array.Empty(); + } + + /// + /// Gets the indexes of the items that were removed from the selection. + /// + public override IReadOnlyList DeselectedIndexes { get; } + + /// + /// Gets the indexes of the items that were added to the selection. + /// + public override IReadOnlyList SelectedIndexes { get; } + + /// + /// Gets the items that were removed from the selection. + /// + public new IReadOnlyList DeselectedItems { get; } + + /// + /// Gets the items that were added to the selection. + /// + public new IReadOnlyList SelectedItems { get; } + + protected override IReadOnlyList GetUntypedDeselectedItems() + { + return _deselectedItems ??= (DeselectedItems as IReadOnlyList) ?? + new SelectedItems.Untyped(DeselectedItems); + } + + protected override IReadOnlyList GetUntypedSelectedItems() + { + return _selectedItems ??= (SelectedItems as IReadOnlyList) ?? + new SelectedItems.Untyped(SelectedItems); + } + } +} diff --git a/src/Avalonia.Controls/Selection/SelectionNodeBase.cs b/src/Avalonia.Controls/Selection/SelectionNodeBase.cs new file mode 100644 index 0000000000..4796e8b9ca --- /dev/null +++ b/src/Avalonia.Controls/Selection/SelectionNodeBase.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Controls.Utils; + +#nullable enable + +namespace Avalonia.Controls.Selection +{ + public abstract class SelectionNodeBase : ICollectionChangedListener + { + private IEnumerable? _source; + private bool _rangesEnabled; + private List? _ranges; + private int _collectionChanging; + + public virtual IEnumerable? Source + { + get => _source; + set + { + if (_source != value) + { + ItemsView?.RemoveListener(this); + _source = value; + ItemsView = value is object ? ItemsSourceView.GetOrCreate(value) : null; + ItemsView?.AddListener(this); + } + } + } + + protected bool IsSourceCollectionChanging => _collectionChanging > 0; + + protected bool RangesEnabled + { + get => _rangesEnabled; + set + { + if (_rangesEnabled != value) + { + _rangesEnabled = value; + + if (!_rangesEnabled) + { + _ranges = null; + } + } + } + } + + internal ItemsSourceView? ItemsView { get; set; } + + internal IReadOnlyList Ranges + { + get + { + if (!RangesEnabled) + { + throw new InvalidOperationException("Ranges not enabled."); + } + + return _ranges ??= new List(); + } + } + + void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + ++_collectionChanging; + } + + void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + OnSourceCollectionChanged(e); + } + + void ICollectionChangedListener.PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + if (--_collectionChanging == 0) + { + OnSourceCollectionChangeFinished(); + } + } + + protected abstract void OnSourceCollectionChangeFinished(); + + private protected abstract void OnIndexesChanged(int shiftIndex, int shiftDelta); + + private protected abstract void OnSourceReset(); + + private protected abstract void OnSelectionChanged(IReadOnlyList deselectedItems); + + private protected int CommitSelect(IndexRange range) + { + if (RangesEnabled) + { + _ranges ??= new List(); + return IndexRange.Add(_ranges, range); + } + + return 0; + } + + private protected int CommitSelect(IReadOnlyList ranges) + { + if (RangesEnabled) + { + _ranges ??= new List(); + return IndexRange.Add(_ranges, ranges); + } + + return 0; + } + + private protected int CommitDeselect(IndexRange range) + { + if (RangesEnabled) + { + _ranges ??= new List(); + return IndexRange.Remove(_ranges, range); + } + + return 0; + } + + private protected int CommitDeselect(IReadOnlyList ranges) + { + if (RangesEnabled && _ranges is object) + { + return IndexRange.Remove(_ranges, ranges); + } + + return 0; + } + + private protected virtual CollectionChangeState OnItemsAdded(int index, IList items) + { + var count = items.Count; + var shifted = false; + + if (_ranges is object) + { + List? toAdd = null; + + for (var i = 0; i < Ranges!.Count; ++i) + { + var range = Ranges[i]; + + // The range is after the inserted items, need to shift the range right + if (range.End >= index) + { + int begin = range.Begin; + + // If the index left of newIndex is inside the range, + // Split the range and remember the left piece to add later + if (range.Contains(index - 1)) + { + range.Split(index - 1, out var before, out _); + (toAdd ??= new List()).Add(before); + begin = index; + } + + // Shift the range to the right + _ranges[i] = new IndexRange(begin + count, range.End + count); + shifted = true; + } + } + + if (toAdd is object) + { + foreach (var range in toAdd) + { + IndexRange.Add(_ranges, range); + } + } + } + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? count : 0, + }; + } + + private protected virtual CollectionChangeState OnItemsRemoved(int index, IList items) + { + var count = items.Count; + var removedRange = new IndexRange(index, index + count - 1); + bool shifted = false; + List? removed = null; + + if (_ranges is object) + { + var deselected = new List(); + + if (IndexRange.Remove(_ranges, removedRange, deselected) > 0) + { + removed = new List(); + + foreach (var range in deselected) + { + for (var i = range.Begin; i <= range.End; ++i) + { +#pragma warning disable CS8604 + removed.Add((T)items[i - index]); +#pragma warning restore CS8604 + } + } + } + + for (var i = 0; i < Ranges!.Count; ++i) + { + var existing = Ranges[i]; + + if (existing.End > removedRange.Begin) + { + _ranges[i] = new IndexRange(existing.Begin - count, existing.End - count); + shifted = true; + } + } + } + + return new CollectionChangeState + { + ShiftIndex = index, + ShiftDelta = shifted ? -count : 0, + RemovedItems = removed, + }; + } + + private protected virtual void OnSourceCollectionChanged(NotifyCollectionChangedEventArgs e) + { + var shiftDelta = 0; + var shiftIndex = -1; + List? removed = null; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + var change = OnItemsAdded(e.NewStartingIndex, e.NewItems); + shiftIndex = change.ShiftIndex; + shiftDelta = change.ShiftDelta; + break; + } + case NotifyCollectionChangedAction.Remove: + { + var change = OnItemsRemoved(e.OldStartingIndex, e.OldItems); + shiftIndex = change.ShiftIndex; + shiftDelta = change.ShiftDelta; + removed = change.RemovedItems; + break; + } + case NotifyCollectionChangedAction.Replace: + { + var removeChange = OnItemsRemoved(e.OldStartingIndex, e.OldItems); + var addChange = OnItemsAdded(e.NewStartingIndex, e.NewItems); + shiftIndex = removeChange.ShiftIndex; + shiftDelta = removeChange.ShiftDelta + addChange.ShiftDelta; + removed = removeChange.RemovedItems; + } + break; + case NotifyCollectionChangedAction.Reset: + OnSourceReset(); + break; + } + + if (shiftDelta != 0) + { + OnIndexesChanged(shiftIndex, shiftDelta); + } + + if (removed is object) + { + OnSelectionChanged(removed); + } + } + + private protected struct CollectionChangeState + { + public int ShiftIndex; + public int ShiftDelta; + public List? RemovedItems; + } + } +} diff --git a/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs new file mode 100644 index 0000000000..6abba0cc8e --- /dev/null +++ b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Runtime.CompilerServices; +using Avalonia.Threading; +using Avalonia.Utilities; + +#nullable enable + +namespace Avalonia.Controls.Utils +{ + internal interface ICollectionChangedListener + { + void PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e); + void Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e); + void PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e); + } + + internal class CollectionChangedEventManager : IWeakSubscriber + { + public static CollectionChangedEventManager Instance { get; } = new CollectionChangedEventManager(); + + private ConditionalWeakTable>> _entries = + new ConditionalWeakTable>>(); + + private CollectionChangedEventManager() + { + } + + public void AddListener(INotifyCollectionChanged collection, ICollectionChangedListener listener) + { + collection = collection ?? throw new ArgumentNullException(nameof(collection)); + listener = listener ?? throw new ArgumentNullException(nameof(listener)); + Dispatcher.UIThread.VerifyAccess(); + + if (!_entries.TryGetValue(collection, out var listeners)) + { + listeners = new List>(); + _entries.Add(collection, listeners); + WeakSubscriptionManager.Subscribe( + collection, + nameof(INotifyCollectionChanged.CollectionChanged), + this); + } + + //if (listeners.Contains(listener)) + //{ + // throw new InvalidOperationException( + // "Collection listener already added for this collection/listener combination."); + //} + + listeners.Add(new WeakReference(listener)); + } + + public void RemoveListener(INotifyCollectionChanged collection, ICollectionChangedListener listener) + { + collection = collection ?? throw new ArgumentNullException(nameof(collection)); + listener = listener ?? throw new ArgumentNullException(nameof(listener)); + Dispatcher.UIThread.VerifyAccess(); + + if (_entries.TryGetValue(collection, out var listeners)) + { + for (var i = 0; i < listeners.Count; ++i) + { + if (listeners[i].TryGetTarget(out var target) && target == listener) + { + listeners.RemoveAt(i); + + if (listeners.Count == 0) + { + WeakSubscriptionManager.Unsubscribe( + collection, + nameof(INotifyCollectionChanged.CollectionChanged), + this); + _entries.Remove(collection); + } + + return; + } + } + } + + throw new InvalidOperationException( + "Collection listener not registered for this collection/listener combination."); + } + + void IWeakSubscriber.OnEvent(object sender, NotifyCollectionChangedEventArgs e) + { + static void Notify( + INotifyCollectionChanged incc, + NotifyCollectionChangedEventArgs args, + List> listeners) + { + foreach (var l in listeners) + { + if (l.TryGetTarget(out var target)) + { + target.PreChanged(incc, args); + } + } + + foreach (var l in listeners) + { + if (l.TryGetTarget(out var target)) + { + target.Changed(incc, args); + } + } + + foreach (var l in listeners) + { + if (l.TryGetTarget(out var target)) + { + target.PostChanged(incc, args); + } + } + } + + if (sender is INotifyCollectionChanged incc && _entries.TryGetValue(incc, out var listeners)) + { + if (Dispatcher.UIThread.CheckAccess()) + { + Notify(incc, e, listeners); + } + else + { + var inccCapture = incc; + var eCapture = e; + var listenersCapture = listeners; + Dispatcher.UIThread.Post(() => Notify(inccCapture, eCapture, listenersCapture)); + } + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs new file mode 100644 index 0000000000..f07d2cddea --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs @@ -0,0 +1,1474 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Collections; +using Avalonia.Controls.Selection; +using Xunit; + +#nullable enable + +namespace Avalonia.Controls.UnitTests.Selection +{ + public class SelectionModelTests_Multiple + { + public class No_Source + { + [Fact] + public void Can_Select_Multiple_Items_Before_Source_Assigned() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + var index = raised switch + { + 0 => 5, + 1 => 10, + 2 => 100, + _ => throw new NotSupportedException(), + }; + + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { index }, e.SelectedIndexes); + Assert.Equal(new string?[] { null }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 5; + target.Select(10); + target.Select(100); + + Assert.Equal(5, target.SelectedIndex); + Assert.Equal(new[] { 5, 10, 100 }, target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Equal(new string?[] { null, null, null }, target.SelectedItems); + Assert.Equal(3, raised); + } + + [Fact] + public void Initializing_Source_Retains_Valid_Selection_And_Removes_Invalid() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 1; + target.Select(2); + target.Select(10); + target.Select(100); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 10, 100 }, e.DeselectedIndexes); + Assert.Equal(new string?[] { null, null }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1, 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Initializing_Source_Coerces_SelectedIndex() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 100; + target.Select(2); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("baz", target.SelectedItem); + Assert.Equal(new[] { "baz" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Initializing_Source_Doesnt_Raise_SelectionChanged_If_Selection_Valid() + { + var target = CreateTarget(false); + var raised = 0; + + target.Select(1); + target.Select(2); + + target.SelectionChanged += (s, e) => + { + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(0, raised); + } + } + + public class SelectedIndex + { + [Fact] + public void SelectedIndex_Larger_Than_Source_Clears_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 15; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Negative_SelectedIndex_Is_Coerced_To_Minus_1() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => ++raised; + + target.SelectedIndex = -5; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Setting_SelectedIndex_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void PropertyChanged_Is_Raised() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedIndexes + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndexes)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedItem + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedItems + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItems)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class Select + { + [Fact] + public void Select_Sets_SelectedIndex_If_Previously_Unset() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_Adds_To_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.Select(1); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0, 1 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo", "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_With_Invalid_Index_Does_Nothing() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + + target.Select(15); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(2); + target.SelectionChanged += (s, e) => ++raised; + target.Select(2); + + Assert.Equal(0, raised); + } + } + + public class SelectRange + { + [Fact] + public void SelectRange_Selects_Items() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 1, 2 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar", "baz" }, e.SelectedItems); + ++raised; + }; + + target.SelectRange(1, 2); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1, 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar", "baz" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRange_Ignores_Out_Of_Bounds_Items() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 11, 12 }, e.SelectedIndexes); + Assert.Equal(new[] { "xyzzy", "thud" }, e.SelectedItems); + ++raised; + }; + + target.SelectRange(11, 20); + + Assert.Equal(11, target.SelectedIndex); + Assert.Equal(new[] { 11, 12 }, target.SelectedIndexes); + Assert.Equal("xyzzy", target.SelectedItem); + Assert.Equal(new[] { "xyzzy", "thud" }, target.SelectedItems); + Assert.Equal(11, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRange_Does_Nothing_For_Non_Intersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => ++raised; + + target.SelectRange(18, 30); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(0, raised); + } + } + + public class Deselect + { + [Fact] + public void Deselect_Clears_Selected_Item() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + target.Select(1); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Deselect(1); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Deselect_Updates_SelectedItem_To_First_Selected_Item() + { + var target = CreateTarget(); + + target.SelectRange(3, 5); + target.Deselect(3); + + Assert.Equal(4, target.SelectedIndex); + } + } + + public class DeselectRange + { + [Fact] + public void DeselectRange_Clears_Identical_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(1, 2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1, 2 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar", "baz" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.DeselectRange(1, 2); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void DeselectRange_Clears_Intersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(1, 2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.DeselectRange(0, 1); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("baz", target.SelectedItem); + Assert.Equal(new[] { "baz" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void DeselectRange_Does_Nothing_For_Nonintersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + target.SelectionChanged += (s, e) => ++raised; + target.DeselectRange(1, 2); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + } + + public class Clear + { + [Fact] + public void Clear_Raises_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(1); + target.Select(2); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1, 2 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar", "baz" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Clear(); + + Assert.Equal(1, raised); + } + } + + public class AnchorIndex + { + [Fact] + public void Setting_SelectedIndex_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_To_Minus_1_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = -1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Select_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void SelectRange_Doesnt_Overwrite_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.AnchorIndex = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectRange(1, 2); + + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Deselect_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(0); + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Deselect(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + } + + public class CollectionChanges + { + [Fact] + public void Adding_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(1, e.Delta); + ++indexesChangedraised; + }; + + data.Insert(0, "new"); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(2, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.Insert(2, "new"); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Adding_Item_At_Beginning_Of_SelectedRange_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectRange(4, 8); + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(4, e.StartIndex); + Assert.Equal(2, e.Delta); + ++indexesChangedraised; + }; + + data.InsertRange(4, new[] { "frank", "tank" }); + + Assert.Equal(6, target.SelectedIndex); + Assert.Equal(new[] { 6, 7, 8, 9, 10 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems); + Assert.Equal(6, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_At_End_Of_SelectedRange_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectRange(4, 8); + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(8, e.StartIndex); + Assert.Equal(2, e.Delta); + ++indexesChangedraised; + }; + + data.InsertRange(8, new[] { "frank", "tank" }); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5, 6, 7, 10 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_In_Middle_Of_SelectedRange_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectRange(4, 8); + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(6, e.StartIndex); + Assert.Equal(2, e.Delta); + ++indexesChangedraised; + }; + + data.InsertRange(6, new[] { "frank", "tank" }); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5, 8, 9, 10 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Removing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveAt(1); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(-1, e.Delta); + ++indexesChangedraised; + }; + + data.RemoveAt(0); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Removing_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.RemoveAt(2); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Removing_Selected_Range_Raises_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "quux", "corge", "grault", "garply", "waldo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(4, 5); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Partial_Selected_Range_Raises_Events_1() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "quux", "corge", "grault" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(0, 7); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0, 1 }, target.SelectedIndexes); + Assert.Equal("garply", target.SelectedItem); + Assert.Equal(new[] { "garply", "waldo" }, target.SelectedItems); + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Partial_Selected_Range_Raises_Events_2() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "garply", "waldo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(7, 3); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5, 6 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "corge", "grault" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(0, selectedIndexRaised); + } + + [Fact] + public void Removing_Partial_Selected_Range_Raises_Events_3() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.SelectRange(4, 8); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "corge", "grault", "garply" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveRange(5, 3); + + Assert.Equal(4, target.SelectedIndex); + Assert.Equal(new[] { 4, 5 }, target.SelectedIndexes); + Assert.Equal("quux", target.SelectedItem); + Assert.Equal(new[] { "quux", "waldo" }, target.SelectedItems); + Assert.Equal(4, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(0, selectedIndexRaised); + } + + [Fact] + public void Replacing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var indexesChangedRaised = 0; + + target.Source = data; + target.SelectRange(1, 4); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.IndexesChanged += (s, e) => ++indexesChangedRaised; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data[1] = "new"; + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2, 3, 4 }, target.SelectedIndexes); + Assert.Equal("baz", target.SelectedItem); + Assert.Equal(new[] { "baz", "qux", "quux" }, target.SelectedItems); + Assert.Equal(2, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(0, indexesChangedRaised); + } + + [Fact] + public void Resetting_Source_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var resetRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + target.SourceReset += (s, e) => ++resetRaised; + + data.Clear(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(0, selectionChangedRaised); + Assert.Equal(1, resetRaised); + Assert.Equal(1, selectedIndexRaised); + } + } + + public class BatchUpdate + { + [Fact] + public void Correctly_Batches_Selects() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Select(2); + target.Select(3); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_SelectRanges() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3, 5, 6 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux", "corge", "grault" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.SelectRange(2, 3); + target.SelectRange(5, 6); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Select_Deselect() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Select(2); + target.Select(3); + target.Select(4); + target.Deselect(4); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Deselect_Select() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 8); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 2, 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Deselect(2); + target.Deselect(3); + target.Deselect(4); + target.Select(4); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Select_Deselect_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 2, 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.SelectRange(2, 6); + target.DeselectRange(4, 8); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Deselect_Select_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 8); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 2, 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "baz", "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.DeselectRange(2, 6); + target.SelectRange(4, 8); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Clear_Select() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 3); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Clear(); + target.Select(2); + } + + Assert.Equal(1, raised); + } + + [Fact] + public void Correctly_Batches_Clear_SelectedIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectRange(2, 3); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 3 }, e.DeselectedIndexes); + Assert.Equal(new[] { "qux" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + using (target.BatchUpdate()) + { + target.Clear(); + target.SelectedIndex = 2; + } + + Assert.Equal(1, raised); + } + } + + public class LostSelection + { + [Fact] + public void Can_Select_First_Item_On_LostSelection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Equal(new[] { 0 }, e.SelectedIndexes); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.LostSelection += (s, e) => + { + target.Select(0); + }; + + target.Clear(); + + Assert.Equal(1, raised); + } + } + + public class SourceReset + { + [Fact] + public void Can_Restore_Selection_In_SourceReset_Event() + { + var data = new ResettingList { "foo", "bar", "baz" }; + var target = CreateTarget(createData: false); + var sourceResetRaised = 0; + var selectionChangedRaised = 0; + + target.Source = data; + target.SelectedIndex = 1; + + target.SourceReset += (s, e) => + { + target.SelectedIndex = data.IndexOf("bar"); + ++sourceResetRaised; + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 3 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++selectionChangedRaised; + }; + + data.Reset(new[] { "qux", "foo", "quux", "bar", "baz" }); + + Assert.Equal(3, target.SelectedIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, sourceResetRaised); + } + } + + private static SelectionModel CreateTarget(bool createData = true) + { + var result = new SelectionModel { SingleSelect = false }; + + if (createData) + { + result.Source = new AvaloniaList + { + "foo", + "bar", + "baz", + "qux", + "quux", + "corge", + "grault", + "garply", + "waldo", + "fred", + "plugh", + "xyzzy", + "thud" + }; + } + + return result; + } + + private class ResettingList : List, INotifyCollectionChanged + { + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public void Reset(IEnumerable? items = null) + { + if (items != null) + { + Clear(); + AddRange(items); + } + + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } + + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs new file mode 100644 index 0000000000..9f301131b7 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs @@ -0,0 +1,1021 @@ +using System; +using System.Collections.Specialized; +using Avalonia.Collections; +using Avalonia.Controls.Selection; +using Avalonia.Controls.Utils; +using Xunit; + +#nullable enable + +namespace Avalonia.Controls.UnitTests.Selection +{ + public class SelectionModelTests_Single + { + public class Source + { + [Fact] + public void Can_Select_Item_Before_Source_Assigned() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Empty(e.DeselectedItems); + Assert.Equal(new[] { 5 }, e.SelectedIndexes); + Assert.Equal(new string?[] { null }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 5; + + Assert.Equal(5, target.SelectedIndex); + Assert.Equal(new[] { 5 }, target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Equal(new string?[] { null }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Initializing_Source_Retains_Valid_Selection() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++raised; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Initializing_Source_Removes_Invalid_Selection() + { + var target = CreateTarget(false); + var raised = 0; + + target.SelectedIndex = 5; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 5 }, e.DeselectedIndexes); + Assert.Equal(new string?[] { null }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "foo", "bar", "baz" }; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Changing_Source_First_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 2; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 2 }, e.DeselectedIndexes); + Assert.Equal(new string?[] { "baz" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Source = new[] { "qux", "quux", "corge" }; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Raises_PropertyChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.Source)) + { + ++raised; + } + }; + + target.Source = new[] { "qux", "quux", "corge" }; + + Assert.Equal(1, raised); + } + } + + public class SelectedIndex + { + [Fact] + public void SelectedIndex_Larger_Than_Source_Clears_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 5; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Negative_SelectedIndex_Is_Coerced_To_Minus_1() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectionChanged += (s, e) => ++raised; + + target.SelectedIndex = -5; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Setting_SelectedIndex_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_During_CollectionChanged_Results_In_Correct_Selection() + { + // Issue #4496 + var data = new AvaloniaList(); + var target = CreateTarget(); + var binding = new MockBinding(target, data); + + target.Source = data; + + data.Add("foo"); + + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void PropertyChanged_Is_Raised() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + + private class MockBinding : ICollectionChangedListener + { + private readonly SelectionModel _target; + + public MockBinding(SelectionModel target, AvaloniaList data) + { + _target = target; + Avalonia.Controls.Utils.CollectionChangedEventManager.Instance.AddListener(data, this); + } + + public void Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + _target.Select(0); + } + + public void PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + } + + public void PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) + { + } + } + } + + public class SelectedItem + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItem)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedIndexes + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndexes)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SelectedItems + { + [Fact] + public void PropertyChanged_Is_Raised_When_SelectedIndex_Changes() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedItems)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class Select + { + [Fact] + public void Select_Sets_SelectedIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_Clears_Old_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Equal(new[] { 1 }, e.SelectedIndexes); + Assert.Equal(new[] { "bar" }, e.SelectedItems); + ++raised; + }; + + target.Select(1); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Select_With_Invalid_Index_Does_Nothing() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + + target.Select(5); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(2); + target.SelectionChanged += (s, e) => ++raised; + target.Select(2); + + Assert.Equal(0, raised); + } + } + + public class SelectRange + { + [Fact] + public void SelectRange_Throws() + { + var target = CreateTarget(); + + Assert.Throws(() => target.SelectRange(0, 10)); + } + } + + public class Deselect + { + [Fact] + public void Deselect_Clears_Current_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Deselect(0); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void Deselect_Does_Nothing_For_Nonselected_Item() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + target.SelectionChanged += (s, e) => ++raised; + target.Deselect(0); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, raised); + } + } + + public class DeselectRange + { + [Fact] + public void DeselectRange_Clears_Current_Selection_For_Intersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 0 }, e.DeselectedIndexes); + Assert.Equal(new[] { "foo" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.DeselectRange(0, 2); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(1, raised); + } + + [Fact] + public void DeselectRange_Does_Nothing_For_Nonintersecting_Range() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 0; + target.SelectionChanged += (s, e) => ++raised; + target.DeselectRange(1, 2); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("foo", target.SelectedItem); + Assert.Equal(new[] { "foo" }, target.SelectedItems); + Assert.Equal(0, raised); + } + } + + public class Clear + { + [Fact] + public void Clear_Raises_SelectionChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(1); + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++raised; + }; + + target.Clear(); + + Assert.Equal(1, raised); + } + } + + public class AnchorIndex + { + [Fact] + public void Setting_SelectedIndex_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Setting_SelectedIndex_To_Minus_1_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = -1; + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Select_Sets_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Select(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(1, raised); + } + + [Fact] + public void Deselect_Doesnt_Clear_AnchorIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.Deselect(1); + + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Raises_PropertyChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.AnchorIndex)) + { + ++raised; + } + }; + + target.SelectedIndex = 1; + + Assert.Equal(1, raised); + } + } + + public class SingleSelect + { + [Fact] + public void Converting_To_Multiple_Selection_Preserves_Selection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++raised; + + target.SingleSelect = false; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, raised); + } + + [Fact] + public void Raises_PropertyChanged() + { + var target = CreateTarget(); + var raised = 0; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SingleSelect)) + { + ++raised; + } + }; + + target.SingleSelect = false; + + Assert.Equal(1, raised); + } + } + + public class CollectionChanges + { + [Fact] + public void Adding_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedRaised = 0; + var selectedIndexRaised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(1, e.Delta); + ++indexesChangedRaised; + }; + + data.Insert(0, "new"); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal(new[] { 2 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(2, target.AnchorIndex); + Assert.Equal(1, indexesChangedRaised); + Assert.Equal(1, selectedIndexRaised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Adding_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.Insert(2, "new"); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Removing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data.RemoveAt(1); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Removing_Item_Before_Selected_Item_Updates_Indexes() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var indexesChangedraised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + + target.IndexesChanged += (s, e) => + { + Assert.Equal(0, e.StartIndex); + Assert.Equal(-1, e.Delta); + ++indexesChangedraised; + }; + + data.RemoveAt(0); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { 0 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(0, target.AnchorIndex); + Assert.Equal(1, indexesChangedraised); + Assert.Equal(0, selectionChangedRaised); + } + + [Fact] + public void Removing_Item_After_Selected_Doesnt_Raise_Events() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var raised = 0; + + target.SelectedIndex = 1; + + target.PropertyChanged += (s, e) => ++raised; + target.SelectionChanged += (s, e) => ++raised; + target.IndexesChanged += (s, e) => ++raised; + + data.RemoveAt(2); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { 1 }, target.SelectedIndexes); + Assert.Equal("bar", target.SelectedItem); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(1, target.AnchorIndex); + Assert.Equal(0, raised); + } + + [Fact] + public void Replacing_Selected_Item_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => + { + Assert.Empty(e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Empty(e.SelectedIndexes); + Assert.Empty(e.SelectedItems); + ++selectionChangedRaised; + }; + + data[1] = "new"; + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(1, selectionChangedRaised); + Assert.Equal(1, selectedIndexRaised); + } + + [Fact] + public void Resetting_Source_Updates_State() + { + var target = CreateTarget(); + var data = (AvaloniaList)target.Source!; + var selectionChangedRaised = 0; + var selectedIndexRaised = 0; + var resetRaised = 0; + + target.Source = data; + target.Select(1); + + target.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(target.SelectedIndex)) + { + ++selectedIndexRaised; + } + }; + + target.SelectionChanged += (s, e) => ++selectionChangedRaised; + target.SourceReset += (s, e) => ++resetRaised; + + data.Clear(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Empty(target.SelectedIndexes); + Assert.Null(target.SelectedItem); + Assert.Empty(target.SelectedItems); + Assert.Equal(-1, target.AnchorIndex); + Assert.Equal(0, selectionChangedRaised); + Assert.Equal(1, resetRaised); + Assert.Equal(1, selectedIndexRaised); + } + } + + public class BatchUpdate + { + [Fact] + public void Changes_Do_Not_Take_Effect_Until_EndUpdate_Called() + { + var target = CreateTarget(); + + target.BeginBatchUpdate(); + target.Select(0); + + Assert.Equal(-1, target.SelectedIndex); + + target.EndBatchUpdate(); + + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Correctly_Batches_Clear_SelectedIndex() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 2; + target.SelectionChanged += (s, e) => ++raised; + + using (target.BatchUpdate()) + { + target.Clear(); + target.SelectedIndex = 2; + } + + Assert.Equal(0, raised); + } + } + + public class LostSelection + { + [Fact] + public void Can_Select_First_Item_On_LostSelection() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + target.SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Equal(new[] { 0 }, e.SelectedIndexes); + Assert.Equal(new[] { "foo" }, e.SelectedItems); + ++raised; + }; + + target.LostSelection += (s, e) => + { + target.Select(0); + }; + + target.Clear(); + + Assert.Equal(1, raised); + } + } + + public class UntypedInterface + { + [Fact] + public void Raises_Untyped_SelectionChanged_Event() + { + var target = CreateTarget(); + var raised = 0; + + target.SelectedIndex = 1; + + ((ISelectionModel)target).SelectionChanged += (s, e) => + { + Assert.Equal(new[] { 1 }, e.DeselectedIndexes); + Assert.Equal(new[] { "bar" }, e.DeselectedItems); + Assert.Equal(new[] { 2 }, e.SelectedIndexes); + Assert.Equal(new[] { "baz" }, e.SelectedItems); + ++raised; + }; + + target.SelectedIndex = 2; + + Assert.Equal(1, raised); + } + } + + private static SelectionModel CreateTarget(bool createData = true) + { + var result = new SelectionModel { SingleSelect = true }; + + if (createData) + { + result.Source = new AvaloniaList { "foo", "bar", "baz" }; + } + + return result; + } + } +}