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;
+ }
+ }
+}