Browse Source

Reimplemented SelectionModel.

Handles only list selections, not nested selections.
pull/4533/head
Steven Kirk 6 years ago
parent
commit
e62bacab7e
  1. 3
      src/Avalonia.Controls/Avalonia.Controls.csproj
  2. 89
      src/Avalonia.Controls/Repeater/ItemsSourceView.cs
  3. 48
      src/Avalonia.Controls/Selection/ISelectionModel.cs
  4. 343
      src/Avalonia.Controls/Selection/IndexRange.cs
  5. 82
      src/Avalonia.Controls/Selection/SelectedIndexes.cs
  6. 121
      src/Avalonia.Controls/Selection/SelectedItems.cs
  7. 632
      src/Avalonia.Controls/Selection/SelectionModel.cs
  8. 18
      src/Avalonia.Controls/Selection/SelectionModelIndexesChangedEventArgs.cs
  9. 85
      src/Avalonia.Controls/Selection/SelectionModelSelectionChangedEventArgs.cs
  10. 286
      src/Avalonia.Controls/Selection/SelectionNodeBase.cs
  11. 135
      src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs
  12. 1474
      tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs
  13. 1021
      tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs

3
src/Avalonia.Controls/Avalonia.Controls.csproj

@ -2,6 +2,9 @@
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Avalonia.Base\Metadata\NullableAttributes.cs" Link="NullableAttributes.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Avalonia.Animation\Avalonia.Animation.csproj" />
<ProjectReference Include="..\Avalonia.Base\Avalonia.Base.csproj" />

89
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.
/// </remarks>
public class ItemsSourceView : INotifyCollectionChanged, IDisposable
public class ItemsSourceView<T> : INotifyCollectionChanged, IDisposable, IReadOnlyList<T>
{
private readonly IList _inner;
private INotifyCollectionChanged _notifyCollectionChanged;
/// <summary>
/// Gets an empty <see cref="ItemsSourceView"/>
/// </summary>
public static ItemsSourceView<T> Empty { get; } = new ItemsSourceView<T>(Array.Empty<T>());
private readonly IList<T> _inner;
private INotifyCollectionChanged? _notifyCollectionChanged;
/// <summary>
/// Initializes a new instance of the ItemsSourceView class for the specified data source.
/// </summary>
/// <param name="source">The data source.</param>
public ItemsSourceView(IEnumerable source)
public ItemsSourceView(IEnumerable<T> source)
: this((IEnumerable)source)
{
Contract.Requires<ArgumentNullException>(source != null);
}
if (source is IList list)
private protected ItemsSourceView(IEnumerable source)
{
source = source ?? throw new ArgumentNullException(nameof(source));
if (source is IList<T> list)
{
_inner = list;
}
else if (source is IEnumerable<object> objectEnumerable)
else if (source is IEnumerable<T> objectEnumerable)
{
_inner = new List<object>(objectEnumerable);
_inner = new List<T>(objectEnumerable);
}
else
{
_inner = new List<object>(source.Cast<object>());
_inner = new List<T>(source.Cast<T>());
}
ListenToCollectionChanges();
@ -63,10 +76,17 @@ namespace Avalonia.Controls
/// </remarks>
public bool HasKeyIndexMapping => false;
/// <summary>
/// Retrieves the item at the specified index.
/// </summary>
/// <param name="index">The index.</param>
/// <returns>The item.</returns>
public T this[int index] => GetAt(index);
/// <summary>
/// Occurs when the collection has changed to indicate the reason for the change and which items changed.
/// </summary>
public event NotifyCollectionChangedEventHandler CollectionChanged;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
/// <inheritdoc/>
public void Dispose()
@ -81,10 +101,26 @@ namespace Avalonia.Controls
/// Retrieves the item at the specified index.
/// </summary>
/// <param name="index">The index.</param>
/// <returns>the item.</returns>
public object GetAt(int index) => _inner[index];
/// <returns>The item.</returns>
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<T> GetOrCreate(IEnumerable<T>? items)
{
if (items is ItemsSourceView<T> isv)
{
return isv;
}
else if (items is null)
{
return Empty;
}
else
{
return new ItemsSourceView<T>(items);
}
}
/// <summary>
/// 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<T> 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<object>
{
public ItemsSourceView(IEnumerable source)
: base(source)
{
}
}
}

48
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<int> SelectedIndexes { get; }
object? SelectedItem { get; }
IReadOnlyList<object?> SelectedItems { get; }
int AnchorIndex { get; set; }
int Count { get; }
public event EventHandler<SelectionModelIndexesChangedEventArgs>? IndexesChanged;
public event EventHandler<SelectionModelSelectionChangedEventArgs>? 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);
}
}
}

