From 85d309830a4fbe93accbfff2fc83784ac9dd7d75 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Mon, 4 Mar 2019 20:05:58 +0100 Subject: [PATCH 1/6] TreeView multiple selection support. --- .../ControlCatalog/Pages/TreeViewPage.xaml | 2 +- .../Primitives/SelectingItemsControl.cs | 2 +- src/Avalonia.Controls/TreeView.cs | 494 ++++++++++++++---- src/Avalonia.Controls/TreeViewHelper.cs | 90 ++++ .../TreeViewTests.cs | 145 ++++- 5 files changed, 639 insertions(+), 94 deletions(-) create mode 100644 src/Avalonia.Controls/TreeViewHelper.cs diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml b/samples/ControlCatalog/Pages/TreeViewPage.xaml index f8f3cd5848..3392572cd3 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml @@ -9,7 +9,7 @@ Margin="0,16,0,0" HorizontalAlignment="Center" Spacing="16"> - + diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index c40ddc37ad..d21211371e 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -639,7 +639,7 @@ namespace Avalonia.Controls.Primitives /// /// The items collection. /// The desired items. - private static void SynchronizeItems(IList items, IEnumerable desired) + internal static void SynchronizeItems(IList items, IEnumerable desired) { int index = 0; diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index c574799724..d8a2eb0c06 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -2,13 +2,15 @@ // 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 Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; -using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; @@ -34,14 +36,24 @@ namespace Avalonia.Controls (o, v) => o.SelectedItem = v); /// - /// Defines the event. + /// Defines the property. /// - public static readonly RoutedEvent SelectedItemChangedEvent = - RoutedEvent.Register( - "SelectedItemChanged", - RoutingStrategies.Bubble); + public static readonly DirectProperty SelectedItemsProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedItems), + o => o.SelectedItems, + (o, v) => o.SelectedItems = v); + /// + /// Defines the property. + /// + protected static readonly StyledProperty SelectionModeProperty = + AvaloniaProperty.Register( + nameof(SelectionMode)); + + private static readonly IList Empty = new object[0]; private object _selectedItem; + private IList _selectedItems; /// /// Initializes static members of the class. @@ -54,16 +66,16 @@ namespace Avalonia.Controls /// /// Occurs when the control's selection changes. /// - public event EventHandler SelectedItemChanged + public event EventHandler SelectionChanged { - add { AddHandler(SelectedItemChangedEvent, value); } - remove { RemoveHandler(SelectedItemChangedEvent, value); } + add => AddHandler(SelectingItemsControl.SelectionChangedEvent, value); + remove => RemoveHandler(SelectingItemsControl.SelectionChangedEvent, value); } /// /// Gets the for the tree view. /// - public new ITreeItemContainerGenerator ItemContainerGenerator => + public new ITreeItemContainerGenerator ItemContainerGenerator => (ITreeItemContainerGenerator)base.ItemContainerGenerator; /// @@ -71,81 +83,270 @@ namespace Avalonia.Controls /// public bool AutoScrollToSelectedItem { - get { return GetValue(AutoScrollToSelectedItemProperty); } - set { SetValue(AutoScrollToSelectedItemProperty, value); } + get => GetValue(AutoScrollToSelectedItemProperty); + set => SetValue(AutoScrollToSelectedItemProperty, value); + } + + private bool _syncingSelectedItems; + + /// + /// Gets or sets the selection mode. + /// + public SelectionMode SelectionMode + { + get => GetValue(SelectionModeProperty); + set => SetValue(SelectionModeProperty, value); } /// /// Gets or sets the selected item. /// public object SelectedItem + { + get => _selectedItem; + set + { + SetAndRaise(SelectedItemProperty, ref _selectedItem, + (object val, ref object backing, Action notifyWrapper) => + { + var old = backing; + backing = val; + + notifyWrapper(() => + RaisePropertyChanged( + SelectedItemProperty, + old, + val)); + + if (val != null) + { + if (SelectedItems.Count != 1 || SelectedItems[0] != val) + { + _syncingSelectedItems = true; + SelectSingleItem(val); + _syncingSelectedItems = false; + } + } + else if (SelectedItems.Count > 0) + { + SelectedItems.Clear(); + } + }, value); + } + } + + /// + /// Gets the selected items. + /// + protected IList SelectedItems { get { - return _selectedItem; + if (_selectedItems == null) + { + _selectedItems = new AvaloniaList(); + SubscribeToSelectedItems(); + } + + return _selectedItems; } set { - if (_selectedItem != null) + if (value?.IsFixedSize == true || value?.IsReadOnly == true) { - var container = ItemContainerGenerator.Index.ContainerFromItem(_selectedItem); - MarkContainerSelected(container, false); + throw new NotSupportedException( + "Cannot use a fixed size or read-only collection as SelectedItems."); } - var oldItem = _selectedItem; - SetAndRaise(SelectedItemProperty, ref _selectedItem, value); + UnsubscribeFromSelectedItems(); + _selectedItems = value ?? new AvaloniaList(); + SubscribeToSelectedItems(); + } + } - if (_selectedItem != null) - { - var container = ItemContainerGenerator.Index.ContainerFromItem(_selectedItem); - MarkContainerSelected(container, true); + /// + /// Subscribes to the CollectionChanged event, if any. + /// + private void SubscribeToSelectedItems() + { + if (_selectedItems is INotifyCollectionChanged incc) + { + incc.CollectionChanged += SelectedItemsCollectionChanged; + } + + SelectedItemsCollectionChanged( + _selectedItems, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + private void SelectSingleItem(object item) + { + SelectedItems.Clear(); + SelectedItems.Add(item); + } + + /// + /// Called when the CollectionChanged event is raised. + /// + /// The event sender. + /// The event args. + private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + IList added = null; + IList removed = null; - if (AutoScrollToSelectedItem && container != null) + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + + SelectedItemsAdded(e.NewItems.Cast().ToArray()); + + if (AutoScrollToSelectedItem) { - container.BringIntoView(); + var container = (TreeViewItem)ItemContainerGenerator.Index.ContainerFromItem(e.NewItems[0]); + + container?.BringIntoView(); } - } - if (oldItem != _selectedItem) - { - // Fire the SelectionChanged event - List removed = new List(); - if (oldItem != null) + added = e.NewItems; + + break; + case NotifyCollectionChangedAction.Remove: + + if (!_syncingSelectedItems) { - removed.Add(oldItem); + if (SelectedItems.Count == 0) + { + SelectedItem = null; + } + else + { + var selectedIndex = SelectedItems.IndexOf(_selectedItem); + + if (selectedIndex == -1) + { + var old = _selectedItem; + _selectedItem = SelectedItems[0]; + + RaisePropertyChanged(SelectedItemProperty, old, _selectedItem); + } + } } - List added = new List(); - if (_selectedItem != null) + foreach (var item in e.OldItems) { - added.Add(_selectedItem); + MarkItemSelected(item, false); } - var changed = new SelectionChangedEventArgs( - SelectedItemChangedEvent, - added, - removed); - RaiseEvent(changed); - } + removed = e.OldItems; + + break; + case NotifyCollectionChangedAction.Reset: + + foreach (IControl container in ItemContainerGenerator.Index.Items) + { + MarkContainerSelected(container, false); + } + + if (SelectedItems.Count > 0) + { + SelectedItemsAdded(SelectedItems); + + added = SelectedItems; + } + else if (!_syncingSelectedItems) + { + SelectedItem = null; + } + + 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 item = SelectedItems[0]; + _selectedItem = item; + RaisePropertyChanged(SelectedItemProperty, oldItem, item); + } + + added = e.NewItems; + removed = e.OldItems; + + break; + } + + if (added?.Count > 0 || removed?.Count > 0) + { + var changed = new SelectionChangedEventArgs( + SelectingItemsControl.SelectionChangedEvent, + added ?? Empty, + removed ?? Empty); + RaiseEvent(changed); + } + } + + private void MarkItemSelected(object item, bool selected) + { + var container = ItemContainerGenerator.Index.ContainerFromItem(item); + + MarkContainerSelected(container, selected); + } + + private void SelectedItemsAdded(IList items) + { + if (items.Count == 0) + { + return; + } + + foreach (object item in items) + { + MarkItemSelected(item, true); + } + + if (SelectedItem == null && !_syncingSelectedItems) + { + SetAndRaise(SelectedItemProperty, ref _selectedItem, items[0]); } } - (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction) + /// + /// Unsubscribes from the CollectionChanged event, if any. + /// + private void UnsubscribeFromSelectedItems() + { + if (_selectedItems is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= SelectedItemsCollectionChanged; + } + } + + (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, + NavigationDirection direction) { if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous) { if (!this.IsVisualAncestorOf(element)) { - IControl result = _selectedItem != null ? - ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) : - ItemContainerGenerator.ContainerFromIndex(0); + IControl result = _selectedItem != null + ? ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) + : ItemContainerGenerator.ContainerFromIndex(0); return (true, result); } - else - { - return (true, null); - } + + return (true, null); } return (false, null); @@ -186,7 +387,7 @@ namespace Avalonia.Controls if (SelectedItem != null) { var next = GetContainerInDirection( - GetContainerFromEventSource(e.Source) as TreeViewItem, + GetContainerFromEventSource(e.Source), direction.Value, true); @@ -208,17 +409,9 @@ namespace Avalonia.Controls NavigationDirection direction, bool intoChildren) { - IItemContainerGenerator parentGenerator; + IItemContainerGenerator parentGenerator = GetParentContainerGenerator(from); - if (from?.Parent is TreeView treeView) - { - parentGenerator = treeView.ItemContainerGenerator; - } - else if (from?.Parent is TreeViewItem item) - { - parentGenerator = item.ItemContainerGenerator; - } - else + if (parentGenerator == null) { return null; } @@ -233,9 +426,9 @@ namespace Avalonia.Controls if (index > 0) { var previous = (TreeViewItem)parentGenerator.ContainerFromIndex(index - 1); - result = previous.IsExpanded ? - (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) : - previous; + result = previous.IsExpanded + ? (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) + : previous; } else { @@ -257,6 +450,7 @@ namespace Avalonia.Controls { return GetContainerInDirection(parentItem, direction, false); } + break; } @@ -293,18 +487,134 @@ namespace Avalonia.Controls { var item = ItemContainerGenerator.Index.ItemFromContainer(container); - if (item != null) + if (item == null) { - if (SelectedItem != null) + return; + } + + IControl selectedContainer = null; + + if (SelectedItem != null) + { + selectedContainer = ItemContainerGenerator.Index.ContainerFromItem(SelectedItem); + } + + var mode = SelectionMode; + var toggle = toggleModifier || (mode & SelectionMode.Toggle) != 0; + var multi = (mode & SelectionMode.Multiple) != 0; + var range = multi && selectedContainer != null && rangeModifier; + + if (!toggle && !range) + { + SelectSingleItem(item); + } + else if (multi && range) + { + SelectingItemsControl.SynchronizeItems( + SelectedItems, + GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem)); + } + else + { + var i = SelectedItems.IndexOf(item); + + if (i != -1) + { + SelectedItems.Remove(item); + } + else + { + if (multi) + { + SelectedItems.Add(item); + } + else + { + SelectedItem = item; + } + } + } + } + + private static IItemContainerGenerator GetParentContainerGenerator(TreeViewItem item) + { + if (item == null) + { + return null; + } + + switch (item.Parent) + { + case TreeView treeView: + return treeView.ItemContainerGenerator; + case TreeViewItem treeViewItem: + return treeViewItem.ItemContainerGenerator; + default: + return null; + } + } + + /// + /// Returns all items that belong to containers between and . + /// The range is inclusive. + /// + /// From container. + /// To container. + private List GetItemsInRange(TreeViewItem from, TreeViewItem to) + { + var items = new List(); + + if (from == null || to == null) + { + return items; + } + + TreeViewItem firstItem = TreeViewHelper.FindFirstNode(this, new TreeViewHelper.SearchInfo(from, to)); + + if (firstItem == null) + { + return items; + } + + bool wasReversed = false; + + if (firstItem == to) + { + var temp = from; + + from = to; + to = temp; + + wasReversed = true; + } + + TreeViewItem node = from; + + while (node != to) + { + var item = ItemContainerGenerator.Index.ItemFromContainer(node); + + if (item != null) { - var old = ItemContainerGenerator.Index.ContainerFromItem(SelectedItem); - MarkContainerSelected(old, false); + items.Add(item); } - SelectedItem = item; + node = GetContainerInDirection(node, NavigationDirection.Down, true); + } + + var toItem = ItemContainerGenerator.Index.ItemFromContainer(to); - MarkContainerSelected(container, true); + if (toItem != null) + { + items.Add(toItem); + } + + if (wasReversed) + { + items.Reverse(); } + + return items; } /// @@ -341,7 +651,7 @@ namespace Avalonia.Controls /// /// The control that raised the event. /// The container or null if the event did not originate in a container. - protected IControl GetContainerFromEventSource(IInteractive eventSource) + protected TreeViewItem GetContainerFromEventSource(IInteractive eventSource) { var item = ((IVisual)eventSource).GetSelfAndVisualAncestors() .OfType() @@ -349,7 +659,7 @@ namespace Avalonia.Controls if (item != null) { - if (item.ItemContainerGenerator.Index == this.ItemContainerGenerator.Index) + if (item.ItemContainerGenerator.Index == ItemContainerGenerator.Index) { return item; } @@ -367,21 +677,23 @@ namespace Avalonia.Controls { var selectedItem = SelectedItem; - if (selectedItem != null) + if (selectedItem == null) { - foreach (var container in e.Containers) - { - if (container.Item == selectedItem) - { - ((TreeViewItem)container.ContainerControl).IsSelected = true; + return; + } - if (AutoScrollToSelectedItem) - { - Dispatcher.UIThread.Post(container.ContainerControl.BringIntoView); - } + foreach (var container in e.Containers) + { + if (container.Item == selectedItem) + { + ((TreeViewItem)container.ContainerControl).IsSelected = true; - break; + if (AutoScrollToSelectedItem) + { + Dispatcher.UIThread.Post(container.ContainerControl.BringIntoView); } + + break; } } } @@ -393,18 +705,18 @@ namespace Avalonia.Controls /// Whether the control is selected private void MarkContainerSelected(IControl container, bool selected) { - if (container != null) + if (container == null) { - var selectable = container as ISelectable; + return; + } - if (selectable != null) - { - selectable.IsSelected = selected; - } - else - { - ((IPseudoClasses)container.Classes).Set(":selected", selected); - } + if (container is ISelectable selectable) + { + selectable.IsSelected = selected; + } + else + { + container.Classes.Set(":selected", selected); } } } diff --git a/src/Avalonia.Controls/TreeViewHelper.cs b/src/Avalonia.Controls/TreeViewHelper.cs new file mode 100644 index 0000000000..b2d43185cb --- /dev/null +++ b/src/Avalonia.Controls/TreeViewHelper.cs @@ -0,0 +1,90 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.Collections.Generic; +using Avalonia.Controls.Generators; + +namespace Avalonia.Controls +{ + /// + /// Helper for related operations. + /// + internal static class TreeViewHelper + { + /// + /// Find which node from search info is first in hierarchy. + /// + /// Search root. + /// Nodes to search for. + /// Found first node. + public static TreeViewItem FindFirstNode(TreeView treeView, in SearchInfo searchInfo) + { + return FindInContainers(treeView.ItemContainerGenerator, in searchInfo); + } + + private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator, + in SearchInfo searchInfo) + { + IEnumerable containers = containerGenerator.Containers; + + foreach (ItemContainerInfo container in containers) + { + TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, in searchInfo); + + if (node != null) + { + return node; + } + } + + return null; + } + + private static TreeViewItem FindFirstNode(TreeViewItem node, in SearchInfo searchInfo) + { + if (node == null) + { + return null; + } + + TreeViewItem match = searchInfo.GetMatch(node); + + if (match != null) + { + return match; + } + + return FindInContainers(node.ItemContainerGenerator, in searchInfo); + } + + /// + /// Node search info. + /// + public readonly struct SearchInfo + { + public readonly TreeViewItem Search1; + public readonly TreeViewItem Search2; + + public SearchInfo(TreeViewItem search1, TreeViewItem search2) + { + Search1 = search1; + Search2 = search2; + } + + public TreeViewItem GetMatch(TreeViewItem candidate) + { + if (candidate == Search1) + { + return Search1; + } + + if (candidate == Search2) + { + return Search2; + } + + return null; + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 1a913865cb..4364af3a62 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -140,6 +140,149 @@ namespace Avalonia.Controls.UnitTests Assert.True(container.IsSelected); } + [Fact] + public void Clicking_WithControlModifier_Selected_Item_Should_Deselect_It() + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + + var item = tree[0].Children[1].Children[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + + target.SelectedItem = item; + + Assert.True(container.IsSelected); + + container.RaiseEvent(new PointerPressedEventArgs + { + RoutedEvent = InputElement.PointerPressedEvent, + MouseButton = MouseButton.Left, + InputModifiers = InputModifiers.Control + }); + + Assert.Null(target.SelectedItem); + Assert.False(container.IsSelected); + } + + [Fact] + public void Clicking_WithControlModifier_Not_Selected_Item_Should_Select_It() + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + + var item1 = tree[0].Children[1].Children[0]; + var container1 = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item1); + + var item2 = tree[0].Children[1]; + var container2 = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item2); + + Assert.NotNull(container1); + Assert.NotNull(container2); + + target.SelectedItem = item1; + + Assert.True(container1.IsSelected); + + container2.RaiseEvent(new PointerPressedEventArgs + { + RoutedEvent = InputElement.PointerPressedEvent, + MouseButton = MouseButton.Left, + InputModifiers = InputModifiers.Control + }); + + Assert.Equal(item2, target.SelectedItem); + Assert.False(container1.IsSelected); + Assert.True(container2.IsSelected); + } + + [Fact] + public void Clicking_WithShiftModifier_Should_Select_Range_Of_Items() + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + SelectionMode = SelectionMode.Multiple + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + + var rootNode = tree[0]; + + var from = rootNode.Children[0]; + var to = rootNode.Children.Last(); + + var fromContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(from); + var toContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(to); + + void ClickContainer(IControl container, InputModifiers modifiers) + { + container.RaiseEvent(new PointerPressedEventArgs + { + RoutedEvent = InputElement.PointerPressedEvent, + MouseButton = MouseButton.Left, + InputModifiers = modifiers + }); + } + + void AssertChildrenSelected() + { + foreach (var child in rootNode.Children) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(child); + + Assert.True(container.IsSelected); + } + } + + // Top to down + ClickContainer(fromContainer, InputModifiers.None); + + Assert.True(fromContainer.IsSelected); + + ClickContainer(toContainer, InputModifiers.Shift); + + AssertChildrenSelected(); + + // Down to up + target.SelectedItem = null; + + ClickContainer(toContainer, InputModifiers.None); + + Assert.True(toContainer.IsSelected); + + ClickContainer(fromContainer, InputModifiers.Shift); + + AssertChildrenSelected(); + } + [Fact] public void Setting_SelectedItem_Should_Set_Container_Selected() { @@ -186,7 +329,7 @@ namespace Avalonia.Controls.UnitTests var item = tree[0].Children[1].Children[0]; var called = false; - target.SelectedItemChanged += (s, e) => + target.SelectionChanged += (s, e) => { Assert.Empty(e.RemovedItems); Assert.Equal(1, e.AddedItems.Count); From 2c04c95a12f6ddd9eefad73219f57d966122c3bb Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Tue, 5 Mar 2019 11:35:46 +0100 Subject: [PATCH 2/6] Expose SelectedItems property. Add more tests for edge cases failing for ListBox. --- src/Avalonia.Controls/TreeView.cs | 2 +- .../TreeViewTests.cs | 171 ++++++++++++++---- 2 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index d8a2eb0c06..6aa45d9dc1 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -138,7 +138,7 @@ namespace Avalonia.Controls /// /// Gets the selected items. /// - protected IList SelectedItems + public IList SelectedItems { get { diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 4364af3a62..f65ef7d08e 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -11,7 +11,6 @@ using Avalonia.Data; using Avalonia.Data.Core; using Avalonia.Input; using Avalonia.LogicalTree; -using Avalonia.Markup.Data; using Avalonia.UnitTests; using Xunit; @@ -147,7 +146,7 @@ namespace Avalonia.Controls.UnitTests var target = new TreeView { Template = CreateTreeViewTemplate(), - Items = tree, + Items = tree }; var visualRoot = new TestRoot(); @@ -183,7 +182,7 @@ namespace Avalonia.Controls.UnitTests var target = new TreeView { Template = CreateTreeViewTemplate(), - Items = tree, + Items = tree }; var visualRoot = new TestRoot(); @@ -218,7 +217,46 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Clicking_WithShiftModifier_Should_Select_Range_Of_Items() + public void Clicking_WithControlModifier_Selected_Item_Should_Deselect_And_Remove_From_SelectedItems() + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + SelectionMode = SelectionMode.Multiple + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + + var rootNode = tree[0]; + + var item1 = rootNode.Children[0]; + var item2 = rootNode.Children.Last(); + + var item1Container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item1); + var item2Container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item2); + + TreeTestHelper.ClickContainer(item1Container, InputModifiers.Control); + Assert.True(item1Container.IsSelected); + + TreeTestHelper.ClickContainer(item2Container, InputModifiers.Control); + Assert.True(item2Container.IsSelected); + + Assert.Equal(new[] {item1, item2}, target.SelectedItems.OfType()); + + TreeTestHelper.ClickContainer(item1Container, InputModifiers.Control); + Assert.False(item1Container.IsSelected); + + Assert.DoesNotContain(item1, target.SelectedItems.OfType()); + } + + [Fact] + public void Clicking_WithShiftModifier_DownDirection_Should_Select_Range_Of_Items() { var tree = CreateTestTreeData(); var target = new TreeView @@ -242,45 +280,92 @@ namespace Avalonia.Controls.UnitTests var fromContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(from); var toContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(to); - void ClickContainer(IControl container, InputModifiers modifiers) - { - container.RaiseEvent(new PointerPressedEventArgs - { - RoutedEvent = InputElement.PointerPressedEvent, - MouseButton = MouseButton.Left, - InputModifiers = modifiers - }); - } + TreeTestHelper.ClickContainer(fromContainer, InputModifiers.None); - void AssertChildrenSelected() + Assert.True(fromContainer.IsSelected); + + TreeTestHelper.ClickContainer(toContainer, InputModifiers.Shift); + TreeTestHelper.AssertChildrenSelected(target, rootNode); + } + + [Fact] + public void Clicking_WithShiftModifier_UpDirection_Should_Select_Range_Of_Items() + { + var tree = CreateTestTreeData(); + var target = new TreeView { - foreach (var child in rootNode.Children) - { - var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(child); + Template = CreateTreeViewTemplate(), + Items = tree, + SelectionMode = SelectionMode.Multiple + }; - Assert.True(container.IsSelected); - } - } + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + + var rootNode = tree[0]; + + var from = rootNode.Children.Last(); + var to = rootNode.Children[0]; - // Top to down - ClickContainer(fromContainer, InputModifiers.None); + var fromContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(from); + var toContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(to); + + TreeTestHelper.ClickContainer(fromContainer, InputModifiers.None); Assert.True(fromContainer.IsSelected); - ClickContainer(toContainer, InputModifiers.Shift); + TreeTestHelper.ClickContainer(toContainer, InputModifiers.Shift); + TreeTestHelper.AssertChildrenSelected(target, rootNode); + } - AssertChildrenSelected(); + [Fact] + public void Clicking_First_Item_Of_SelectedItems_Should_Select_Only_It() + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + SelectionMode = SelectionMode.Multiple + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); - // Down to up - target.SelectedItem = null; + var rootNode = tree[0]; - ClickContainer(toContainer, InputModifiers.None); + var from = rootNode.Children.Last(); + var to = rootNode.Children[0]; - Assert.True(toContainer.IsSelected); + var fromContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(from); + var toContainer = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(to); - ClickContainer(fromContainer, InputModifiers.Shift); + TreeTestHelper.ClickContainer(fromContainer, InputModifiers.None); - AssertChildrenSelected(); + TreeTestHelper.ClickContainer(toContainer, InputModifiers.Shift); + TreeTestHelper.AssertChildrenSelected(target, rootNode); + + TreeTestHelper.ClickContainer(fromContainer, InputModifiers.None); + + Assert.True(fromContainer.IsSelected); + + foreach (var child in rootNode.Children) + { + if (child == from) + { + continue; + } + + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(child); + + Assert.False(container.IsSelected); + } } [Fact] @@ -309,7 +394,6 @@ namespace Avalonia.Controls.UnitTests Assert.True(container.IsSelected); } - [Fact] public void Setting_SelectedItem_Should_Raise_SelectedItemChanged_Event() { @@ -585,7 +669,7 @@ namespace Avalonia.Controls.UnitTests new Node { Value = "Child3", - }, + } } } }; @@ -658,6 +742,29 @@ namespace Avalonia.Controls.UnitTests } } + private static class TreeTestHelper + { + public static void ClickContainer(IControl container, InputModifiers modifiers) + { + container.RaiseEvent(new PointerPressedEventArgs + { + RoutedEvent = InputElement.PointerPressedEvent, + MouseButton = MouseButton.Left, + InputModifiers = modifiers + }); + } + + public static void AssertChildrenSelected(TreeView treeView, Node rootNode) + { + foreach (var child in rootNode.Children) + { + var container = (TreeViewItem)treeView.ItemContainerGenerator.Index.ContainerFromItem(child); + + Assert.True(container.IsSelected); + } + } + } + private class Node : NotifyingBase { private IAvaloniaList _children; From 0116a9ba23a9164bdb288dbc64b0bd9e5253d30b Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Thu, 7 Mar 2019 11:09:51 +0100 Subject: [PATCH 3/6] Fix TreeViewPage indent. --- samples/ControlCatalog/Pages/TreeViewPage.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/ControlCatalog/Pages/TreeViewPage.xaml b/samples/ControlCatalog/Pages/TreeViewPage.xaml index 3392572cd3..c03edb8b03 100644 --- a/samples/ControlCatalog/Pages/TreeViewPage.xaml +++ b/samples/ControlCatalog/Pages/TreeViewPage.xaml @@ -9,7 +9,7 @@ Margin="0,16,0,0" HorizontalAlignment="Center" Spacing="16"> - + From c9d8fa6a9d637370bc9e3470e4e674be80dc39b9 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Thu, 7 Mar 2019 11:13:29 +0100 Subject: [PATCH 4/6] Move SynchronizeItems about private methods. --- .../Primitives/SelectingItemsControl.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index d21211371e..d68f90cc4f 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -614,26 +614,6 @@ namespace Avalonia.Controls.Primitives return false; } - /// - /// 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. /// @@ -666,6 +646,26 @@ namespace Avalonia.Controls.Primitives } } + /// + /// 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]; + } + /// /// Called when a container raises the . /// From 5822e60db615148be9ffb2d71bb20f1e77ce1f01 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Thu, 7 Mar 2019 11:41:42 +0100 Subject: [PATCH 5/6] Fix ternary operator formatting, add config entry to the .editorconfig. --- .editorconfig | 3 +++ src/Avalonia.Controls/TreeView.cs | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.editorconfig b/.editorconfig index b7a03207a4..5f08d1e940 100644 --- a/.editorconfig +++ b/.editorconfig @@ -132,6 +132,9 @@ csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_between_square_brackets = false +# Wrapping preferences +csharp_wrap_before_ternary_opsigns = false + # Xaml files [*.xaml] indent_size = 4 diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 6aa45d9dc1..19e09b4339 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -340,9 +340,9 @@ namespace Avalonia.Controls { if (!this.IsVisualAncestorOf(element)) { - IControl result = _selectedItem != null - ? ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) - : ItemContainerGenerator.ContainerFromIndex(0); + IControl result = _selectedItem != null ? + ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) : + ItemContainerGenerator.ContainerFromIndex(0); return (true, result); } @@ -426,9 +426,9 @@ namespace Avalonia.Controls if (index > 0) { var previous = (TreeViewItem)parentGenerator.ContainerFromIndex(index - 1); - result = previous.IsExpanded - ? (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) - : previous; + result = previous.IsExpanded ? + (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) : + previous; } else { From c3480fb84033fd179d86e1dfe85b924877562658 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Thu, 7 Mar 2019 11:49:22 +0100 Subject: [PATCH 6/6] Remove TreeViewHelper and shorten the code so the extra struct is not needed. --- src/Avalonia.Controls/TreeView.cs | 50 +++++++++++++- src/Avalonia.Controls/TreeViewHelper.cs | 90 ------------------------- 2 files changed, 49 insertions(+), 91 deletions(-) delete mode 100644 src/Avalonia.Controls/TreeViewHelper.cs diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 19e09b4339..db8de49494 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -554,6 +554,54 @@ namespace Avalonia.Controls } } + /// + /// Find which node is first in hierarchy. + /// + /// Search root. + /// Nodes to find. + /// Node to find. + /// Found first node. + private static TreeViewItem FindFirstNode(TreeView treeView, TreeViewItem nodeA, TreeViewItem nodeB) + { + return FindInContainers(treeView.ItemContainerGenerator, nodeA, nodeB); + } + + private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator, + TreeViewItem nodeA, + TreeViewItem nodeB) + { + IEnumerable containers = containerGenerator.Containers; + + foreach (ItemContainerInfo container in containers) + { + TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, nodeA, nodeB); + + if (node != null) + { + return node; + } + } + + return null; + } + + private static TreeViewItem FindFirstNode(TreeViewItem node, TreeViewItem nodeA, TreeViewItem nodeB) + { + if (node == null) + { + return null; + } + + TreeViewItem match = node == nodeA ? nodeA : node == nodeB ? nodeB : null; + + if (match != null) + { + return match; + } + + return FindInContainers(node.ItemContainerGenerator, nodeA, nodeB); + } + /// /// Returns all items that belong to containers between and . /// The range is inclusive. @@ -569,7 +617,7 @@ namespace Avalonia.Controls return items; } - TreeViewItem firstItem = TreeViewHelper.FindFirstNode(this, new TreeViewHelper.SearchInfo(from, to)); + TreeViewItem firstItem = FindFirstNode(this, from, to); if (firstItem == null) { diff --git a/src/Avalonia.Controls/TreeViewHelper.cs b/src/Avalonia.Controls/TreeViewHelper.cs deleted file mode 100644 index b2d43185cb..0000000000 --- a/src/Avalonia.Controls/TreeViewHelper.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System.Collections.Generic; -using Avalonia.Controls.Generators; - -namespace Avalonia.Controls -{ - /// - /// Helper for related operations. - /// - internal static class TreeViewHelper - { - /// - /// Find which node from search info is first in hierarchy. - /// - /// Search root. - /// Nodes to search for. - /// Found first node. - public static TreeViewItem FindFirstNode(TreeView treeView, in SearchInfo searchInfo) - { - return FindInContainers(treeView.ItemContainerGenerator, in searchInfo); - } - - private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator, - in SearchInfo searchInfo) - { - IEnumerable containers = containerGenerator.Containers; - - foreach (ItemContainerInfo container in containers) - { - TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, in searchInfo); - - if (node != null) - { - return node; - } - } - - return null; - } - - private static TreeViewItem FindFirstNode(TreeViewItem node, in SearchInfo searchInfo) - { - if (node == null) - { - return null; - } - - TreeViewItem match = searchInfo.GetMatch(node); - - if (match != null) - { - return match; - } - - return FindInContainers(node.ItemContainerGenerator, in searchInfo); - } - - /// - /// Node search info. - /// - public readonly struct SearchInfo - { - public readonly TreeViewItem Search1; - public readonly TreeViewItem Search2; - - public SearchInfo(TreeViewItem search1, TreeViewItem search2) - { - Search1 = search1; - Search2 = search2; - } - - public TreeViewItem GetMatch(TreeViewItem candidate) - { - if (candidate == Search1) - { - return Search1; - } - - if (candidate == Search2) - { - return Search2; - } - - return null; - } - } - } -}