diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 041b81155a..3150b6be91 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -84,6 +84,16 @@ namespace Avalonia.Controls set { SetValue(VirtualizationModeProperty, value); } } + /// + /// Selects all items in the . + /// + public new void SelectAll() => base.SelectAll(); + + /// + /// Deselects all items in the . + /// + public new void UnselectAll() => base.UnselectAll(); + /// protected override IItemContainerGenerator CreateItemContainerGenerator() { @@ -118,7 +128,8 @@ namespace Avalonia.Controls e.Source, true, (e.InputModifiers & InputModifiers.Shift) != 0, - (e.InputModifiers & InputModifiers.Control) != 0); + (e.InputModifiers & InputModifiers.Control) != 0, + e.MouseButton == MouseButton.Right); } } diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 7b91d6235d..91a9fa7e40 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -12,6 +12,7 @@ using Avalonia.Data; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; +using Avalonia.Logging; using Avalonia.Styling; using Avalonia.VisualTree; @@ -103,6 +104,7 @@ namespace Avalonia.Controls.Primitives private static readonly IList Empty = Array.Empty(); + private readonly Selection _selection = new Selection(); private int _selectedIndex = -1; private object _selectedItem; private IList _selectedItems; @@ -152,23 +154,8 @@ namespace Avalonia.Controls.Primitives { if (_updateCount == 0) { - SetAndRaise(SelectedIndexProperty, ref _selectedIndex, (int val, ref int backing, Action notifyWrapper) => - { - var old = backing; - var effective = (val >= 0 && val < Items?.Cast().Count()) ? val : -1; - - if (old != effective) - { - backing = effective; - notifyWrapper(() => - RaisePropertyChanged( - SelectedIndexProperty, - old, - effective, - BindingPriority.LocalValue)); - SelectedItem = ElementAt(Items, effective); - } - }, value); + var effective = (value >= 0 && value < ItemCount) ? value : -1; + UpdateSelectedItem(effective); } else { @@ -192,41 +179,7 @@ namespace Avalonia.Controls.Primitives { if (_updateCount == 0) { - SetAndRaise(SelectedItemProperty, ref _selectedItem, (object val, ref object backing, Action notifyWrapper) => - { - var old = backing; - var index = IndexOf(Items, val); - var effective = index != -1 ? val : null; - - if (!object.Equals(effective, old)) - { - backing = effective; - - notifyWrapper(() => - RaisePropertyChanged( - SelectedItemProperty, - old, - effective, - BindingPriority.LocalValue)); - - SelectedIndex = index; - - if (effective != null) - { - if (SelectedItems.Count != 1 || SelectedItems[0] != effective) - { - _syncingSelectedItems = true; - SelectedItems.Clear(); - SelectedItems.Add(effective); - _syncingSelectedItems = false; - } - } - else if (SelectedItems.Count > 0) - { - SelectedItems.Clear(); - } - } - }, value); + UpdateSelectedItem(IndexOf(Items, value)); } else { @@ -354,31 +307,23 @@ namespace Avalonia.Controls.Primitives { SelectedIndex = 0; } + else + { + _selection.ItemsInserted(e.NewStartingIndex, e.NewItems.Count); + UpdateSelectedItem(_selection.First(), false); + } break; case NotifyCollectionChangedAction.Remove: - case NotifyCollectionChangedAction.Replace: - var selectedIndex = SelectedIndex; - - if (selectedIndex >= e.OldStartingIndex && - selectedIndex < e.OldStartingIndex + e.OldItems.Count) - { - if (!AlwaysSelected) - { - selectedIndex = SelectedIndex = -1; - } - else - { - LostSelection(); - } - } + _selection.ItemsRemoved(e.OldStartingIndex, e.OldItems.Count); + UpdateSelectedItem(_selection.First(), false); + ResetSelectedItems(); + break; - var items = Items?.Cast(); - if (selectedIndex >= items.Count()) - { - selectedIndex = SelectedIndex = items.Count() - 1; - } + case NotifyCollectionChangedAction.Replace: + UpdateSelectedItem(SelectedIndex, false); + ResetSelectedItems(); break; case NotifyCollectionChangedAction.Move: @@ -439,11 +384,7 @@ namespace Avalonia.Controls.Primitives { if (i.ContainerControl != null && i.Item != null) { - var ms = MemberSelector; - bool selected = ms == null ? - SelectedItems.Contains(i.Item) : - SelectedItems.OfType().Any(v => Equals(ms.Select(v), i.Item)); - + bool selected = _selection.Contains(i.Index); MarkContainerSelected(i.ContainerControl, selected); } } @@ -476,9 +417,12 @@ namespace Avalonia.Controls.Primitives var keymap = AvaloniaLocator.Current.GetService(); bool Match(List gestures) => gestures.Any(g => g.Matches(e)); - if (this.SelectionMode == SelectionMode.Multiple && Match(keymap.SelectAll)) + if (ItemCount > 0 && + Match(keymap.SelectAll) && + (((SelectionMode & SelectionMode.Multiple) != 0) || + (SelectionMode & SelectionMode.Toggle) != 0)) { - SynchronizeItems(SelectedItems, Items?.Cast()); + SelectAll(); e.Handled = true; } } @@ -520,6 +464,41 @@ namespace Avalonia.Controls.Primitives return false; } + /// + /// Selects all items in the control. + /// + protected void SelectAll() + { + if ((SelectionMode & (SelectionMode.Multiple | SelectionMode.Toggle)) == 0) + { + throw new NotSupportedException("Multiple selection is not enabled on this control."); + } + + UpdateSelectedItems(() => + { + _selection.Clear(); + + for (var i = 0; i < ItemCount; ++i) + { + _selection.Add(i); + } + + UpdateSelectedItem(0, false); + + foreach (var container in ItemContainerGenerator.Containers) + { + MarkItemSelected(container.Index, true); + } + + ResetSelectedItems(); + }); + } + + /// + /// Deselects all items in the control. + /// + protected void UnselectAll() => UpdateSelectedItem(-1); + /// /// Updates the selection for an item based on user interaction. /// @@ -527,51 +506,83 @@ namespace Avalonia.Controls.Primitives /// 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 toggleModifier = false, + bool rightButton = 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; + var toggle = (toggleModifier || (mode & SelectionMode.Toggle) != 0); + var range = multi && rangeModifier; - if (!toggle && !range) + if (range) { - SelectedIndex = index; - } - else if (multi && range) - { - SynchronizeItems( - SelectedItems, - GetRange(Items, SelectedIndex, index)); + UpdateSelectedItems(() => + { + var start = SelectedIndex != -1 ? SelectedIndex : 0; + var step = start < index ? 1 : -1; + + _selection.Clear(); + + for (var i = start; i != index; i += step) + { + _selection.Add(i); + } + + _selection.Add(index); + + var first = Math.Min(start, index); + var last = Math.Max(start, index); + + foreach (var container in ItemContainerGenerator.Containers) + { + MarkItemSelected( + container.Index, + container.Index >= first && container.Index <= last); + } + + ResetSelectedItems(); + }); } - else + else if (multi && toggle) { - var item = ElementAt(Items, index); - var i = SelectedItems.IndexOf(item); - - if (i != -1 && (!AlwaysSelected || SelectedItems.Count > 1)) - { - SelectedItems.Remove(item); - } - else + UpdateSelectedItems(() => { - if (multi) + if (!_selection.Contains(index)) { - SelectedItems.Add(item); + _selection.Add(index); + MarkItemSelected(index, true); + SelectedItems.Add(ElementAt(Items, index)); } else { - SelectedIndex = index; + _selection.Remove(index); + MarkItemSelected(index, false); + + if (index == _selectedIndex) + { + UpdateSelectedItem(_selection.First(), false); + } + + SelectedItems.Remove(ElementAt(Items, index)); } - } + }); + } + else if (toggle) + { + SelectedIndex = (SelectedIndex == index) ? -1 : index; + } + else + { + UpdateSelectedItem(index, !(rightButton && _selection.Contains(index))); } if (Presenter?.Panel != null) @@ -596,17 +607,19 @@ namespace Avalonia.Controls.Primitives /// 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 toggleModifier = false, + bool rightButton = false) { var index = ItemContainerGenerator?.IndexFromContainer(container) ?? -1; if (index != -1) { - UpdateSelection(index, select, rangeModifier, toggleModifier); + UpdateSelection(index, select, rangeModifier, toggleModifier, rightButton); } } @@ -618,6 +631,7 @@ namespace Avalonia.Controls.Primitives /// 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. @@ -626,51 +640,20 @@ namespace Avalonia.Controls.Primitives IInteractive eventSource, bool select = true, bool rangeModifier = false, - bool toggleModifier = false) + bool toggleModifier = false, + bool rightButton = false) { var container = GetContainerFromEventSource(eventSource); if (container != null) { - UpdateSelection(container, select, rangeModifier, toggleModifier); + UpdateSelection(container, select, rangeModifier, toggleModifier, rightButton); return true; } return false; } - /// - /// Makes a list of objects equal another. - /// - /// The items collection. - /// The desired items. - internal static void SynchronizeItems(IList items, IEnumerable desired) - { - var index = 0; - - foreach (object item in desired) - { - int itemIndex = items.IndexOf(item); - - if (itemIndex == -1) - { - items.Insert(index, item); - } - else if(itemIndex != index) - { - items.RemoveAt(itemIndex); - items.Insert(index, item); - } - - ++index; - } - - while (index < items.Count) - { - items.RemoveAt(items.Count - 1); - } - } - /// /// Gets a range of items from an IEnumerable. /// @@ -678,17 +661,19 @@ namespace Avalonia.Controls.Primitives /// The index of the first item. /// The index of the last item. /// The items. - private static IEnumerable GetRange(IEnumerable items, int first, int last) + private static List GetRange(IEnumerable items, int first, int last) { var list = (items as IList) ?? items.Cast().ToList(); - int step = first > last ? -1 : 1; + var step = first > last ? -1 : 1; + var result = new List(); for (int i = first; i != last; i += step) { - yield return list[i]; + result.Add(list[i]); } - yield return list[last]; + result.Add(list[last]); + return result; } /// @@ -724,19 +709,14 @@ namespace Avalonia.Controls.Primitives private void LostSelection() { var items = Items?.Cast(); + var index = -1; if (items != null && AlwaysSelected) { - var index = Math.Min(SelectedIndex, items.Count() - 1); - - if (index > -1) - { - SelectedItem = items.ElementAt(index); - return; - } + index = Math.Min(SelectedIndex, items.Count() - 1); } - SelectedIndex = -1; + SelectedIndex = index; } /// @@ -793,7 +773,7 @@ namespace Avalonia.Controls.Primitives /// /// The item. /// Whether the item should be selected or deselected. - private void MarkItemSelected(object item, bool selected) + private int MarkItemSelected(object item, bool selected) { var index = IndexOf(Items, item); @@ -801,6 +781,21 @@ namespace Avalonia.Controls.Primitives { MarkItemSelected(index, selected); } + + return index; + } + + private void ResetSelectedItems() + { + UpdateSelectedItems(() => + { + SelectedItems.Clear(); + + foreach (var i in _selection) + { + SelectedItems.Add(ElementAt(Items, i)); + } + }); } /// @@ -810,95 +805,97 @@ namespace Avalonia.Controls.Primitives /// The event args. private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { - var generator = ItemContainerGenerator; + if (_syncingSelectedItems) + { + return; + } + + void Add(IList newItems, IList addedItems = null) + { + foreach (var item in newItems) + { + var index = MarkItemSelected(item, true); + + if (index != -1 && _selection.Add(index) && addedItems != null) + { + addedItems.Add(item); + } + } + } + + void UpdateSelection() + { + if ((SelectedIndex != -1 && !_selection.Contains(SelectedIndex)) || + (SelectedIndex == -1 && _selection.HasItems)) + { + _selectedIndex = _selection.First(); + _selectedItem = ElementAt(Items, _selectedIndex); + RaisePropertyChanged(SelectedIndexProperty, -1, _selectedIndex, BindingPriority.LocalValue); + RaisePropertyChanged(SelectedItemProperty, null, _selectedItem, BindingPriority.LocalValue); + + if (AutoScrollToSelectedItem) + { + ScrollIntoView(_selectedIndex); + } + } + } + IList added = null; IList removed = null; switch (e.Action) { case NotifyCollectionChangedAction.Add: - SelectedItemsAdded(e.NewItems.Cast().ToList()); - - if (AutoScrollToSelectedItem) { - ScrollIntoView(e.NewItems[0]); + Add(e.NewItems); + UpdateSelection(); + added = e.NewItems; } - added = e.NewItems; break; case NotifyCollectionChangedAction.Remove: if (SelectedItems.Count == 0) { - if (!_syncingSelectedItems) - { - SelectedIndex = -1; - } + SelectedIndex = -1; } foreach (var item in e.OldItems) { - MarkItemSelected(item, false); + var index = MarkItemSelected(item, false); + _selection.Remove(index); } removed = e.OldItems; break; + case NotifyCollectionChangedAction.Replace: + throw new NotSupportedException("Replacing items in a SelectedItems collection is not supported."); + + case NotifyCollectionChangedAction.Move: + throw new NotSupportedException("Moving items in a SelectedItems collection is not supported."); + case NotifyCollectionChangedAction.Reset: - if (generator != null) { removed = new List(); + added = new List(); - foreach (var item in generator.Containers) + foreach (var index in _selection.ToList()) { - if (item?.ContainerControl != null) + var item = ElementAt(Items, index); + + if (!SelectedItems.Contains(item)) { - if (MarkContainerSelected(item.ContainerControl, false)) - { - removed.Add(item.Item); - } + MarkItemSelected(index, false); + removed.Add(item); + _selection.Remove(index); } } - } - if (SelectedItems.Count > 0) - { - _selectedItem = null; - SelectedItemsAdded(SelectedItems); - added = SelectedItems; - } - else if (!_syncingSelectedItems) - { - SelectedIndex = -1; - } - - break; - - case NotifyCollectionChangedAction.Replace: - foreach (var item in e.OldItems) - { - MarkItemSelected(item, false); - } - - foreach (var item in e.NewItems) - { - MarkItemSelected(item, true); + Add(SelectedItems, added); + UpdateSelection(); } - if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems) - { - 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); - } - - added = e.NewItems; - removed = e.OldItems; break; } @@ -912,34 +909,6 @@ namespace Avalonia.Controls.Primitives } } - /// - /// 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 && !_syncingSelectedItems) - { - 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. /// @@ -970,6 +939,112 @@ namespace Avalonia.Controls.Primitives } } + /// + /// Updates the selection due to a change to or + /// . + /// + /// The new selected index. + /// Whether to clear existing selection. + private void UpdateSelectedItem(int index, bool clear = true) + { + var oldIndex = _selectedIndex; + var oldItem = _selectedItem; + + if (index == -1 && AlwaysSelected) + { + index = Math.Min(SelectedIndex, ItemCount - 1); + } + + var item = ElementAt(Items, index); + var added = -1; + HashSet removed = null; + + _selectedIndex = index; + _selectedItem = item; + + if (oldIndex != index || _selection.HasMultiple) + { + if (clear) + { + removed = _selection.Clear(); + } + + if (index != -1) + { + if (_selection.Add(index)) + { + added = index; + } + + if (removed?.Contains(index) == true) + { + removed.Remove(index); + added = -1; + } + } + + if (removed != null) + { + foreach (var i in removed) + { + MarkItemSelected(i, false); + } + } + + MarkItemSelected(index, true); + + RaisePropertyChanged( + SelectedIndexProperty, + oldIndex, + index); + } + + if (!Equals(item, oldItem)) + { + RaisePropertyChanged( + SelectedItemProperty, + oldItem, + item); + } + + if (removed != null && index != -1) + { + removed.Remove(index); + } + + if (added != -1 || removed?.Count > 0) + { + ResetSelectedItems(); + + var e = new SelectionChangedEventArgs( + SelectionChangedEvent, + added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty(), + removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty()); + RaiseEvent(e); + } + } + + private void UpdateSelectedItems(Action action) + { + try + { + _syncingSelectedItems = true; + action(); + } + catch (Exception ex) + { + Logger.Error( + LogArea.Property, + this, + "Error thrown updating SelectedItems: {Error}", + ex); + } + finally + { + _syncingSelectedItems = false; + } + } + private void UpdateFinished() { if (_updateSelectedIndex != int.MinValue) @@ -981,5 +1056,104 @@ namespace Avalonia.Controls.Primitives SelectedItems = _updateSelectedItems; } } + + private class Selection : IEnumerable + { + private readonly List _list = new List(); + private HashSet _set = new HashSet(); + + public bool HasItems => _set.Count > 0; + public bool HasMultiple => _set.Count > 1; + + public bool Add(int index) + { + if (index == -1) + { + throw new ArgumentException("Invalid index", "index"); + } + + if (_set.Add(index)) + { + _list.Add(index); + return true; + } + + return false; + } + + public bool Remove(int index) + { + if (_set.Remove(index)) + { + _list.RemoveAll(x => x == index); + return true; + } + + return false; + } + + public HashSet Clear() + { + var result = _set; + _list.Clear(); + _set = new HashSet(); + return result; + } + + public void ItemsInserted(int index, int count) + { + _set = new HashSet(); + + for (var i = 0; i < _list.Count; ++i) + { + var ix = _list[i]; + + if (ix >= index) + { + var newIndex = ix + count; + _list[i] = newIndex; + _set.Add(newIndex); + } + else + { + _set.Add(ix); + } + } + } + + public void ItemsRemoved(int index, int count) + { + var last = (index + count) - 1; + + _set = new HashSet(); + + for (var i = 0; i < _list.Count; ++i) + { + var ix = _list[i]; + + if (ix >= index && ix <= last) + { + _list.RemoveAt(i--); + } + else if (ix > last) + { + var newIndex = ix - count; + _list[i] = newIndex; + _set.Add(newIndex); + } + else + { + _set.Add(ix); + } + } + } + + public bool Contains(int index) => _set.Contains(index); + + public int First() => HasItems ? _list[0] : -1; + + public IEnumerator GetEnumerator() => _set.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } } } diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index c3fbce1d83..888f4a2013 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -409,7 +409,7 @@ namespace Avalonia.Controls if (this.SelectionMode == SelectionMode.Multiple && Match(keymap.SelectAll)) { - SelectingItemsControl.SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items); + SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items); e.Handled = true; } } @@ -521,7 +521,7 @@ namespace Avalonia.Controls } else if (multi && range) { - SelectingItemsControl.SynchronizeItems( + SynchronizeItems( SelectedItems, GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem)); } @@ -778,5 +778,27 @@ namespace Avalonia.Controls container.Classes.Set(":selected", selected); } } + + /// + /// Makes a list of objects equal another (though doesn't preserve order). + /// + /// The items collection. + /// The desired items. + private static void SynchronizeItems(IList items, IEnumerable desired) + { + var list = items.Cast().ToList(); + var toRemove = list.Except(desired).ToList(); + var toAdd = desired.Except(list).ToList(); + + foreach (var i in toRemove) + { + items.Remove(i); + } + + foreach (var i in toAdd) + { + items.Add(i); + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 8e421bf0a2..75cababf54 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -536,6 +536,9 @@ namespace Avalonia.Controls.UnitTests.Primitives SelectedIndex = 1, }; + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + var called = false; target.SelectionChanged += (s, e) => @@ -545,8 +548,6 @@ namespace Avalonia.Controls.UnitTests.Primitives called = true; }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); target.SelectedIndex = -1; Assert.True(called); @@ -783,6 +784,116 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(2, vm.Child.SelectedIndex); } + [Fact] + public void Should_Select_Correct_Item_When_Duplicate_Items_Are_Present() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz"}, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Down((Interactive)target.Presenter.Panel.Children[3]); + + Assert.Equal(3, target.SelectedIndex); + } + + [Fact] + public void Should_Apply_Selected_Pseudoclass_To_Correct_Item_When_Duplicate_Items_Are_Present() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Down((Interactive)target.Presenter.Panel.Children[3]); + + Assert.Equal(new[] { ":selected" }, target.Presenter.Panel.Children[3].Classes); + } + + [Fact] + public void Adding_Item_Before_SelectedItem_Should_Update_SelectedIndex() + { + var items = new ObservableCollection + { + "Foo", + "Bar", + "Baz" + }; + + var target = new ListBox + { + Template = Template(), + Items = items, + SelectedIndex = 1, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + items.Insert(0, "Qux"); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + } + + [Fact] + public void Removing_Item_Before_SelectedItem_Should_Update_SelectedIndex() + { + var items = new ObservableCollection + { + "Foo", + "Bar", + "Baz" + }; + + var target = new ListBox + { + Template = Template(), + Items = items, + SelectedIndex = 1, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + items.RemoveAt(0); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + } + + [Fact] + public void Replacing_Selected_Item_Should_Update_SelectedItem() + { + var items = new ObservableCollection + { + "Foo", + "Bar", + "Baz" + }; + + var target = new ListBox + { + Template = Template(), + Items = items, + SelectedIndex = 1, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + items[1] = "Qux"; + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("Qux", target.SelectedItem); + } + private FuncControlTemplate Template() { return new FuncControlTemplate(control => diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 9ef7d567c8..a33d97779e 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -4,12 +4,15 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Markup.Data; using Xunit; @@ -17,6 +20,8 @@ namespace Avalonia.Controls.UnitTests.Primitives { public class SelectingItemsControlTests_Multiple { + private MouseTestHelper _helper = new MouseTestHelper(); + [Fact] public void Setting_SelectedIndex_Should_Add_To_SelectedItems() { @@ -258,31 +263,25 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void Replacing_First_SelectedItem_Should_Update_SelectedItem_SelectedIndex() + public void Setting_SelectedIndex_Should_Unmark_Previously_Selected_Containers() { - var items = new[] - { - new ListBoxItem(), - new ListBoxItem(), - new ListBoxItem(), - }; - var target = new TestSelector { - Items = items, + Items = new[] { "foo", "bar", "baz" }, Template = Template(), }; target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - target.SelectedIndex = 1; - target.SelectedItems[0] = items[2]; - Assert.Equal(2, target.SelectedIndex); - Assert.Equal(items[2], target.SelectedItem); - Assert.False(items[0].IsSelected); - Assert.False(items[1].IsSelected); - Assert.True(items[2].IsSelected); + target.SelectedItems.Add("foo"); + target.SelectedItems.Add("bar"); + + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); + + target.SelectedIndex = 2; + + Assert.Equal(new[] { 2 }, SelectedContainers(target)); } [Fact] @@ -361,6 +360,52 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { "baz", "qux", "qiz" }, target.SelectedItems.Cast().ToList()); } + [Fact] + public void Setting_SelectedIndex_After_Range_Should_Unmark_Previously_Selected_Containers() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar", "baz", "qux" }, + Template = Template(), + SelectedIndex = 0, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + target.SelectRange(2); + + Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); + + target.SelectedIndex = 3; + + Assert.Equal(new[] { 3 }, SelectedContainers(target)); + } + + [Fact] + public void Toggling_Selection_After_Range_Should_Work() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar", "baz", "foo", "bar", "baz" }, + Template = Template(), + SelectedIndex = 0, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + target.SelectRange(3); + + Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedContainers(target)); + + target.Toggle(4); + + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, SelectedContainers(target)); + } + [Fact] public void Suprious_SelectedIndex_Changes_Should_Not_Be_Triggered() { @@ -382,6 +427,40 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { -1, 1, 0 }, selectedIndexes); } + [Fact] + public void Can_Set_SelectedIndex_To_Another_Selected_Item() + { + var target = new TestSelector + { + Items = new[] { "foo", "bar", "baz" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.SelectedItems.Add("foo"); + target.SelectedItems.Add("bar"); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(new[] { "foo", "bar" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); + + var raised = false; + target.SelectionChanged += (s, e) => + { + raised = true; + Assert.Empty(e.AddedItems); + Assert.Equal(new[] { "foo" }, e.RemovedItems); + }; + + target.SelectedIndex = 1; + + Assert.True(raised); + Assert.Equal(1, target.SelectedIndex); + Assert.Equal(new[] { "bar" }, target.SelectedItems); + Assert.Equal(new[] { 1 }, SelectedContainers(target)); + } + /// /// Tests a problem discovered with ListBox with selection. /// @@ -471,6 +550,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { DataContext = items, Template = Template(), + Items = items, }; var called = false; @@ -540,35 +620,418 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.True(called); } + + [Fact] + public void Shift_Selecting_From_No_Selection_Selects_From_Start() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Shift); + + var panel = target.Presenter.Panel; + + Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); + } [Fact] - public void Replacing_SelectedItems_Should_Raise_SelectionChanged_With_CorrectItems() + public void Ctrl_Selecting_SelectedItem_With_Multiple_Selection_Active_Sets_SelectedItem_To_Next_Selection() { - var items = new[] { "foo", "bar", "baz" }; + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Qux" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[1]); + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control); + _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + Assert.Equal(new[] { "Bar", "Baz", "Qux" }, target.SelectedItems); + + _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control); + + Assert.Equal(2, target.SelectedIndex); + Assert.Equal("Baz", target.SelectedItem); + Assert.Equal(new[] { "Baz", "Qux" }, target.SelectedItems); + } + + [Fact] + public void Ctrl_Selecting_Non_SelectedItem_With_Multiple_Selection_Active_Leaves_SelectedItem_The_Same() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[1]); + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + + _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + } + + [Fact] + public void Should_Ctrl_Select_Correct_Item_When_Duplicate_Items_Are_Present() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[3]); + _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control); + + var panel = target.Presenter.Panel; + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + Assert.Equal(new[] { 3, 4 }, SelectedContainers(target)); + } + + [Fact] + public void Should_Shift_Select_Correct_Item_When_Duplicates_Are_Present() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[3]); + _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: InputModifiers.Shift); + + var panel = target.Presenter.Panel; + Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { 3, 4, 5 }, SelectedContainers(target)); + } + + [Fact] + public void Can_Shift_Select_All_Items_When_Duplicates_Are_Present() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[0]); + _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: InputModifiers.Shift); + + var panel = target.Presenter.Panel; + + Assert.Equal(new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target)); + } + + [Fact] + public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[0]); + + Assert.Equal(new[] { "Foo" }, target.SelectedItems); + + _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + + _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control); + + Assert.Equal(new[] { "Foo", "Bar", "Foo" }, target.SelectedItems); + + _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control); + + Assert.Equal(new[] { "Foo", "Bar", "Foo", "Bar" }, target.SelectedItems); + } + + [Fact] + public void SelectAll_Sets_SelectedIndex_And_SelectedItem() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + target.SelectAll(); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Foo", target.SelectedItem); + } + + [Fact] + public void UnselectAll_Clears_SelectedIndex_And_SelectedItem() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + SelectedIndex = 0, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + target.UnselectAll(); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Equal(null, target.SelectedItem); + } + + [Fact] + public void SelectAll_Handles_Duplicate_Items() + { var target = new TestSelector { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.SelectAll(); + + Assert.Equal(new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, target.SelectedItems); + } + + [Fact] + public void Adding_Item_Before_SelectedItems_Should_Update_Selection() + { + var items = new ObservableCollection + { + "Foo", + "Bar", + "Baz" + }; + + var target = new ListBox + { + Template = Template(), Items = items, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + target.SelectAll(); + items.Insert(0, "Qux"); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("Foo", target.SelectedItem); + Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { 1, 2, 3 }, SelectedContainers(target)); + } + + [Fact] + public void Removing_Item_Before_SelectedItem_Should_Update_Selection() + { + var items = new ObservableCollection + { + "Foo", + "Bar", + "Baz" + }; + + var target = new TestSelector + { Template = Template(), - SelectedItem = "bar", + Items = items, + SelectionMode = SelectionMode.Multiple, }; - var called = false; + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); - target.SelectionChanged += (s, e) => + target.SelectedIndex = 1; + target.SelectRange(2); + + Assert.Equal(new[] { "Bar", "Baz" }, target.SelectedItems); + + items.RemoveAt(0); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + Assert.Equal(new[] { "Bar", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); + } + + [Fact] + public void Removing_SelectedItem_With_Multiple_Selection_Active_Should_Update_Selection() + { + var items = new ObservableCollection { - Assert.Equal(new[] { "foo",}, e.AddedItems.Cast()); - Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast()); - called = true; + "Foo", + "Bar", + "Baz" + }; + + var target = new ListBox + { + Template = Template(), + Items = items, + SelectionMode = SelectionMode.Multiple, }; target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - target.SelectedItems[0] = "foo"; - Assert.True(called); + target.SelectAll(); + items.RemoveAt(0); + + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + Assert.Equal(new[] { "Bar", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); } + [Fact] + public void Replacing_Selected_Item_Should_Update_SelectedItems() + { + var items = new ObservableCollection + { + "Foo", + "Bar", + "Baz" + }; + + var target = new ListBox + { + Template = Template(), + Items = items, + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + target.SelectAll(); + items[1] = "Qux"; + + Assert.Equal(new[] { "Foo", "Qux", "Baz" }, target.SelectedItems); + } + + [Fact] + public void Left_Click_On_SelectedItem_Should_Clear_Existing_Selection() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + ItemTemplate = new FuncDataTemplate(x => new TextBlock { Width = 20, Height = 10 }), + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.SelectAll(); + + Assert.Equal(3, target.SelectedItems.Count); + + _helper.Click((Interactive)target.Presenter.Panel.Children[0]); + + Assert.Equal(1, target.SelectedItems.Count); + Assert.Equal(new[] { "Foo", }, target.SelectedItems); + Assert.Equal(new[] { 0 }, SelectedContainers(target)); + } + + [Fact] + public void Right_Click_On_SelectedItem_Should_Not_Clear_Existing_Selection() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + ItemTemplate = new FuncDataTemplate(x => new TextBlock { Width = 20, Height = 10 }), + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.SelectAll(); + + Assert.Equal(3, target.SelectedItems.Count); + + _helper.Click((Interactive)target.Presenter.Panel.Children[0], MouseButton.Right); + + Assert.Equal(3, target.SelectedItems.Count); + } + + [Fact] + public void Right_Click_On_UnselectedItem_Should_Clear_Existing_Selection() + { + var target = new ListBox + { + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz" }, + ItemTemplate = new FuncDataTemplate(x => new TextBlock { Width = 20, Height = 10 }), + SelectionMode = SelectionMode.Multiple, + }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Click((Interactive)target.Presenter.Panel.Children[0]); + _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Shift); + + Assert.Equal(2, target.SelectedItems.Count); + + _helper.Click((Interactive)target.Presenter.Panel.Children[2], MouseButton.Right); + + Assert.Equal(1, target.SelectedItems.Count); + } + + private IEnumerable SelectedContainers(SelectingItemsControl target) + { + return target.Presenter.Panel.Children + .Select((x, i) => x.Classes.Contains(":selected") ? i : -1) + .Where(x => x != -1); + } private FuncControlTemplate Template() { @@ -598,10 +1061,10 @@ namespace Avalonia.Controls.UnitTests.Primitives set { base.SelectionMode = value; } } - public void SelectRange(int index) - { - UpdateSelection(index, true, true); - } + public new void SelectAll() => base.SelectAll(); + public new void UnselectAll() => base.UnselectAll(); + public void SelectRange(int index) => UpdateSelection(index, true, true); + public void Toggle(int index) => UpdateSelection(index, true, false, true); } private class OldDataContextViewModel