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; private IList? _initSelectedItems; public SelectionModel() { } public SelectionModel(IEnumerable? source) { Source = source; } public new IEnumerable? Source { get => base.Source as IEnumerable; set => SetSource(value); } public bool SingleSelect { get => _singleSelect; set { if (_singleSelect != value) { if (value == true) { using var update = BatchUpdate(); var selectedIndex = SelectedIndex; Clear(); SelectedIndex = selectedIndex; } _singleSelect = value; RangesEnabled = !value; if (RangesEnabled && _selectedIndex >= 0) { CommitSelect(new IndexRange(_selectedIndex)); } RaisePropertyChanged(nameof(SingleSelect)); } } } public int SelectedIndex { get => _selectedIndex; set { using var update = BatchUpdate(); Clear(); Select(value); } } public IReadOnlyList SelectedIndexes => _selectedIndexes ??= new SelectedIndexes(this); [MaybeNull, AllowNull] public T SelectedItem { get { if (ItemsView is object) { return GetItemAt(_selectedIndex); } else if (_initSelectedItems is object && _initSelectedItems.Count > 0) { return (T)_initSelectedItems[0]; } return default; } set { if (ItemsView is object) { SelectedIndex = ItemsView.IndexOf(value!); } else { Clear(); #pragma warning disable CS8601 SetInitSelectedItems(new T[] { value }); #pragma warning restore CS8601 } } } public IReadOnlyList SelectedItems { get { if (ItemsView is null && _initSelectedItems is object) { return _initSelectedItems is IReadOnlyList i ? i : _initSelectedItems.Cast().ToList(); } return _selectedItems ??= new SelectedItems(this); } } public int AnchorIndex { get => _anchorIndex; set { using var update = BatchUpdate(); var index = CoerceIndex(value); update.Operation.AnchorIndex = index; } } public int Count { get { if (SingleSelect) { return _selectedIndex >= 0 ? 1 : 0; } else { return IndexRange.GetCount(Ranges); } } } IEnumerable? ISelectionModel.Source { get => Source; set => SetSource(value); } object? ISelectionModel.SelectedItem { get => SelectedItem; set { if (value is T t) { SelectedItem = t; } else { SelectedIndex = -1; } } } IReadOnlyList ISelectionModel.SelectedItems { get => _selectedItemsUntyped ??= new SelectedItems.Untyped(SelectedItems); } public event EventHandler? IndexesChanged; public event EventHandler>? SelectionChanged; public event EventHandler? LostSelection; public event EventHandler? SourceReset; public event PropertyChangedEventHandler? PropertyChanged; event EventHandler? ISelectionModel.SelectionChanged { add => _untypedSelectionChanged += value; remove => _untypedSelectionChanged -= value; } public BatchUpdateOperation BatchUpdate() => new BatchUpdateOperation(this); public void BeginBatchUpdate() { _operation ??= new Operation(this); ++_operation.UpdateCount; } public void EndBatchUpdate() { if (_operation is null || _operation.UpdateCount == 0) { throw new InvalidOperationException("No batch update in progress."); } if (--_operation.UpdateCount == 0) { // If the collection is currently changing, commit the update when the // collection change finishes. if (!IsSourceCollectionChanging) { CommitOperation(_operation); } } } public bool IsSelected(int index) { if (index < 0) { return false; } else if (SingleSelect) { return _selectedIndex == index; } else { return IndexRange.Contains(Ranges, index); } } public void Select(int index) => SelectRange(index, index, false, true); public void Deselect(int index) => DeselectRange(index, index); public void SelectRange(int start, int end) => SelectRange(start, end, false, false); public void DeselectRange(int start, int end) { using var update = BatchUpdate(); var o = update.Operation; var range = new IndexRange(Math.Max(0, start), end); 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; } _initSelectedItems = null; } public void SelectAll() => SelectRange(0, int.MaxValue); public void Clear() => DeselectRange(0, int.MaxValue); protected void RaisePropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } private protected virtual void SetSource(IEnumerable? value) { if (base.Source != value) { if (_operation is object) { throw new InvalidOperationException("Cannot change source while update is in progress."); } if (base.Source is object && value is object) { using var update = BatchUpdate(); update.Operation.SkipLostSelection = true; Clear(); } base.Source = value; using (var update = BatchUpdate()) { update.Operation.IsSourceUpdate = true; if (_initSelectedItems is object && ItemsView is object) { foreach (T i in _initSelectedItems) { Select(ItemsView.IndexOf(i)); } _initSelectedItems = null; } else { TrimInvalidSelections(update.Operation); } RaisePropertyChanged(nameof(Source)); } } } private protected override void OnIndexesChanged(int shiftIndex, int shiftDelta) { IndexesChanged?.Invoke(this, new SelectionModelIndexesChangedEventArgs(shiftIndex, shiftDelta)); } private protected override void OnSourceReset() { _selectedIndex = _anchorIndex = -1; CommitDeselect(new IndexRange(0, int.MaxValue)); if (SourceReset is object) { SourceReset.Invoke(this, EventArgs.Empty); } else { //Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log( // this, // "SelectionModel received Reset but no SourceReset handler was registered to handle it. " + // "Selection may be out of sync.", // typeof(SelectionModel)); } } private protected override void OnSelectionChanged(IReadOnlyList deselectedItems) { // Note: We're *not* putting this in a using scope. A collection update is still in progress // so the operation won't get commited by normal means: we have to commit it manually. var update = BatchUpdate(); update.Operation.DeselectedItems = deselectedItems; if (_selectedIndex == -1 && LostSelection is object) { LostSelection(this, EventArgs.Empty); } // Don't raise PropertyChanged events here as the OnSourceCollectionChanged event that // let to this method being called will raise them if necessary. CommitOperation(update.Operation, raisePropertyChanged: false); } 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 ((e.Action == NotifyCollectionChangedAction.Remove && e.OldStartingIndex <= oldSelectedIndex) || (e.Action == NotifyCollectionChangedAction.Replace && e.OldStartingIndex == oldSelectedIndex) || e.Action == NotifyCollectionChangedAction.Reset) { RaisePropertyChanged(nameof(SelectedItem)); } if (oldAnchorIndex != _anchorIndex) { RaisePropertyChanged(nameof(AnchorIndex)); } } private protected override bool IsValidCollectionChange(NotifyCollectionChangedEventArgs e) { if (!base.IsValidCollectionChange(e)) { return false; } if (ItemsView is object && e.Action == NotifyCollectionChangedAction.Add) { if (e.NewStartingIndex <= _selectedIndex) { return _selectedIndex + e.NewItems.Count < ItemsView.Count; } if (e.NewStartingIndex <= _anchorIndex) { return _anchorIndex + e.NewItems.Count < ItemsView.Count; } } return true; } private protected void SetInitSelectedItems(IList items) { if (Source is object) { throw new InvalidOperationException("Cannot set init selected items when Source is set."); } _initSelectedItems = items; } 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; } _initSelectedItems = null; } [return: MaybeNull] private T GetItemAt(int index) { if (ItemsView is null || index < 0 || index >= ItemsView.Count) { return default; } return ItemsView[index]; } private int CoerceIndex(int index) { index = Math.Max(index, -1); if (ItemsView is object && index >= ItemsView.Count) { index = -1; } return index; } private IndexRange CoerceRange(int start, int end) { var max = ItemsView is object ? ItemsView.Count - 1 : int.MaxValue; if (start > max || (start < 0 && end < 0)) { return new IndexRange(-1); } start = Math.Max(start, 0); end = Math.Min(end, max); return new IndexRange(start, end); } private void TrimInvalidSelections(Operation operation) { if (ItemsView is null) { return; } var max = ItemsView.Count - 1; if (operation.SelectedIndex > max) { operation.SelectedIndex = GetFirstSelectedIndexFromRanges(); } if (operation.AnchorIndex > max) { operation.AnchorIndex = GetFirstSelectedIndexFromRanges(); } if (RangesEnabled && Ranges.Count > 0) { var selected = Ranges.ToList(); if (max < 0) { operation.DeselectedRanges = selected; } else { var valid = new IndexRange(0, max); var removed = new List(); IndexRange.Intersect(selected, valid, removed); operation.DeselectedRanges = removed; } } } private void CommitOperation(Operation operation, bool raisePropertyChanged = true) { try { var oldAnchorIndex = _anchorIndex; var oldSelectedIndex = _selectedIndex; var indexesChanged = false; if (operation.SelectedIndex == -1 && LostSelection is object && !operation.SkipLostSelection) { operation.UpdateCount++; LostSelection?.Invoke(this, EventArgs.Empty); } _selectedIndex = operation.SelectedIndex; _anchorIndex = operation.AnchorIndex; if (operation.SelectedRanges is object) { indexesChanged |= CommitSelect(operation.SelectedRanges) > 0; } if (operation.DeselectedRanges is object) { indexesChanged |= CommitDeselect(operation.DeselectedRanges) > 0; } if (SelectionChanged is object || _untypedSelectionChanged is object) { IReadOnlyList? deselected = operation.DeselectedRanges; IReadOnlyList? selected = operation.SelectedRanges; if (SingleSelect && oldSelectedIndex != _selectedIndex) { if (oldSelectedIndex != -1) { deselected = new[] { new IndexRange(oldSelectedIndex) }; } if (_selectedIndex != -1) { selected = new[] { new IndexRange(_selectedIndex) }; } } if (deselected?.Count > 0 || selected?.Count > 0 || operation.DeselectedItems is object) { // If the operation was caused by Source being updated, then use a null source // so that the items will appear as nulls. var deselectedSource = operation.IsSourceUpdate ? null : ItemsView; // If the operation contains DeselectedItems then we're notifying a source // CollectionChanged event. LostFocus may have caused another item to have been // selected, but it can't have caused a deselection (as it was called due to // selection being lost) so we're ok to discard `deselected` here. var deselectedItems = operation.DeselectedItems ?? SelectedItems.Create(deselected, deselectedSource); var e = new SelectionModelSelectionChangedEventArgs( SelectedIndexes.Create(deselected), SelectedIndexes.Create(selected), deselectedItems, SelectedItems.Create(selected, ItemsView)); SelectionChanged?.Invoke(this, e); _untypedSelectionChanged?.Invoke(this, e); } } if (raisePropertyChanged) { if (oldSelectedIndex != _selectedIndex) { indexesChanged = true; RaisePropertyChanged(nameof(SelectedIndex)); } if (oldSelectedIndex != _selectedIndex || operation.IsSourceUpdate) { RaisePropertyChanged(nameof(SelectedItem)); } if (oldAnchorIndex != _anchorIndex) { indexesChanged = true; RaisePropertyChanged(nameof(AnchorIndex)); } if (indexesChanged) { RaisePropertyChanged(nameof(SelectedIndexes)); } if (indexesChanged || operation.IsSourceUpdate) { RaisePropertyChanged(nameof(SelectedItems)); } } } finally { _operation = null; } } public struct BatchUpdateOperation : IDisposable { private readonly SelectionModel _owner; private bool _isDisposed; public BatchUpdateOperation(SelectionModel owner) { _owner = owner; _isDisposed = false; owner.BeginBatchUpdate(); } internal Operation Operation => _owner._operation!; public void Dispose() { if (!_isDisposed) { _owner?.EndBatchUpdate(); _isDisposed = true; } } } internal class Operation { public Operation(SelectionModel owner) { AnchorIndex = owner.AnchorIndex; SelectedIndex = owner.SelectedIndex; } public int UpdateCount { get; set; } public bool IsSourceUpdate { get; set; } public bool SkipLostSelection { get; set; } public int AnchorIndex { get; set; } public int SelectedIndex { get; set; } public List? SelectedRanges { get; set; } public List? DeselectedRanges { get; set; } public IReadOnlyList? DeselectedItems { get; set; } } } }