From 2a77f4426ad45f7dc35a76b0673281d3dd409dd7 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 19 Feb 2019 18:49:49 +0100 Subject: [PATCH 01/23] Snap dirty rects in DeferredRenderer to device pixels. Fixes #2015. --- .../Rendering/DeferredRenderer.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs index 3bc5e92fb4..5293e1b978 100644 --- a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs +++ b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs @@ -393,24 +393,36 @@ namespace Avalonia.Rendering } else { + var scale = scene.Scaling; + foreach (var rect in layer.Dirty) { + var snappedRect = SnapToDevicePixels(rect, scale); context.Transform = Matrix.Identity; - context.PushClip(rect); + context.PushClip(snappedRect); context.Clear(Colors.Transparent); - Render(context, node, layer.LayerRoot, rect); + Render(context, node, layer.LayerRoot, snappedRect); context.PopClip(); if (DrawDirtyRects) { - _dirtyRectsDisplay.Add(rect); + _dirtyRectsDisplay.Add(snappedRect); } } } } } } + } + private static Rect SnapToDevicePixels(Rect rect, double scale) + { + return new Rect( + Math.Floor(rect.X * scale) / scale, + Math.Floor(rect.Y * scale) / scale, + Math.Ceiling(rect.Width * scale) / scale, + Math.Ceiling(rect.Height * scale) / scale); + } private void RenderOverlay(Scene scene, IDrawingContextImpl parentContent) From 85d309830a4fbe93accbfff2fc83784ac9dd7d75 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Mon, 4 Mar 2019 20:05:58 +0100 Subject: [PATCH 02/23] 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 89d969b367b4e1bd37982361602a74518e431ab9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 4 Mar 2019 22:45:47 +0100 Subject: [PATCH 03/23] Added selector comma operator. The comma selector can be used to separate a number different selectors, all of which will be applied to the control with an OR. Fixes #1742 --- src/Avalonia.Styling/Styling/OrSelector.cs | 131 ++++++++++++++++++ src/Avalonia.Styling/Styling/Selectors.cs | 22 +++ .../Markup/Parsers/SelectorGrammar.cs | 30 ++-- .../Markup/Parsers/SelectorParser.cs | 20 +++ .../Parsers/SelectorGrammarTests.cs | 16 +++ .../Parsers/SelectorParserTests.cs | 7 + .../SelectorTests_Or.cs | 106 ++++++++++++++ 7 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 src/Avalonia.Styling/Styling/OrSelector.cs create mode 100644 tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs diff --git a/src/Avalonia.Styling/Styling/OrSelector.cs b/src/Avalonia.Styling/Styling/OrSelector.cs new file mode 100644 index 0000000000..58c5c778fb --- /dev/null +++ b/src/Avalonia.Styling/Styling/OrSelector.cs @@ -0,0 +1,131 @@ +// 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; +using System.Collections.Generic; + +namespace Avalonia.Styling +{ + /// + /// The OR style selector. + /// + internal class OrSelector : Selector + { + private readonly IReadOnlyList _selectors; + private string _selectorString; + private Type _targetType; + + /// + /// Initializes a new instance of the class. + /// + /// The selectors to OR. + public OrSelector(IReadOnlyList selectors) + { + Contract.Requires(selectors != null); + Contract.Requires(selectors.Count > 1); + + _selectors = selectors; + } + + /// + public override bool InTemplate => false; + + /// + public override bool IsCombinator => false; + + /// + public override Type TargetType + { + get + { + if (_targetType == null) + { + _targetType = EvaluateTargetType(); + } + + return _targetType; + } + } + + /// + public override string ToString() + { + if (_selectorString == null) + { + _selectorString = string.Join(", ", _selectors); + } + + return _selectorString; + } + + protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) + { + var activators = new List>(); + var neverThisInstance = false; + + foreach (var selector in _selectors) + { + var match = selector.Match(control, subscribe); + + switch (match.Result) + { + case SelectorMatchResult.AlwaysThisType: + case SelectorMatchResult.AlwaysThisInstance: + return match; + case SelectorMatchResult.NeverThisInstance: + neverThisInstance = true; + break; + case SelectorMatchResult.Sometimes: + activators.Add(match.Activator); + break; + } + } + + if (activators.Count > 1) + { + return new SelectorMatch(StyleActivator.Or(activators)); + } + else if (activators.Count == 1) + { + return new SelectorMatch(activators[0]); + } + else if (neverThisInstance) + { + return SelectorMatch.NeverThisInstance; + } + else + { + return SelectorMatch.NeverThisType; + } + } + + protected override Selector MovePrevious() => null; + + private Type EvaluateTargetType() + { + var result = default(Type); + + foreach (var selector in _selectors) + { + if (selector.TargetType == null) + { + return null; + } + else if (result == null) + { + result = selector.TargetType; + } + else + { + while (!result.IsAssignableFrom(selector.TargetType)) + { + result = result.BaseType; + } + } + } + + return result; + } + } +} + diff --git a/src/Avalonia.Styling/Styling/Selectors.cs b/src/Avalonia.Styling/Styling/Selectors.cs index deb677e04c..3e7a30d389 100644 --- a/src/Avalonia.Styling/Styling/Selectors.cs +++ b/src/Avalonia.Styling/Styling/Selectors.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.Collections.Generic; +using System.Linq; namespace Avalonia.Styling { @@ -137,6 +139,26 @@ namespace Avalonia.Styling return previous.OfType(typeof(T)); } + /// + /// Returns a selector which ORs selectors. + /// + /// The selectors to be OR'd. + /// The selector. + public static Selector Or(params Selector[] selectors) + { + return new OrSelector(selectors); + } + + /// + /// Returns a selector which ORs selectors. + /// + /// The selectors to be OR'd. + /// The selector. + public static Selector Or(IReadOnlyList selectors) + { + return new OrSelector(selectors); + } + /// /// Returns a selector which matches a control with the specified property value. /// diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs index 55c3aab81f..e11e333a49 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorGrammar.cs @@ -49,7 +49,7 @@ namespace Avalonia.Markup.Parsers state = ParseStart(ref r); break; case State.Middle: - state = ParseMiddle(ref r, end); + (state, syntax) = ParseMiddle(ref r, end); break; case State.CanHaveType: state = ParseCanHaveType(ref r); @@ -113,33 +113,37 @@ namespace Avalonia.Markup.Parsers return State.TypeName; } - private static State ParseMiddle(ref CharacterReader r, char? end) + private static (State, ISyntax) ParseMiddle(ref CharacterReader r, char? end) { if (r.TakeIf(':')) { - return State.Colon; + return (State.Colon, null); } else if (r.TakeIf('.')) { - return State.Class; + return (State.Class, null); } else if (r.TakeIf(char.IsWhiteSpace) || r.Peek == '>') { - return State.Traversal; + return (State.Traversal, null); } else if (r.TakeIf('/')) { - return State.Template; + return (State.Template, null); } else if (r.TakeIf('#')) { - return State.Name; + return (State.Name, null); + } + else if (r.TakeIf(',')) + { + return (State.Start, new CommaSyntax()); } else if (end.HasValue && !r.End && r.Peek == end.Value) { - return State.End; + return (State.End, null); } - return State.TypeName; + return (State.TypeName, null); } private static State ParseCanHaveType(ref CharacterReader r) @@ -415,5 +419,13 @@ namespace Avalonia.Markup.Parsers return (obj is NotSyntax not) && Argument.SequenceEqual(not.Argument); } } + + public class CommaSyntax : ISyntax + { + public override bool Equals(object obj) + { + return obj is CommaSyntax or; + } + } } } diff --git a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs index 8137ac3f48..493579d676 100644 --- a/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs +++ b/src/Markup/Avalonia.Markup/Markup/Parsers/SelectorParser.cs @@ -43,6 +43,7 @@ namespace Avalonia.Markup.Parsers private Selector Create(IEnumerable syntax) { var result = default(Selector); + var results = default(List); foreach (var i in syntax) { @@ -106,11 +107,30 @@ namespace Avalonia.Markup.Parsers case SelectorGrammar.NotSyntax not: result = result.Not(x => Create(not.Argument)); break; + case SelectorGrammar.CommaSyntax comma: + if (results == null) + { + results = new List(); + } + + results.Add(result); + result = null; + break; default: throw new NotSupportedException($"Unsupported selector grammar '{i.GetType()}'."); } } + if (results != null) + { + if (result != null) + { + results.Add(result); + } + + result = results.Count > 1 ? Selectors.Or(results) : results[0]; + } + return result; } diff --git a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs index e3ce4b0968..216043aa20 100644 --- a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorGrammarTests.cs @@ -261,6 +261,22 @@ namespace Avalonia.Markup.UnitTests.Parsers result); } + [Fact] + public void OfType_Comma_Is_Class() + { + var result = SelectorGrammar.Parse("TextBlock, :is(Button).foo"); + + Assert.Equal( + new SelectorGrammar.ISyntax[] + { + new SelectorGrammar.OfTypeSyntax { TypeName = "TextBlock" }, + new SelectorGrammar.CommaSyntax(), + new SelectorGrammar.IsSyntax { TypeName = "Button" }, + new SelectorGrammar.ClassSyntax { Class = "foo" }, + }, + result); + } + [Fact] public void Namespace_Alone_Fails() { diff --git a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs index 1b1a96a7e2..1c0cba56c9 100644 --- a/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Parsers/SelectorParserTests.cs @@ -14,6 +14,13 @@ namespace Avalonia.Markup.UnitTests.Parsers var result = target.Parse("TextBlock[IsPointerOver=True]"); } + [Fact] + public void Parses_Comma_Separated_Selectors() + { + var target = new SelectorParser((ns, type) => typeof(TextBlock)); + var result = target.Parse("TextBlock, TextBlock:foo"); + } + [Fact] public void Throws_If_OfType_Type_Not_Found() { diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs new file mode 100644 index 0000000000..521c73ce27 --- /dev/null +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_Or.cs @@ -0,0 +1,106 @@ +// 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 Xunit; + +namespace Avalonia.Styling.UnitTests +{ + public class SelectorTests_Or + { + [Fact] + public void Or_Selector_Should_Have_Correct_String_Representation() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).OfType().Class("bar")); + + Assert.Equal("Control1.foo, Control2.bar", target.ToString()); + } + + [Fact] + public void Or_Selector_Matches_Control_Of_Correct_Type() + { + var target = Selectors.Or( + default(Selector).OfType(), + default(Selector).OfType().Class("bar")); + var control = new Control1(); + + Assert.Equal(SelectorMatchResult.AlwaysThisType, target.Match(control).Result); + } + + [Fact] + public void Or_Selector_Matches_Control_Of_Correct_Type_With_Class() + { + var target = Selectors.Or( + default(Selector).OfType(), + default(Selector).OfType().Class("bar")); + var control = new Control2(); + + Assert.Equal(SelectorMatchResult.Sometimes, target.Match(control).Result); + } + + [Fact] + public void Or_Selector_Doesnt_Match_Control_Of_Incorrect_Type() + { + var target = Selectors.Or( + default(Selector).OfType(), + default(Selector).OfType().Class("bar")); + var control = new Control3(); + + Assert.Equal(SelectorMatchResult.NeverThisType, target.Match(control).Result); + } + + [Fact] + public void Or_Selector_Doesnt_Match_Control_With_Incorrect_Name() + { + var target = Selectors.Or( + default(Selector).OfType().Name("foo"), + default(Selector).OfType().Name("foo")); + var control = new Control1 { Name = "bar" }; + + Assert.Equal(SelectorMatchResult.NeverThisInstance, target.Match(control).Result); + } + + [Fact] + public void Returns_Correct_TargetType_When_Types_Same() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).OfType().Class("bar")); + + Assert.Equal(typeof(Control1), target.TargetType); + } + + [Fact] + public void Returns_Common_TargetType() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).OfType().Class("bar")); + + Assert.Equal(typeof(TestControlBase), target.TargetType); + } + + [Fact] + public void Returns_Null_TargetType_When_A_Selector_Has_No_TargetType() + { + var target = Selectors.Or( + default(Selector).OfType().Class("foo"), + default(Selector).Class("bar")); + + Assert.Equal(null, target.TargetType); + } + + public class Control1 : TestControlBase + { + } + + public class Control2 : TestControlBase + { + } + + public class Control3 : TestControlBase + { + } + } +} From 36da749d38c9ac3c963585863d22667e692de89c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 4 Mar 2019 22:55:18 +0100 Subject: [PATCH 04/23] Use comma operator for Separator style. --- src/Avalonia.Themes.Default/Separator.xaml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Avalonia.Themes.Default/Separator.xaml b/src/Avalonia.Themes.Default/Separator.xaml index 6312a14df5..cf0db16ee6 100644 --- a/src/Avalonia.Themes.Default/Separator.xaml +++ b/src/Avalonia.Themes.Default/Separator.xaml @@ -11,13 +11,7 @@ - - -