diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 7b91d6235d..143b2ffd7d 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 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 { @@ -439,11 +392,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 +425,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 +472,52 @@ 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); + } + + foreach (var container in ItemContainerGenerator.Containers) + { + MarkItemSelected(container.Index, true); + } + + ResetSelectedItems(GetRange(Items, 0, ItemCount - 1)); + }); + } + + /// + /// Deselects all items in the control. + /// + protected void UnselectAll() + { + UpdateSelectedItems(() => + { + _selection.Clear(); + + foreach (var container in ItemContainerGenerator.Containers) + { + MarkItemSelected(container.Index, false); + } + + SelectedItems.Clear(); + }); + } + /// /// Updates the selection for an item based on user interaction. /// @@ -538,40 +536,66 @@ namespace Avalonia.Controls.Primitives 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) - { - SelectedIndex = index; - } - else if (multi && range) + if (range) { - SynchronizeItems( - SelectedItems, - GetRange(Items, SelectedIndex, index)); + UpdateSelectedItems(() => + { + var start = SelectedIndex != -1 ? SelectedIndex : 0; + var first = Math.Min(start, index); + var last = Math.Max(start, index); + + for (var i = first; i < last; ++i) + { + _selection.Add(i); + } + + _selection.Add(last); + + foreach (var container in ItemContainerGenerator.Containers) + { + MarkItemSelected( + container.Index, + container.Index >= first && container.Index <= last); + } + + ResetSelectedItems(GetRange(Items, start, index)); + }); } - else + else if (multi && toggle) { - var item = ElementAt(Items, index); - var i = SelectedItems.IndexOf(item); - - if (i != -1 && (!AlwaysSelected || SelectedItems.Count > 1)) + UpdateSelectedItems(() => { - SelectedItems.Remove(item); - } - else - { - 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 + { + SelectedIndex = index; } if (Presenter?.Panel != null) @@ -640,34 +664,24 @@ namespace Avalonia.Controls.Primitives } /// - /// Makes a list of objects equal another. + /// Makes a list of objects equal another (though doesn't preserve order). /// /// The items collection. /// The desired items. internal static void SynchronizeItems(IList items, IEnumerable desired) { - var index = 0; + var list = items.Cast().ToList(); + var toRemove = list.Except(desired).ToList(); + var toAdd = desired.Except(list).ToList(); - foreach (object item in desired) + foreach(var i in toRemove) { - int itemIndex = items.IndexOf(item); - - if (itemIndex == -1) - { - items.Insert(index, item); - } - else if(itemIndex != index) - { - items.RemoveAt(itemIndex); - items.Insert(index, item); - } - - ++index; + items.Remove(i); } - while (index < items.Count) + foreach (var i in toAdd) { - items.RemoveAt(items.Count - 1); + items.Add(i); } } @@ -678,17 +692,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 +740,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 +804,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 +812,18 @@ namespace Avalonia.Controls.Primitives { MarkItemSelected(index, selected); } + + return index; + } + + private void ResetSelectedItems(IEnumerable items) + { + SelectedItems.Clear(); + + foreach (var i in items) + { + SelectedItems.Add(i); + } } /// @@ -810,95 +833,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); - } - 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); + Add(SelectedItems, added); + UpdateSelection(); } - added = e.NewItems; - removed = e.OldItems; break; } @@ -912,34 +937,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 +967,130 @@ 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); + + UpdateSelectedItems(() => + { + if (clear) + { + SelectedItems.Clear(); + + if (index != -1) + { + SelectedItems.Add(item); + } + } + else + { + if (added != -1) + { + SelectedItems.Add(added); + } + } + }); + } + + if (removed != null && index != -1) + { + removed.Remove(index); + } + + if (added != -1 || removed?.Count > 0) + { + 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 +1102,56 @@ namespace Avalonia.Controls.Primitives SelectedItems = _updateSelectedItems; } } + + private class Selection : IEnumerable + { + private 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 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/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 8e421bf0a2..683b3b41e8 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,42 @@ 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]); + + var panel = target.Presenter.Panel; + + 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]); + + var panel = target.Presenter.Panel; + + Assert.Equal(new[] { ":selected" }, target.Presenter.Panel.Children[3].Classes); + } + 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..e0a8fa81f4 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -10,6 +10,8 @@ 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 +19,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 +262,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 +359,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 +426,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 +549,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { DataContext = items, Template = Template(), + Items = items, }; var called = false; @@ -540,35 +619,193 @@ 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.Down((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, + }; - var target = new TestSelector + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Down((Interactive)target.Presenter.Panel.Children[1]); + _helper.Down((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control); + _helper.Down((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.Down((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 { - Items = items, Template = Template(), - SelectedItem = "bar", + Items = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, }; - var called = false; + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + _helper.Down((Interactive)target.Presenter.Panel.Children[1]); + _helper.Down((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control); - target.SelectionChanged += (s, e) => + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("Bar", target.SelectedItem); + + _helper.Down((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 { - Assert.Equal(new[] { "foo",}, e.AddedItems.Cast()); - Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast()); - called = true; + Template = Template(), + Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, }; target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - target.SelectedItems[0] = "foo"; + _helper.Down((Interactive)target.Presenter.Panel.Children[3]); + _helper.Down((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control); - Assert.True(called); + 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.Down((Interactive)target.Presenter.Panel.Children[3]); + _helper.Down((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.Down((Interactive)target.Presenter.Panel.Children[0]); + _helper.Down((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.Down((Interactive)target.Presenter.Panel.Children[0]); + + Assert.Equal(new[] { "Foo" }, target.SelectedItems); + + _helper.Down((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + + _helper.Down((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control); + + Assert.Equal(new[] { "Foo", "Bar", "Foo" }, target.SelectedItems); + + _helper.Down((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control); + + Assert.Equal(new[] { "Foo", "Bar", "Foo", "Bar" }, target.SelectedItems); + } + + [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); + } + + 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 +835,11 @@ 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 void SelectRange(int index) => UpdateSelection(index, true, true); + + public void Toggle(int index) => UpdateSelection(index, true, false, true); } private class OldDataContextViewModel