From 7d73960ef676d2c2942d65e288fd12d97e7bd46b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 8 Oct 2015 23:29:18 +0200 Subject: [PATCH] Make selecting ranges work. --- .../Views/MainWindow.paml | 2 +- src/Perspex.Base/Collections/IPerspexList.cs | 8 ++ src/Perspex.Base/Collections/PerspexList.cs | 47 ++++--- .../Collections/PerspexListExtensions.cs | 61 +++++++++ src/Perspex.Controls/ListBox.cs | 8 +- .../Primitives/SelectingItemsControl.cs | 117 +++++++++++++----- src/Perspex.Controls/Primitives/TabStrip.cs | 4 +- .../SelectingItemsControlTests_Multiple.cs | 63 ++++++++++ 8 files changed, 257 insertions(+), 53 deletions(-) diff --git a/samples/XamlTestApplicationPcl/Views/MainWindow.paml b/samples/XamlTestApplicationPcl/Views/MainWindow.paml index 4774398f4f..3dfac47f36 100644 --- a/samples/XamlTestApplicationPcl/Views/MainWindow.paml +++ b/samples/XamlTestApplicationPcl/Views/MainWindow.paml @@ -49,7 +49,7 @@ - + diff --git a/src/Perspex.Base/Collections/IPerspexList.cs b/src/Perspex.Base/Collections/IPerspexList.cs index c680affdc9..804ae73e07 100644 --- a/src/Perspex.Base/Collections/IPerspexList.cs +++ b/src/Perspex.Base/Collections/IPerspexList.cs @@ -16,6 +16,14 @@ namespace Perspex.Collections /// new int Count { get; } + + /// + /// Gets or sets the element at the specified index. + /// + /// The index. + /// The element at the requested index. + new T this[int index] { get; set; } + /// /// Adds multiple items to the collection. /// diff --git a/src/Perspex.Base/Collections/PerspexList.cs b/src/Perspex.Base/Collections/PerspexList.cs index 2e8746b071..c0a35feac2 100644 --- a/src/Perspex.Base/Collections/PerspexList.cs +++ b/src/Perspex.Base/Collections/PerspexList.cs @@ -187,17 +187,22 @@ namespace Perspex.Collections { Contract.Requires(items != null); - if (Validate != null) + var list = (items as IList) ?? items.ToList(); + + if (list.Count > 0) { - foreach (var item in items) + if (Validate != null) { - Validate(item); + foreach (var item in list) + { + Validate((T)item); + } } - } - int index = _inner.Count; - _inner.AddRange(items); - NotifyAdd((items as IList) ?? items.ToList(), index); + int index = _inner.Count; + _inner.AddRange(items); + NotifyAdd(list, index); + } } /// @@ -205,9 +210,12 @@ namespace Perspex.Collections /// public void Clear() { - var old = _inner; - _inner = new List(); - NotifyReset(old); + if (this.Count > 0) + { + var old = _inner; + _inner = new List(); + NotifyReset(old); + } } /// @@ -272,16 +280,21 @@ namespace Perspex.Collections { Contract.Requires(items != null); - if (Validate != null) + var list = (items as IList) ?? items.ToList(); + + if (list.Count > 0) { - foreach (var item in items) + if (Validate != null) { - Validate(item); + foreach (var item in list) + { + Validate((T)item); + } } - } - _inner.InsertRange(index, items); - NotifyAdd((items as IList) ?? items.ToList(), index); + _inner.InsertRange(index, items); + NotifyAdd((items as IList) ?? items.ToList(), index); + } } /// @@ -311,7 +324,7 @@ namespace Perspex.Collections { Contract.Requires(items != null); - List removed = new List(); + var list = (items as IList) ?? items.ToList(); foreach (var i in items) { diff --git a/src/Perspex.Base/Collections/PerspexListExtensions.cs b/src/Perspex.Base/Collections/PerspexListExtensions.cs index d1af24f31d..af70a4aa86 100644 --- a/src/Perspex.Base/Collections/PerspexListExtensions.cs +++ b/src/Perspex.Base/Collections/PerspexListExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Linq; using System.Reactive.Disposables; namespace Perspex.Collections @@ -133,6 +134,66 @@ namespace Perspex.Collections return Disposable.Create(() => collection.CollectionChanged -= handler); } + /// + /// Invokes an action for each item in a collection and subsequently each item added or + /// removed from the collection. + /// + /// The type of the collection items. + /// The collection. + /// + /// An action called initially with all items in the collection and subsequently with a + /// list of items added to the collection. The parameters passed are the index of the + /// first item added to the collection and the items added. + /// + /// + /// An action called with all items removed from the collection. The parameters passed + /// are the index of the first item removed from the collection and the items removed. + /// + /// + /// An action called when the collection is reset. + /// + /// A disposable used to terminate the subscription. + public static IDisposable ForEachItem( + this IPerspexReadOnlyList collection, + Action> added, + Action> removed, + Action reset) + { + NotifyCollectionChangedEventHandler handler = (_, e) => + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + added(e.NewStartingIndex, e.NewItems.Cast()); + break; + + case NotifyCollectionChangedAction.Replace: + removed(e.OldStartingIndex, e.OldItems.Cast()); + added(e.NewStartingIndex, e.NewItems.Cast()); + break; + + case NotifyCollectionChangedAction.Remove: + removed(e.OldStartingIndex, e.OldItems.Cast()); + break; + + case NotifyCollectionChangedAction.Reset: + if (reset == null) + { + throw new InvalidOperationException( + "Reset called on collection without reset handler."); + } + + reset(); + break; + } + }; + + added(0, collection); + collection.CollectionChanged += handler; + + return Disposable.Create(() => collection.CollectionChanged -= handler); + } + /// /// Listens for property changed events from all items in a collection. /// diff --git a/src/Perspex.Controls/ListBox.cs b/src/Perspex.Controls/ListBox.cs index fb523d6ed7..044de39828 100644 --- a/src/Perspex.Controls/ListBox.cs +++ b/src/Perspex.Controls/ListBox.cs @@ -38,7 +38,7 @@ namespace Perspex.Controls if (e.NavigationMethod == NavigationMethod.Directional) { - UpdateSelectionFromEventSource(e.Source, true); + UpdateSelectionFromEventSource(e.Source); } } @@ -49,7 +49,11 @@ namespace Perspex.Controls if (e.MouseButton == MouseButton.Left || e.MouseButton == MouseButton.Right) { - UpdateSelectionFromEventSource(e.Source, true); + UpdateSelectionFromEventSource( + e.Source, + true, + (e.InputModifiers & InputModifiers.Shift) != 0, + (e.InputModifiers & InputModifiers.Control) != 0); } } } diff --git a/src/Perspex.Controls/Primitives/SelectingItemsControl.cs b/src/Perspex.Controls/Primitives/SelectingItemsControl.cs index 9abd72635d..3ffdfb780b 100644 --- a/src/Perspex.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Perspex.Controls/Primitives/SelectingItemsControl.cs @@ -3,6 +3,7 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using Perspex.Collections; @@ -103,8 +104,8 @@ namespace Perspex.Controls.Primitives { ItemContainerGenerator.ContainersInitialized.Subscribe(ContainersInitialized); _selectedIndexes.Validate = ValidateIndex; - _selectedIndexes.ForEachItem(SelectedIndexAdded, SelectedIndexRemoved, SelectionReset); - _selectedItems.ForEachItem(SelectedItemAdded, SelectedItemRemoved, SelectionReset); + _selectedIndexes.ForEachItem(SelectedIndexesAdded, SelectedIndexesRemoved, SelectionReset); + _selectedItems.ForEachItem(SelectedItemsAdded, SelectedItemsRemoved, SelectionReset); } /// @@ -269,20 +270,35 @@ namespace Perspex.Controls.Primitives /// /// The index of the item. /// Whether the item should be selected or unselected. - protected void UpdateSelection(int index, bool select) + /// 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 = (mode & SelectionMode.Toggle) != 0; + var toggle = toggleModifier || (mode & SelectionMode.Toggle) != 0; var multi = (mode & SelectionMode.Multiple) != 0; + var range = multi && SelectedIndexes.Count > 0 ? rangeModifier : false; - if (!toggle) + if (!toggle && !range) { SelectedIndex = index; } + else if (multi && range) + { + var first = SelectedIndexes[0]; + + // TODO: Don't deselect items in new selection. + SelectedIndexes.Clear(); + SelectedIndexes.AddRange(Range(first, index)); + } else { var i = SelectedIndexes.IndexOf(index); @@ -311,18 +327,36 @@ namespace Perspex.Controls.Primitives } } + private IEnumerable Range(int first, int last) + { + int step = first > last ? -1 : 1; + + for (int i = first; i != last; i += step) + { + yield return i; + } + + yield return last; + } + /// /// Updates the selection for a container based on user interaction. /// /// The container. /// Whether the container should be selected or unselected. - protected void UpdateSelection(IControl container, bool select) + /// 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); + UpdateSelection(index, select, rangeModifier, toggleModifier); } } @@ -332,17 +366,23 @@ namespace Perspex.Controls.Primitives /// /// 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) + protected bool UpdateSelectionFromEventSource( + IInteractive eventSource, + bool select = true, + bool rangeModifier = false, + bool toggleModifier = false) { var item = GetContainerFromEventSource(eventSource); if (item != null) { - UpdateSelection(item, select); + UpdateSelection(item, select, rangeModifier, toggleModifier); return true; } @@ -480,21 +520,27 @@ namespace Perspex.Controls.Primitives /// Called when an index is added to the collection. /// /// The index in the SelectedIndexes collection. - /// The item index. - private void SelectedIndexAdded(int listIndex, int itemIndex) + /// The item indexes. + private void SelectedIndexesAdded(int listIndex, IEnumerable itemIndexes) { - if (SelectedIndexes.Count == 1) + var indexes = (itemIndexes as IList) ?? itemIndexes.ToList(); + IControl container = null; + + if (SelectedItems.Count != SelectedIndexes.Count) { - RaisePropertyChanged(SelectedIndexProperty, -1, itemIndex, BindingPriority.LocalValue); + var items = indexes.Select(x => Items.Cast().ElementAt(x)); + SelectedItems.AddRange(items); } - if (SelectedItems.Count != SelectedIndexes.Count) + foreach (var itemIndex in indexes) { - var item = Items.Cast().ElementAt(itemIndex); - SelectedItems.Insert(listIndex, item); + container = MarkIndexSelected(itemIndex, true); } - var container = MarkIndexSelected(itemIndex, true); + if (SelectedIndexes.Count == 1) + { + RaisePropertyChanged(SelectedIndexProperty, -1, SelectedIndexes[0], BindingPriority.LocalValue); + } if (container != null && Presenter?.Panel != null) { @@ -506,20 +552,29 @@ namespace Perspex.Controls.Primitives /// Called when an index is removed from the collection. /// /// The index in the SelectedIndexes collection. - /// The item index. - private void SelectedIndexRemoved(int listIndex, int itemIndex) + /// The item indexes. + private void SelectedIndexesRemoved(int listIndex, IEnumerable itemIndexes) { - if (SelectedIndexes.Count == 0) + var sync = SelectedIndexes.Count != SelectedItems.Count; + + foreach (var itemIndex in itemIndexes) { - RaisePropertyChanged(SelectedIndexProperty, itemIndex, -1, BindingPriority.LocalValue); + if (sync) + { + SelectedItems.RemoveAt(listIndex++); + } + + MarkIndexSelected(itemIndex, false); } - if (SelectedIndexes.Count != SelectedItems.Count) + if (SelectedIndexes.Count == 0) { - SelectedItems.RemoveAt(listIndex); + RaisePropertyChanged( + SelectedIndexProperty, + itemIndexes.First(), + -1, + BindingPriority.LocalValue); } - - MarkIndexSelected(itemIndex, false); } /// @@ -527,16 +582,16 @@ namespace Perspex.Controls.Primitives /// /// The index in the SelectedItems collection. /// The item. - private void SelectedItemAdded(int index, object item) + private void SelectedItemsAdded(int index, object item) { - if (SelectedItems.Count == 1) + if (SelectedIndexes.Count != SelectedItems.Count) { - RaisePropertyChanged(SelectedItemProperty, null, item, BindingPriority.LocalValue); + SelectedIndexes.Insert(index, IndexOf(Items, item)); } - if (SelectedIndexes.Count != SelectedItems.Count) + if (SelectedItems.Count == 1) { - SelectedIndexes.Insert(index, IndexOf(Items, item)); + RaisePropertyChanged(SelectedItemProperty, null, item, BindingPriority.LocalValue); } } @@ -545,7 +600,7 @@ namespace Perspex.Controls.Primitives /// /// The index in the SelectedItems collection. /// The item. - private void SelectedItemRemoved(int index, object item) + private void SelectedItemsRemoved(int index, object item) { if (SelectedIndexes.Count != SelectedItems.Count) { diff --git a/src/Perspex.Controls/Primitives/TabStrip.cs b/src/Perspex.Controls/Primitives/TabStrip.cs index 2f2f1b44b5..8231a5f121 100644 --- a/src/Perspex.Controls/Primitives/TabStrip.cs +++ b/src/Perspex.Controls/Primitives/TabStrip.cs @@ -56,7 +56,7 @@ namespace Perspex.Controls.Primitives if (e.NavigationMethod == NavigationMethod.Directional) { - UpdateSelectionFromEventSource(e.Source, true); + UpdateSelectionFromEventSource(e.Source); } } @@ -67,7 +67,7 @@ namespace Perspex.Controls.Primitives if (e.MouseButton == MouseButton.Left) { - UpdateSelectionFromEventSource(e.Source, true); + UpdateSelectionFromEventSource(e.Source); } } } diff --git a/tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 38af6d4dd8..53938d4620 100644 --- a/tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -219,6 +219,58 @@ namespace Perspex.Controls.UnitTests.Primitives Assert.False(((ListBoxItem)target.Presenter.Panel.Children[1]).IsSelected); } + [Fact] + public void Range_Select_Should_Select_Range() + { + var target = new TestSelector + { + Items = new[] + { + "foo", + "bar", + "baz", + "qux", + "qiz", + "lol", + }, + SelectionMode = SelectionMode.Multiple, + Template = Template(), + }; + + target.ApplyTemplate(); + target.SelectedIndex = 1; + target.SelectRange(3); + + Assert.Equal(new[] { 1, 2, 3 }, target.SelectedIndexes); + Assert.Equal(new[] { "bar", "baz", "qux" }, target.SelectedItems); + } + + [Fact] + public void Range_Select_Backwards_Should_Select_Range() + { + var target = new TestSelector + { + Items = new[] + { + "foo", + "bar", + "baz", + "qux", + "qiz", + "lol", + }, + SelectionMode = SelectionMode.Multiple, + Template = Template(), + }; + + target.ApplyTemplate(); + target.SelectedIndex = 3; + target.SelectRange(1); + + Assert.Equal(new[] { 3, 2, 1 }, target.SelectedIndexes); + Assert.Equal(new[] { "qux", "baz", "bar" }, target.SelectedItems); + } + private class TestSelector : SelectingItemsControl { public new IPerspexList SelectedIndexes @@ -230,6 +282,17 @@ namespace Perspex.Controls.UnitTests.Primitives { get { return base.SelectedItems; } } + + public new SelectionMode SelectionMode + { + get { return base.SelectionMode; } + set { base.SelectionMode = value; } + } + + public void SelectRange(int index) + { + UpdateSelection(index, true, true); + } } private ControlTemplate Template()