343
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<IndexRange>
{
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<IndexRange>? 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<IndexRange> 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<IndexRange> ranges,
IndexRange range,
IList<IndexRange>? 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<IndexRange> destination,
IReadOnlyList<IndexRange> source,
IList<IndexRange>? added = null)
{
var result = 0;
foreach (var range in source)
{
result += Add(destination, range, added);
}
return result;
}
public static int Intersect(
IList<IndexRange> ranges,
IndexRange range,
IList<IndexRange>? 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<IndexRange>? ranges,
IndexRange range,
IList<IndexRange>? 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<IndexRange> destination,
IReadOnlyList<IndexRange> source,
IList<IndexRange>? added = null)
{
var result = 0;
foreach (var range in source)
{
result += Remove(destination, range, added);
}
return result;
}
public static IEnumerable<int> EnumerateIndices(IEnumerable<IndexRange> ranges)
{
foreach (var range in ranges)
{
for (var i = range.Begin; i <= range.End; ++i)
{
yield return i;
}
}
}
public static int GetCount(IEnumerable<IndexRange> ranges)
{
var result = 0;
foreach (var range in ranges)
{
result += (range.End - range.Begin) + 1;
}
return result;
}
private static void MergeRanges(IList<IndexRange> 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);
}
}
}
}
}

