// Copyright (c) The Perspex Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using Perspex.Collections; using Perspex.Controls.Generators; using Perspex.Input; using Perspex.Interactivity; using Perspex.Styling; using Perspex.VisualTree; namespace Perspex.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 selection 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 PerspexProperty SelectedIndexProperty = PerspexProperty.RegisterDirect( nameof(SelectedIndex), o => o.SelectedIndex, (o, v) => o.SelectedIndex = v); /// /// Defines the property. /// public static readonly PerspexProperty SelectedItemProperty = PerspexProperty.RegisterDirect( nameof(SelectedItem), o => o.SelectedItem, (o, v) => o.SelectedItem = v); /// /// Defines the property. /// protected static readonly PerspexProperty> SelectedItemsProperty = PerspexProperty.RegisterDirect>( nameof(SelectedItems), o => o.SelectedItems, (o, v) => o.SelectedItems = v); /// /// Defines the property. /// protected static readonly PerspexProperty SelectionModeProperty = PerspexProperty.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); private int _selectedIndex = -1; private object _selectedItem; private IList _selectedItems; private bool _ignoreContainerSelectionChanged; /// /// Initializes static members of the class. /// static SelectingItemsControl() { IsSelectedChangedEvent.AddClassHandler(x => x.ContainerSelectionChanged); } /// /// Initializes a new instance of the class. /// public SelectingItemsControl() { ItemContainerGenerator.ContainersInitialized.Subscribe(ContainersInitialized); } /// /// Gets or sets the index of the selected item. /// public int SelectedIndex { get { return _selectedIndex; } set { var old = SelectedIndex; var effective = (value >= 0 && value < Items?.Cast().Count()) ? value : -1; if (old != effective) { _selectedIndex = effective; RaisePropertyChanged(SelectedIndexProperty, old, effective, BindingPriority.LocalValue); SelectedItem = ElementAt(Items, effective); } } } /// /// Gets or sets the selected item. /// public object SelectedItem { get { return _selectedItem; } set { var old = SelectedItem; var index = IndexOf(Items, value); var effective = index != -1 ? value : null; if (effective != old) { _selectedItem = effective; RaisePropertyChanged(SelectedItemProperty, old, effective, BindingPriority.LocalValue); SelectedIndex = index; if (effective != null) { if (SelectedItems.Count != 1 || SelectedItems[0] != effective) { SelectedItems.Clear(); SelectedItems.Add(effective); } } else { SelectedItems.Clear(); } } } } /// /// Gets the selected items. /// protected IList SelectedItems { get { if (_selectedItems == null) { _selectedItems = new PerspexList(); SubscribeToSelectedItems(); } return _selectedItems; } set { if (value != null) { UnsubscribeFromSelectedItems(); _selectedItems = value; SubscribeToSelectedItems(); } } } /// /// Gets or sets the selection mode. /// protected SelectionMode SelectionMode { get { return GetValue(SelectionModeProperty); } set { SetValue(SelectionModeProperty, value); } } /// /// Gets a value indicating whether is set. /// protected bool AlwaysSelected => (SelectionMode & SelectionMode.AlwaysSelected) != 0; /// /// 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) { var item = ((IVisual)eventSource).GetSelfAndVisualAncestors() .OfType() .FirstOrDefault(x => x.LogicalParent == this); return item as IControl; } /// protected override void ItemsChanged(PerspexPropertyChangedEventArgs e) { base.ItemsChanged(e); if (SelectedIndex != -1) { SelectedIndex = IndexOf((IEnumerable)e.NewValue, SelectedItem); } else if (AlwaysSelected && Items != null & Items.Cast().Any()) { SelectedIndex = 0; } } /// protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { base.ItemsCollectionChanged(sender, e); switch (e.Action) { case NotifyCollectionChangedAction.Add: if (AlwaysSelected && SelectedIndex == -1) { SelectedIndex = 0; } break; case NotifyCollectionChangedAction.Remove: case NotifyCollectionChangedAction.Replace: var selectedIndex = SelectedIndex; if (selectedIndex >= e.OldStartingIndex && selectedIndex < e.OldStartingIndex + e.OldItems.Count) { if (!AlwaysSelected) { SelectedIndex = -1; } else { LostSelection(); } } break; case NotifyCollectionChangedAction.Reset: SelectedIndex = IndexOf(e.NewItems, SelectedItem); break; } } /// /// 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). protected void UpdateSelection( int index, bool select = true, bool rangeModifier = false, bool toggleModifier = false) { if (index != -1) { if (select) { var mode = SelectionMode; var toggle = toggleModifier || (mode & SelectionMode.Toggle) != 0; var multi = (mode & SelectionMode.Multiple) != 0; var range = multi && SelectedIndex != -1 ? rangeModifier : false; if (!toggle && !range) { SelectedIndex = index; } else if (multi && range) { SynchronizeItems( SelectedItems, GetRange(Items, SelectedIndex, index)); } else { var item = ElementAt(Items, index); var i = SelectedItems.IndexOf(item); if (i != -1 && (!AlwaysSelected || SelectedItems.Count > 1)) { SelectedItems.Remove(item); } else { if (multi) { SelectedItems.Add(item); } else { SelectedIndex = index; } } } if (Presenter?.Panel != null) { var container = ItemContainerGenerator.ContainerFromIndex(index); KeyboardNavigation.SetTabOnceActiveElement( (InputElement)Presenter.Panel, container); } } else { LostSelection(); } } } /// /// 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). protected void UpdateSelection( IControl container, bool select = true, bool rangeModifier = false, bool toggleModifier = false) { var index = ItemContainerGenerator.IndexFromContainer(container); if (index != -1) { UpdateSelection(index, select, rangeModifier, toggleModifier); } } /// /// Updates the selection based on an event source 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). /// /// 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) { var item = GetContainerFromEventSource(eventSource); if (item != null) { UpdateSelection(item, select, rangeModifier, toggleModifier); return true; } return false; } /// /// Gets the item at the specified index in a collection. /// /// The collection. /// The index. /// The index of the item or -1 if the item was not found. private static object ElementAt(IEnumerable items, int index) { var typedItems = items?.Cast(); if (index != -1 && typedItems != null && index < typedItems.Count()) { return typedItems.ElementAt(index) ?? null; } else { return null; } } /// /// Gets the index of an item in a collection. /// /// The collection. /// The item. /// The index of the item or -1 if the item was not found. private static int IndexOf(IEnumerable items, object item) { if (items != null && item != null) { var list = items as IList; if (list != null) { return list.IndexOf(item); } else { int index = 0; foreach (var i in items) { if (Equals(i, item)) { return index; } ++index; } } } return -1; } /// /// Gets a range of items from an IEnumerable. /// /// The items. /// The index of the first item. /// The index of the last item. /// The items. private static IEnumerable GetRange(IEnumerable items, int first, int last) { var list = (items as IList) ?? items.Cast().ToList(); int step = first > last ? -1 : 1; for (int i = first; i != last; i += step) { yield return list[i]; } yield return list[last]; } /// /// Makes a list of objects equal another. /// /// The items collection. /// The desired items. private static void SynchronizeItems(IList items, IEnumerable desired) { int index = 0; foreach (var i in desired) { if (index < items.Count) { if (items[index] != i) { items[index] = i; } } else { items.Add(i); } ++index; } while (index < items.Count) { items.RemoveAt(items.Count - 1); } } /// /// Called when new containers are initialized by the . /// /// The containers. private void ContainersInitialized(ItemContainers containers) { var selectedIndex = SelectedIndex; var selectedContainer = containers.Items.OfType().FirstOrDefault(x => x.IsSelected); if (selectedContainer != null) { SelectedIndex = containers.Items.IndexOf((IControl)selectedContainer) + containers.StartingIndex; } else if (selectedIndex >= containers.StartingIndex && selectedIndex < containers.StartingIndex + containers.Items.Count) { var container = containers.Items[selectedIndex - containers.StartingIndex]; MarkContainerSelected(container, true); } } /// /// Called when a container raises the . /// /// The event. private void ContainerSelectionChanged(RoutedEventArgs e) { if (!_ignoreContainerSelectionChanged) { var selectable = (ISelectable)e.Source; if (selectable != null) { UpdateSelectionFromEventSource(e.Source, selectable.IsSelected); } } } /// /// Called when the currently selected item is lost and the selection must be changed /// depending on the property. /// private void LostSelection() { var items = Items?.Cast(); if (items != null && AlwaysSelected) { var index = Math.Min(SelectedIndex, items.Count() - 1); if (index > -1) { SelectedItem = items.ElementAt(index); return; } } SelectedIndex = -1; } /// /// Sets a container's 'selected' class or . /// /// The container. /// Whether the control is selected private void MarkContainerSelected(IControl container, bool selected) { try { var selectable = container as ISelectable; var styleable = container as IStyleable; _ignoreContainerSelectionChanged = true; if (selectable != null) { selectable.IsSelected = selected; } else if (styleable != null) { if (selected) { styleable.Classes.Add(":selected"); } else { styleable.Classes.Remove(":selected"); } } } finally { _ignoreContainerSelectionChanged = false; } } /// /// Sets an item container's 'selected' class or . /// /// The index of the item. /// Whether the item should be selected or deselected. private void MarkItemSelected(int index, bool selected) { var container = ItemContainerGenerator.ContainerFromIndex(index); if (container != null) { MarkContainerSelected(container, selected); } } /// /// Sets an item container's 'selected' class or . /// /// The item. /// Whether the item should be selected or deselected. private void MarkItemSelected(object item, bool selected) { var index = IndexOf(Items, item); if (index != -1) { MarkItemSelected(index, selected); } } /// /// Called when the CollectionChanged event is raised. /// /// The event sender. /// The event args. private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: SelectedItemsAdded(e.NewItems.Cast().ToList()); break; case NotifyCollectionChangedAction.Remove: if (SelectedItems.Count == 0) { SelectedIndex = -1; } else { foreach (var item in e.OldItems) { MarkItemSelected(item, false); } } break; case NotifyCollectionChangedAction.Reset: foreach (var item in ItemContainerGenerator.Containers) { MarkContainerSelected(item, false); } SelectedIndex = -1; SelectedItemsAdded(SelectedItems); break; case NotifyCollectionChangedAction.Replace: foreach (var item in e.OldItems) { MarkItemSelected(item, false); } foreach (var item in e.NewItems) { MarkItemSelected(item, true); } if (SelectedItem != SelectedItems[0]) { var oldItem = SelectedItem; var oldIndex = SelectedIndex; var item = SelectedItems[0]; var index = IndexOf(Items, item); _selectedIndex = index; _selectedItem = item; RaisePropertyChanged(SelectedIndexProperty, oldIndex, index, BindingPriority.LocalValue); RaisePropertyChanged(SelectedItemProperty, oldItem, item, BindingPriority.LocalValue); } break; } } /// /// Called when items are added to the collection. /// /// The added items. private void SelectedItemsAdded(IList items) { if (items.Count > 0) { foreach (var item in items) { MarkItemSelected(item, true); } if (SelectedItem == null) { var index = IndexOf(Items, items[0]); if (index != -1) { _selectedItem = items[0]; _selectedIndex = index; RaisePropertyChanged(SelectedIndexProperty, -1, index, BindingPriority.LocalValue); RaisePropertyChanged(SelectedItemProperty, null, items[0], BindingPriority.LocalValue); } } } } /// /// Subscribes to the CollectionChanged event, if any. /// private void SubscribeToSelectedItems() { var incc = _selectedItems as INotifyCollectionChanged; if (incc != null) { incc.CollectionChanged += SelectedItemsCollectionChanged; } SelectedItemsCollectionChanged( _selectedItems, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } /// /// Unsubscribes from the CollectionChanged event, if any. /// private void UnsubscribeFromSelectedItems() { var incc = _selectedItems as INotifyCollectionChanged; if (incc != null) { incc.CollectionChanged -= SelectedItemsCollectionChanged; } } } }