using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using Avalonia.Controls.Generators; using Avalonia.Controls.Selection; using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.VisualTree; #nullable enable namespace Avalonia.Controls.Primitives { /// /// An that maintains a selection. /// /// /// /// provides a base class for s /// that maintain a selection (single or multiple). By default only its /// and properties are visible; the /// current multiple and together with the /// properties are protected, however a derived class can expose /// these if it wishes to support multiple selection. /// /// /// maintains a selection respecting the current /// but it does not react to user input; this must be handled in a /// derived class. It does, however, respond to events /// from items and updates the selection accordingly. /// /// public class SelectingItemsControl : ItemsControl { /// /// Defines the property. /// public static readonly StyledProperty AutoScrollToSelectedItemProperty = AvaloniaProperty.Register( nameof(AutoScrollToSelectedItem), defaultValue: true); /// /// Defines the property. /// public static readonly DirectProperty SelectedIndexProperty = AvaloniaProperty.RegisterDirect( nameof(SelectedIndex), o => o.SelectedIndex, (o, v) => o.SelectedIndex = v, unsetValue: -1, defaultBindingMode: BindingMode.TwoWay); /// /// Defines the property. /// public static readonly DirectProperty SelectedItemProperty = AvaloniaProperty.RegisterDirect( nameof(SelectedItem), o => o.SelectedItem, (o, v) => o.SelectedItem = v, defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true); /// /// Defines the property. /// protected static readonly DirectProperty SelectedItemsProperty = AvaloniaProperty.RegisterDirect( nameof(SelectedItems), o => o.SelectedItems, (o, v) => o.SelectedItems = v); /// /// Defines the property. /// protected static readonly DirectProperty SelectionProperty = AvaloniaProperty.RegisterDirect( nameof(Selection), o => o.Selection, (o, v) => o.Selection = v); /// /// Defines the property. /// protected static readonly StyledProperty SelectionModeProperty = AvaloniaProperty.Register( nameof(SelectionMode)); /// /// Event that should be raised by items that implement to /// notify the parent that their selection state /// has changed. /// public static readonly RoutedEvent IsSelectedChangedEvent = RoutedEvent.Register( "IsSelectedChanged", RoutingStrategies.Bubble); /// /// Defines the event. /// public static readonly RoutedEvent SelectionChangedEvent = RoutedEvent.Register( "SelectionChanged", RoutingStrategies.Bubble); private static readonly IList Empty = Array.Empty(); private ISelectionModel? _selection; private int _oldSelectedIndex; private object? _oldSelectedItem; private IList? _oldSelectedItems; private bool _ignoreContainerSelectionChanged; private UpdateState? _updateState; private bool _hasScrolledToSelectedItem; /// /// Initializes static members of the class. /// static SelectingItemsControl() { IsSelectedChangedEvent.AddClassHandler((x, e) => x.ContainerSelectionChanged(e)); } /// /// Occurs when the control's selection changes. /// public event EventHandler SelectionChanged { add { AddHandler(SelectionChangedEvent, value); } remove { RemoveHandler(SelectionChangedEvent, value); } } /// /// Gets or sets a value indicating whether to automatically scroll to newly selected items. /// public bool AutoScrollToSelectedItem { get { return GetValue(AutoScrollToSelectedItemProperty); } set { SetValue(AutoScrollToSelectedItemProperty, value); } } /// /// Gets or sets the index of the selected item. /// public int SelectedIndex { get { // When a Begin/EndInit/DataContext update is in place we return the value to be // updated here, even though it's not yet active and the property changed notification // has not yet been raised. If we don't do this then the old value will be written back // to the source when two-way bound, and the update value will be lost. return _updateState?.SelectedIndex.HasValue == true ? _updateState.SelectedIndex.Value : Selection.SelectedIndex; } set { if (_updateState is object) { _updateState.SelectedIndex = value; } else { Selection.SelectedIndex = value; } } } /// /// Gets or sets the selected item. /// public object? SelectedItem { get { // See SelectedIndex setter for more information. return _updateState?.SelectedItem.HasValue == true ? _updateState.SelectedItem.Value : Selection.SelectedItem; } set { if (_updateState is object) { _updateState.SelectedItem = value; } else { Selection.SelectedItem = value; } } } /// /// Gets or sets the selected items. /// /// /// By default returns a collection that can be modified in order to manipulate the control /// selection, however this property will return null if is /// re-assigned; you should only use _either_ Selection or SelectedItems. /// protected IList? SelectedItems { get { // See SelectedIndex setter for more information. if (_updateState?.SelectedItems.HasValue == true) { return _updateState.SelectedItems.Value; } else if (Selection is InternalSelectionModel ism) { var result = ism.WritableSelectedItems; _oldSelectedItems = result; return result; } return null; } set { if (_updateState is object) { _updateState.SelectedItems = new Optional(value); } else if (Selection is InternalSelectionModel i) { i.WritableSelectedItems = value; } else { throw new InvalidOperationException("Cannot set both Selection and SelectedItems."); } } } /// /// Gets or sets the model that holds the current selection. /// protected ISelectionModel Selection { get { if (_updateState?.Selection.HasValue == true) { return _updateState.Selection.Value; } else { if (_selection is null) { _selection = CreateDefaultSelectionModel(); InitializeSelectionModel(_selection); } return _selection; } } set { value ??= CreateDefaultSelectionModel(); if (_updateState is object) { _updateState.Selection = new Optional(value); } else if (_selection != value) { if (value.Source != null && value.Source != Items) { throw new ArgumentException( "The supplied ISelectionModel already has an assigned Source but this " + "collection is different to the Items on the control."); } var oldSelection = _selection?.SelectedItems.ToList(); DeinitializeSelectionModel(_selection); _selection = value; if (oldSelection?.Count > 0) { RaiseEvent(new SelectionChangedEventArgs( SelectionChangedEvent, oldSelection, Array.Empty())); } InitializeSelectionModel(_selection); if (_oldSelectedItems != SelectedItems) { RaisePropertyChanged( SelectedItemsProperty, new Optional(_oldSelectedItems), new BindingValue(SelectedItems)); _oldSelectedItems = SelectedItems; } } } } /// /// Gets or sets the selection mode. /// /// /// Note that the selection mode only applies to selections made via user interaction. /// Multiple selections can be made programatically regardless of the value of this property. /// protected SelectionMode SelectionMode { get { return GetValue(SelectionModeProperty); } set { SetValue(SelectionModeProperty, value); } } /// /// Gets a value indicating whether is set. /// protected bool AlwaysSelected => SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected); /// public override void BeginInit() { base.BeginInit(); BeginUpdating(); } /// public override void EndInit() { base.EndInit(); EndUpdating(); } /// /// Scrolls the specified item into view. /// /// The index of the item. public void ScrollIntoView(int index) => Presenter?.ScrollIntoView(index); /// /// Scrolls the specified item into view. /// /// The item. public void ScrollIntoView(object item) => ScrollIntoView(IndexOf(Items, item)); /// /// Tries to get the container that was the source of an event. /// /// The control that raised the event. /// The container or null if the event did not originate in a container. protected IControl? GetContainerFromEventSource(IInteractive? eventSource) { for (var current = eventSource as IVisual; current != null; current = current.VisualParent) { if (current is IControl control && control.LogicalParent == this && ItemContainerGenerator?.IndexFromContainer(control) != -1) { return control; } } return null; } protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { base.ItemsCollectionChanged(sender, e); if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0) { SelectedIndex = 0; } } protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); AutoScrollToSelectedItemIfNecessary(); } protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); void ExecuteScrollWhenLayoutUpdated(object sender, EventArgs e) { LayoutUpdated -= ExecuteScrollWhenLayoutUpdated; AutoScrollToSelectedItemIfNecessary(); } if (AutoScrollToSelectedItem) { LayoutUpdated += ExecuteScrollWhenLayoutUpdated; } } /// protected override void OnContainersMaterialized(ItemContainerEventArgs e) { base.OnContainersMaterialized(e); foreach (var container in e.Containers) { if ((container.ContainerControl as ISelectable)?.IsSelected == true) { Selection.Select(container.Index); MarkContainerSelected(container.ContainerControl, true); } else { var selected = Selection.IsSelected(container.Index); MarkContainerSelected(container.ContainerControl, selected); } } } /// protected override void OnContainersDematerialized(ItemContainerEventArgs e) { base.OnContainersDematerialized(e); var panel = (InputElement)Presenter.Panel; if (panel != null) { foreach (var container in e.Containers) { if (KeyboardNavigation.GetTabOnceActiveElement(panel) == container.ContainerControl) { KeyboardNavigation.SetTabOnceActiveElement(panel, null); break; } } } } protected override void OnContainersRecycled(ItemContainerEventArgs e) { foreach (var i in e.Containers) { if (i.ContainerControl != null && i.Item != null) { bool selected = Selection.IsSelected(i.Index); MarkContainerSelected(i.ContainerControl, selected); } } } /// protected override void OnDataContextBeginUpdate() { base.OnDataContextBeginUpdate(); BeginUpdating(); } /// protected override void OnDataContextEndUpdate() { base.OnDataContextEndUpdate(); EndUpdating(); } /// /// Called to update the validation state for properties for which data validation is /// enabled. /// /// The property. /// The new binding value for the property. protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value) { if (property == SelectedItemProperty) { DataValidationErrors.SetError(this, value.Error); } } protected override void OnInitialized() { base.OnInitialized(); if (_selection is object) { _selection.Source = Items; } } protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (!e.Handled) { var keymap = AvaloniaLocator.Current.GetService(); bool Match(List gestures) => gestures.Any(g => g.Matches(e)); if (ItemCount > 0 && Match(keymap.SelectAll) && SelectionMode.HasFlagCustom(SelectionMode.Multiple)) { Selection.SelectAll(); e.Handled = true; } } } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == AutoScrollToSelectedItemProperty) { AutoScrollToSelectedItemIfNecessary(); } if (change.Property == ItemsProperty && _updateState is null && _selection is object) { var newValue = change.NewValue.GetValueOrDefault(); _selection.Source = newValue; if (newValue is null) { _selection.Clear(); } } else if (change.Property == SelectionModeProperty && _selection is object) { var newValue = change.NewValue.GetValueOrDefault(); _selection.SingleSelect = !newValue.HasFlagCustom(SelectionMode.Multiple); } } /// /// Moves the selection in the specified direction relative to the current selection. /// /// The direction to move. /// Whether to wrap when the selection reaches the first or last item. /// True if the selection was moved; otherwise false. protected bool MoveSelection(NavigationDirection direction, bool wrap) { var from = SelectedIndex != -1 ? ItemContainerGenerator.ContainerFromIndex(SelectedIndex) : null; return MoveSelection(from, direction, wrap); } /// /// Moves the selection in the specified direction relative to the specified container. /// /// The container which serves as a starting point for the movement. /// The direction to move. /// Whether to wrap when the selection reaches the first or last item. /// True if the selection was moved; otherwise false. protected bool MoveSelection(IControl? from, NavigationDirection direction, bool wrap) { if (Presenter?.Panel is INavigableContainer container && GetNextControl(container, direction, from, wrap) is IControl next) { var index = ItemContainerGenerator.IndexFromContainer(next); if (index != -1) { SelectedIndex = index; return true; } } return false; } /// /// Updates the selection for an item based on user interaction. /// /// The index of the item. /// Whether the item should be selected or unselected. /// Whether the range modifier is enabled (i.e. shift key). /// Whether the toggle modifier is enabled (i.e. ctrl key). /// Whether the event is a right-click. protected void UpdateSelection( int index, bool select = true, bool rangeModifier = false, bool toggleModifier = false, bool rightButton = false) { if (index < 0 || index >= ItemCount) { return; } var mode = SelectionMode; var multi = mode.HasFlagCustom(SelectionMode.Multiple); var toggle = toggleModifier || mode.HasFlagCustom(SelectionMode.Toggle); var range = multi && rangeModifier; if (!select) { Selection.Deselect(index); } else if (rightButton) { if (Selection.IsSelected(index) == false) { SelectedIndex = index; } } else if (range) { using var operation = Selection.BatchUpdate(); Selection.Clear(); Selection.SelectRange(Selection.AnchorIndex, index); } else if (multi && toggle) { if (Selection.IsSelected(index) == true) { Selection.Deselect(index); } else { Selection.Select(index); } } else if (toggle) { SelectedIndex = (SelectedIndex == index) ? -1 : index; } else { using var operation = Selection.BatchUpdate(); Selection.Clear(); Selection.Select(index); } if (Presenter?.Panel != null) { var container = ItemContainerGenerator.ContainerFromIndex(index); KeyboardNavigation.SetTabOnceActiveElement( (InputElement)Presenter.Panel, container); } } /// /// Updates the selection for a container based on user interaction. /// /// The container. /// Whether the container should be selected or unselected. /// Whether the range modifier is enabled (i.e. shift key). /// Whether the toggle modifier is enabled (i.e. ctrl key). /// Whether the event is a right-click. protected void UpdateSelection( IControl container, bool select = true, bool rangeModifier = false, bool toggleModifier = false, bool rightButton = false) { var index = ItemContainerGenerator?.IndexFromContainer(container) ?? -1; if (index != -1) { UpdateSelection(index, select, rangeModifier, toggleModifier, rightButton); } } /// /// Updates the selection based on an event that may have originated in a container that /// belongs to the control. /// /// The control that raised the event. /// Whether the container should be selected or unselected. /// Whether the range modifier is enabled (i.e. shift key). /// Whether the toggle modifier is enabled (i.e. ctrl key). /// Whether the event is a right-click. /// /// True if the event originated from a container that belongs to the control; otherwise /// false. /// protected bool UpdateSelectionFromEventSource( IInteractive? eventSource, bool select = true, bool rangeModifier = false, bool toggleModifier = false, bool rightButton = false) { var container = GetContainerFromEventSource(eventSource); if (container != null) { UpdateSelection(container, select, rangeModifier, toggleModifier, rightButton); return true; } return false; } /// /// Called when is raised on /// . /// /// The sender. /// The event args. private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ISelectionModel.AnchorIndex)) { _hasScrolledToSelectedItem = false; AutoScrollToSelectedItemIfNecessary(); } else if (e.PropertyName == nameof(ISelectionModel.SelectedIndex) && _oldSelectedIndex != SelectedIndex) { RaisePropertyChanged(SelectedIndexProperty, _oldSelectedIndex, SelectedIndex); _oldSelectedIndex = SelectedIndex; } else if (e.PropertyName == nameof(ISelectionModel.SelectedItem) && _oldSelectedItem != SelectedItem) { RaisePropertyChanged(SelectedItemProperty, _oldSelectedItem, SelectedItem); _oldSelectedItem = SelectedItem; } else if (e.PropertyName == nameof(InternalSelectionModel.WritableSelectedItems) && _oldSelectedItems != (Selection as InternalSelectionModel)?.SelectedItems) { RaisePropertyChanged( SelectedItemsProperty, new Optional(_oldSelectedItems), new BindingValue(SelectedItems)); _oldSelectedItems = SelectedItems; } } /// /// Called when event is raised on /// . /// /// The sender. /// The event args. private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) { void Mark(int index, bool selected) { var container = ItemContainerGenerator.ContainerFromIndex(index); if (container != null) { MarkContainerSelected(container, selected); } } foreach (var i in e.SelectedIndexes) { Mark(i, true); } foreach (var i in e.DeselectedIndexes) { Mark(i, false); } var route = BuildEventRoute(SelectionChangedEvent); if (route.HasHandlers) { var ev = new SelectionChangedEventArgs( SelectionChangedEvent, e.DeselectedItems.ToList(), e.SelectedItems.ToList()); RaiseEvent(ev); } } /// /// Called when event is raised on /// . /// /// The sender. /// The event args. private void OnSelectionModelLostSelection(object sender, EventArgs e) { if (AlwaysSelected && Items is object) { SelectedIndex = 0; } } private void AutoScrollToSelectedItemIfNecessary() { if (AutoScrollToSelectedItem && !_hasScrolledToSelectedItem && Presenter is object && Selection.AnchorIndex >= 0 && ((IVisual)this).IsAttachedToVisualTree) { ScrollIntoView(Selection.AnchorIndex); _hasScrolledToSelectedItem = true; } } /// /// Called when a container raises the . /// /// The event. private void ContainerSelectionChanged(RoutedEventArgs e) { if (!_ignoreContainerSelectionChanged && e.Source is IControl control && e.Source is ISelectable selectable && control.LogicalParent == this && ItemContainerGenerator?.IndexFromContainer(control) != -1) { UpdateSelection(control, selectable.IsSelected); } if (e.Source != this) { e.Handled = true; } } /// /// Sets a container's 'selected' class or . /// /// The container. /// Whether the control is selected /// The previous selection state. private bool MarkContainerSelected(IControl container, bool selected) { try { bool result; _ignoreContainerSelectionChanged = true; if (container is ISelectable selectable) { result = selectable.IsSelected; selectable.IsSelected = selected; } else { result = container.Classes.Contains(":selected"); ((IPseudoClasses)container.Classes).Set(":selected", selected); } return result; } finally { _ignoreContainerSelectionChanged = false; } } private void UpdateContainerSelection() { if (Presenter?.Panel is IPanel panel) { foreach (var container in panel.Children) { MarkContainerSelected( container, Selection.IsSelected(ItemContainerGenerator.IndexFromContainer(container))); } } } private ISelectionModel CreateDefaultSelectionModel() { return new InternalSelectionModel { SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple), }; } private void InitializeSelectionModel(ISelectionModel model) { if (_updateState is null) { model.Source = Items; } model.PropertyChanged += OnSelectionModelPropertyChanged; model.SelectionChanged += OnSelectionModelSelectionChanged; model.LostSelection += OnSelectionModelLostSelection; if (model.SingleSelect) { SelectionMode &= ~SelectionMode.Multiple; } else { SelectionMode |= SelectionMode.Multiple; } _oldSelectedIndex = model.SelectedIndex; _oldSelectedItem = model.SelectedItem; if (AlwaysSelected && model.Count == 0) { model.SelectedIndex = 0; } UpdateContainerSelection(); if (SelectedIndex != -1) { RaiseEvent(new SelectionChangedEventArgs( SelectionChangedEvent, Array.Empty(), Selection.SelectedItems.ToList())); } } private void DeinitializeSelectionModel(ISelectionModel? model) { if (model is object) { model.PropertyChanged -= OnSelectionModelPropertyChanged; model.SelectionChanged -= OnSelectionModelSelectionChanged; } } private void BeginUpdating() { _updateState ??= new UpdateState(); _updateState.UpdateCount++; } private void EndUpdating() { if (_updateState is object && --_updateState.UpdateCount == 0) { var state = _updateState; _updateState = null; if (state.Selection.HasValue) { Selection = state.Selection.Value; } if (state.SelectedItems.HasValue) { SelectedItems = state.SelectedItems.Value; } Selection.Source = Items; if (Items is null) { Selection.Clear(); } if (state.SelectedIndex.HasValue) { SelectedIndex = state.SelectedIndex.Value; } else if (state.SelectedItem.HasValue) { SelectedItem = state.SelectedItem.Value; } } } // When in a BeginInit..EndInit block, or when the DataContext is updating, we need to // defer changes to the selection model because we have no idea in which order properties // will be set. Consider: // // - Both Items and SelectedItem are bound // - The DataContext changes // - The binding for SelectedItem updates first, producing an item // - Items is searched to find the index of the new selected item // - However Items isn't yet updated; the item is not found // - SelectedIndex is incorrectly set to -1 // // This logic cannot be encapsulated in SelectionModel because the selection model can also // be bound, consider: // // - Both Items and Selection are bound // - The DataContext changes // - The binding for Items updates first // - The new items are assigned to Selection.Source // - The binding for Selection updates, producing a new SelectionModel // - Both the old and new SelectionModels have the incorrect Source private class UpdateState { private Optional _selectedIndex; private Optional _selectedItem; public int UpdateCount { get; set; } public Optional Selection { get; set; } public Optional SelectedItems { get; set; } public Optional SelectedIndex { get => _selectedIndex; set { _selectedIndex = value; _selectedItem = default; } } public Optional SelectedItem { get => _selectedItem; set { _selectedItem = value; _selectedIndex = default; } } } } }