82
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<T> : IReadOnlyList<int>
{
private readonly SelectionModel<T>? _owner;
private readonly IReadOnlyList<IndexRange>? _ranges;
public SelectedIndexes(SelectionModel<T> owner) => _owner = owner;
public SelectedIndexes(IReadOnlyList<IndexRange> 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<IndexRange> Ranges => _ranges ?? _owner!.Ranges!;
public IEnumerator<int> GetEnumerator()
{
IEnumerator<int> SingleSelect()
{
if (_owner.SelectedIndex >= 0)
{
yield return _owner.SelectedIndex;
}
}
if (_owner?.SingleSelect == true)
{
return SingleSelect();
}
else
{
return IndexRange.EnumerateIndices(Ranges).GetEnumerator();
}
}
public static SelectedIndexes<T>? Create(IReadOnlyList<IndexRange>? ranges)
{
return ranges is object ? new SelectedIndexes<T>(ranges) : null;
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}

121
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<T> : IReadOnlyList<T>
{
private readonly SelectionModel<T>? _owner;
private readonly ItemsSourceView<T>? _items;
private readonly IReadOnlyList<IndexRange>? _ranges;
public SelectedItems(SelectionModel<T> owner) => _owner = owner;
public SelectedItems(IReadOnlyList<IndexRange> ranges, ItemsSourceView<T>? 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<T>? Items => _items ?? _owner?.ItemsView;
private IReadOnlyList<IndexRange>? Ranges => _ranges ?? _owner!.Ranges;
public IEnumerator<T> 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<T>? Create(
IReadOnlyList<IndexRange>? ranges,
ItemsSourceView<T>? items)
{
return ranges is object ? new SelectedItems<T>(ranges, items) : null;
}
public class Untyped : IReadOnlyList<object?>
{
private readonly IReadOnlyList<T> _source;
public Untyped(IReadOnlyList<T> source) => _source = source;
public object? this[int index] => _source[index];
public int Count => _source.Count;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public IEnumerator<object?> GetEnumerator()
{
foreach (var i in _source)
{
yield return i;
}
}
}
}
}

632
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<T> : SelectionNodeBase<T>, ISelectionModel
{
private bool _singleSelect = true;
private int _anchorIndex = -1;
private int _selectedIndex = -1;
private Operation? _operation;
private SelectedIndexes<T>? _selectedIndexes;
private SelectedItems<T>? _selectedItems;
private SelectedItems<T>.Untyped? _selectedItemsUntyped;
private EventHandler<SelectionModelSelectionChangedEventArgs>? _untypedSelectionChanged;
public SelectionModel()
{
}
public SelectionModel(IEnumerable<T>? source)
{
Source = source;
}
public override IEnumerable<T>? 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<int> SelectedIndexes => _selectedIndexes ??= new SelectedIndexes<T>(this);
[MaybeNull]
public T SelectedItem => GetItemAt(_selectedIndex);
public IReadOnlyList<T> SelectedItems => _selectedItems ??= new SelectedItems<T>(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<T>?)value;
}
object? ISelectionModel.SelectedItem => SelectedItem;
IReadOnlyList<object?> ISelectionModel.SelectedItems
{
get => _selectedItemsUntyped ??= new SelectedItems<T>.Untyped(SelectedItems);
}
public event EventHandler<SelectionModelIndexesChangedEventArgs>? IndexesChanged;
public event EventHandler<SelectionModelSelectionChangedEventArgs<T>>? SelectionChanged;
public event EventHandler? LostSelection;
public event EventHandler? SourceReset;
public event PropertyChangedEventHandler? PropertyChanged;
event EventHandler<SelectionModelSelectionChangedEventArgs>? 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<IndexRange>();
var operationDeselected = new List<IndexRange>();
o.DeselectedRanges ??= new List<IndexRange>();
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<T> deselectedItems)
{
if (SelectionChanged is object || _untypedSelectionChanged is object)
{
var e = new SelectionModelSelectionChangedEventArgs<T>(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<T>? 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> { (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<IndexRange>? 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<IndexRange>();
if (RangesEnabled)
{
o.SelectedRanges ??= new List<IndexRange>();
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>();
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<IndexRange>? deselected = operation.DeselectedRanges;
IReadOnlyList<IndexRange>? 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<T>(
SelectedIndexes<T>.Create(deselected),
SelectedIndexes<T>.Create(selected),
SelectedItems<T>.Create(deselected, deselectedSource),
SelectedItems<T>.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<T> _owner;
private bool _isDisposed;
public BatchUpdateOperation(SelectionModel<T> 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<T> 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<IndexRange>? SelectedRanges { get; set; }
public List<IndexRange>? DeselectedRanges { get; set; }
}
}
}

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

85
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
{
/// <summary>
/// Gets the indexes of the items that were removed from the selection.
/// </summary>
public abstract IReadOnlyList<int> DeselectedIndexes { get; }
/// <summary>
/// Gets the indexes of the items that were added to the selection.
/// </summary>
public abstract IReadOnlyList<int> SelectedIndexes { get; }
/// <summary>
/// Gets the items that were removed from the selection.
/// </summary>
public IReadOnlyList<object?> DeselectedItems => GetUntypedDeselectedItems();
/// <summary>
/// Gets the items that were added to the selection.
/// </summary>
public IReadOnlyList<object?> SelectedItems => GetUntypedSelectedItems();
protected abstract IReadOnlyList<object?> GetUntypedDeselectedItems();
protected abstract IReadOnlyList<object?> GetUntypedSelectedItems();
}
public class SelectionModelSelectionChangedEventArgs<T> : SelectionModelSelectionChangedEventArgs
{
private IReadOnlyList<object?>? _deselectedItems;
private IReadOnlyList<object?>? _selectedItems;
public SelectionModelSelectionChangedEventArgs(
IReadOnlyList<int>? deselectedIndices = null,
IReadOnlyList<int>? selectedIndices = null,
IReadOnlyList<T>? deselectedItems = null,
IReadOnlyList<T>? selectedItems = null)
{
DeselectedIndexes = deselectedIndices ?? Array.Empty<int>();
SelectedIndexes = selectedIndices ?? Array.Empty<int>();
DeselectedItems = deselectedItems ?? Array.Empty<T>();
SelectedItems = selectedItems ?? Array.Empty<T>();
}
/// <summary>
/// Gets the indexes of the items that were removed from the selection.
/// </summary>
public override IReadOnlyList<int> DeselectedIndexes { get; }
/// <summary>
/// Gets the indexes of the items that were added to the selection.
/// </summary>
public override IReadOnlyList<int> SelectedIndexes { get; }
/// <summary>
/// Gets the items that were removed from the selection.
/// </summary>
public new IReadOnlyList<T> DeselectedItems { get; }
/// <summary>
/// Gets the items that were added to the selection.
/// </summary>
public new IReadOnlyList<T> SelectedItems { get; }
protected override IReadOnlyList<object?> GetUntypedDeselectedItems()
{
return _deselectedItems ??= (DeselectedItems as IReadOnlyList<object?>) ??
new SelectedItems<T>.Untyped(DeselectedItems);
}
protected override IReadOnlyList<object?> GetUntypedSelectedItems()
{
return _selectedItems ??= (SelectedItems as IReadOnlyList<object?>) ??
new SelectedItems<T>.Untyped(SelectedItems);
}
}
}

286
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<T> : ICollectionChangedListener
{
private IEnumerable<T>? _source;
private bool _rangesEnabled;
private List<IndexRange>? _ranges;
private int _collectionChanging;
public virtual IEnumerable<T>? Source
{
get => _source;
set
{
if (_source != value)
{
ItemsView?.RemoveListener(this);
_source = value;
ItemsView = value is object ? ItemsSourceView<T>.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<T>? ItemsView { get; set; }
internal IReadOnlyList<IndexRange> Ranges
{
get
{
if (!RangesEnabled)
{
throw new InvalidOperationException("Ranges not enabled.");
}
return _ranges ??= new List<IndexRange>();
}
}
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<T> deselectedItems);
private protected int CommitSelect(IndexRange range)
{
if (RangesEnabled)
{
_ranges ??= new List<IndexRange>();
return IndexRange.Add(_ranges, range);
}
return 0;
}
private protected int CommitSelect(IReadOnlyList<IndexRange> ranges)
{
if (RangesEnabled)
{
_ranges ??= new List<IndexRange>();
return IndexRange.Add(_ranges, ranges);
}
return 0;
}
private protected int CommitDeselect(IndexRange range)
{
if (RangesEnabled)
{
_ranges ??= new List<IndexRange>();
return IndexRange.Remove(_ranges, range);
}
return 0;
}
private protected int CommitDeselect(IReadOnlyList<IndexRange> 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<IndexRange>? 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<IndexRange>()).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<T>? removed = null;
if (_ranges is object)
{
var deselected = new List<IndexRange>();
if (IndexRange.Remove(_ranges, removedRange, deselected) > 0)
{
removed = new List<T>();
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<T>? 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<T>? RemovedItems;
}
}
}

135
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<NotifyCollectionChangedEventArgs>
{
public static CollectionChangedEventManager Instance { get; } = new CollectionChangedEventManager();
private ConditionalWeakTable<INotifyCollectionChanged, List<WeakReference<ICollectionChangedListener>>> _entries =
new ConditionalWeakTable<INotifyCollectionChanged, List<WeakReference<ICollectionChangedListener>>>();
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<WeakReference<ICollectionChangedListener>>();
_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<ICollectionChangedListener>(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<NotifyCollectionChangedEventArgs>.OnEvent(object sender, NotifyCollectionChangedEventArgs e)
{
static void Notify(
INotifyCollectionChanged incc,
NotifyCollectionChangedEventArgs args,
List<WeakReference<ICollectionChangedListener>> 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));
}
}
}
}
}

1474
tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Multiple.cs

File diff suppressed because it is too large

1021
tests/Avalonia.Controls.UnitTests/Selection/SelectionModelTests_Single.cs

File diff suppressed because it is too large
Loading…
Cancel
Save