From 2584f4bb18ebacbf66cc1d4e3e387479dec27563 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 22 Jul 2018 22:29:00 +0200 Subject: [PATCH 01/22] Removed directional navigation code. --- src/Avalonia.Controls/MenuItem.cs | 5 +- .../Presenters/ItemsPresenter.cs | 9 +- src/Avalonia.Controls/TreeViewItem.cs | 5 +- src/Avalonia.Input/KeyboardNavigation.cs | 33 - .../KeyboardNavigationHandler.cs | 47 +- .../Navigation/DirectionalNavigation.cs | 242 ------ .../KeyboardNavigationTests_Arrows.cs | 799 ------------------ .../KeyboardNavigationTests_Custom.cs | 31 - 8 files changed, 9 insertions(+), 1162 deletions(-) delete mode 100644 src/Avalonia.Input/Navigation/DirectionalNavigation.cs delete mode 100644 tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 96f6fb59b0..cec653e045 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -72,10 +72,7 @@ namespace Avalonia.Controls /// The default value for the property. /// private static readonly ITemplate DefaultPanel = - new FuncTemplate(() => new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - }); + new FuncTemplate(() => new StackPanel()); /// /// The timer used to display submenus. diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index f8d62a1cbf..500c7aa187 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -143,13 +143,6 @@ namespace Avalonia.Controls.Presenters Virtualizer = ItemVirtualizer.Create(this); ((ILogicalScrollable)this).InvalidateScroll?.Invoke(); - if (!Panel.IsSet(KeyboardNavigation.DirectionalNavigationProperty)) - { - KeyboardNavigation.SetDirectionalNavigation( - (InputElement)Panel, - KeyboardNavigationMode.Contained); - } - KeyboardNavigation.SetTabNavigation( (InputElement)Panel, KeyboardNavigation.GetTabNavigation(this)); @@ -175,4 +168,4 @@ namespace Avalonia.Controls.Presenters ((ILogicalScrollable)this).InvalidateScroll?.Invoke(); } } -} \ No newline at end of file +} diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index bed27ef033..8af3333dd4 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -32,10 +32,7 @@ namespace Avalonia.Controls ListBoxItem.IsSelectedProperty.AddOwner(); private static readonly ITemplate DefaultPanel = - new FuncTemplate(() => new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - }); + new FuncTemplate(() => new StackPanel()); private TreeView _treeView; private bool _isExpanded; diff --git a/src/Avalonia.Input/KeyboardNavigation.cs b/src/Avalonia.Input/KeyboardNavigation.cs index cbd9a74f4c..0277876e24 100644 --- a/src/Avalonia.Input/KeyboardNavigation.cs +++ b/src/Avalonia.Input/KeyboardNavigation.cs @@ -8,19 +8,6 @@ namespace Avalonia.Input /// public static class KeyboardNavigation { - /// - /// Defines the DirectionalNavigation attached property. - /// - /// - /// The DirectionalNavigation attached property defines how pressing arrow keys causes - /// focus to be navigated between the children of the container. - /// - public static readonly AttachedProperty DirectionalNavigationProperty = - AvaloniaProperty.RegisterAttached( - "DirectionalNavigation", - typeof(KeyboardNavigation), - KeyboardNavigationMode.None); - /// /// Defines the TabNavigation attached property. /// @@ -46,26 +33,6 @@ namespace Avalonia.Input "TabOnceActiveElement", typeof(KeyboardNavigation)); - /// - /// Gets the for a container. - /// - /// The container. - /// The for the container. - public static KeyboardNavigationMode GetDirectionalNavigation(InputElement element) - { - return element.GetValue(DirectionalNavigationProperty); - } - - /// - /// Sets the for a container. - /// - /// The container. - /// The for the container. - public static void SetDirectionalNavigation(InputElement element, KeyboardNavigationMode value) - { - element.SetValue(DirectionalNavigationProperty, value); - } - /// /// Gets the for a container. /// diff --git a/src/Avalonia.Input/KeyboardNavigationHandler.cs b/src/Avalonia.Input/KeyboardNavigationHandler.cs index bf2b61d08b..bc3098a7fb 100644 --- a/src/Avalonia.Input/KeyboardNavigationHandler.cs +++ b/src/Avalonia.Input/KeyboardNavigationHandler.cs @@ -85,7 +85,7 @@ namespace Avalonia.Input } else { - return DirectionalNavigation.GetNext(element, direction); + throw new NotSupportedException(); } } @@ -122,47 +122,12 @@ namespace Avalonia.Input { var current = FocusManager.Instance.Current; - if (current != null) + if (current != null && e.Key == Key.Tab) { - NavigationDirection? direction = null; - - switch (e.Key) - { - case Key.Tab: - direction = (e.Modifiers & InputModifiers.Shift) == 0 ? - NavigationDirection.Next : NavigationDirection.Previous; - break; - case Key.Up: - direction = NavigationDirection.Up; - break; - case Key.Down: - direction = NavigationDirection.Down; - break; - case Key.Left: - direction = NavigationDirection.Left; - break; - case Key.Right: - direction = NavigationDirection.Right; - break; - case Key.PageUp: - direction = NavigationDirection.PageUp; - break; - case Key.PageDown: - direction = NavigationDirection.PageDown; - break; - case Key.Home: - direction = NavigationDirection.First; - break; - case Key.End: - direction = NavigationDirection.Last; - break; - } - - if (direction.HasValue) - { - Move(current, direction.Value, e.Modifiers); - e.Handled = true; - } + var direction = (e.Modifiers & InputModifiers.Shift) == 0 ? + NavigationDirection.Next : NavigationDirection.Previous; + Move(current, direction, e.Modifiers); + e.Handled = true; } } } diff --git a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs b/src/Avalonia.Input/Navigation/DirectionalNavigation.cs deleted file mode 100644 index 75cb3a39e8..0000000000 --- a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs +++ /dev/null @@ -1,242 +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; -using System.Collections.Generic; -using System.Linq; -using Avalonia.VisualTree; - -namespace Avalonia.Input.Navigation -{ - /// - /// The implementation for default directional navigation. - /// - public static class DirectionalNavigation - { - /// - /// Gets the next control in the specified navigation direction. - /// - /// The element. - /// The navigation direction. - /// - /// The next element in the specified direction, or null if - /// was the last in the requested direction. - /// - public static IInputElement GetNext( - IInputElement element, - NavigationDirection direction) - { - Contract.Requires(element != null); - Contract.Requires( - direction != NavigationDirection.Next && - direction != NavigationDirection.Previous); - - var container = element.GetVisualParent(); - - if (container != null) - { - var mode = KeyboardNavigation.GetDirectionalNavigation((InputElement)container); - - switch (mode) - { - case KeyboardNavigationMode.Continue: - return GetNextInContainer(element, container, direction) ?? - GetFirstInNextContainer(element, element, direction); - case KeyboardNavigationMode.Cycle: - return GetNextInContainer(element, container, direction) ?? - GetFocusableDescendant(container, direction); - case KeyboardNavigationMode.Contained: - return GetNextInContainer(element, container, direction); - default: - return null; - } - } - else - { - return GetFocusableDescendants(element).FirstOrDefault(); - } - } - - /// - /// Returns a value indicting whether the specified direction is forward. - /// - /// The direction. - /// True if the direction is forward. - private static bool IsForward(NavigationDirection direction) - { - return direction == NavigationDirection.Next || - direction == NavigationDirection.Last || - direction == NavigationDirection.Right || - direction == NavigationDirection.Down; - } - - /// - /// Gets the first or last focusable descendant of the specified element. - /// - /// The element. - /// The direction to search. - /// The element or null if not found.## - private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction) - { - return IsForward(direction) ? - GetFocusableDescendants(container).FirstOrDefault() : - GetFocusableDescendants(container).LastOrDefault(); - } - - /// - /// Gets the focusable descendants of the specified element. - /// - /// The element. - /// The element's focusable descendants. - private static IEnumerable GetFocusableDescendants(IInputElement element) - { - var children = element.GetVisualChildren().OfType(); - - foreach (var child in children) - { - if (child.CanFocus()) - { - yield return child; - } - - if (child.CanFocusDescendants()) - { - foreach (var descendant in GetFocusableDescendants(child)) - { - yield return descendant; - } - } - } - } - - /// - /// Gets the next item that should be focused in the specified container. - /// - /// The starting element/ - /// The container. - /// The direction. - /// The next element, or null if the element is the last. - private static IInputElement GetNextInContainer( - IInputElement element, - IInputElement container, - NavigationDirection direction) - { - if (direction == NavigationDirection.Down) - { - var descendant = GetFocusableDescendants(element).FirstOrDefault(); - - if (descendant != null) - { - return descendant; - } - } - - if (container != null) - { - var navigable = container as INavigableContainer; - - if (navigable != null) - { - while (element != null) - { - element = navigable.GetControl(direction, element); - - if (element != null && element.CanFocus()) - { - break; - } - } - } - else - { - // TODO: Do a spatial search here if the container doesn't implement - // INavigableContainer. - element = null; - } - - if (element != null && direction == NavigationDirection.Up) - { - var descendant = GetFocusableDescendants(element).LastOrDefault(); - - if (descendant != null) - { - return descendant; - } - } - - return element; - } - - return null; - } - - /// - /// Gets the first item that should be focused in the next container. - /// - /// The element being navigated away from. - /// The container. - /// The direction of the search. - /// The first element, or null if there are no more elements. - private static IInputElement GetFirstInNextContainer( - IInputElement element, - IInputElement container, - NavigationDirection direction) - { - var parent = container.GetVisualParent(); - var isForward = IsForward(direction); - IInputElement next = null; - - if (parent != null) - { - if (!isForward && parent.CanFocus()) - { - return parent; - } - - var siblings = parent.GetVisualChildren() - .OfType() - .Where(FocusExtensions.CanFocusDescendants); - var sibling = isForward ? - siblings.SkipWhile(x => x != container).Skip(1).FirstOrDefault() : - siblings.TakeWhile(x => x != container).LastOrDefault(); - - if (sibling != null) - { - if (sibling is ICustomKeyboardNavigation custom) - { - var (handled, customNext) = custom.GetNext(element, direction); - - if (handled) - { - return customNext; - } - } - - if (sibling.CanFocus()) - { - next = sibling; - } - else - { - next = isForward ? - GetFocusableDescendants(sibling).FirstOrDefault() : - GetFocusableDescendants(sibling).LastOrDefault(); - } - } - - if (next == null) - { - next = GetFirstInNextContainer(element, parent, direction); - } - } - else - { - next = isForward ? - GetFocusableDescendants(container).FirstOrDefault() : - GetFocusableDescendants(container).LastOrDefault(); - } - - return next; - } - } -} diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs deleted file mode 100644 index b81b724e2a..0000000000 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs +++ /dev/null @@ -1,799 +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 Avalonia.Controls; -using Xunit; - -namespace Avalonia.Input.UnitTests -{ - public class KeyboardNavigationTests_Arrows - { - [Fact] - public void Down_Continue_Returns_Down_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_First_Control_In_Down_Sibling_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_Down_Sibling() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - (next = new Button { Name = "Button4" }), - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_First_Control_In_Down_Uncle_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_Child_Of_Top_Level() - { - Button next; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button1" }), - } - }; - - var result = KeyboardNavigationHandler.GetNext(top, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Wraps() - { - Button current; - Button next; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - new Button { Name = "Button3" }, - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - (current = new Button { Name = "Button6" }), - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Cycle_Returns_Down_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Cycle_Wraps_To_First() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - (next = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Contained_Returns_Down_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Contained_Stops_At_End() - { - Button current; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Null(result); - } - - [Fact] - public void Down_None_Does_Nothing() - { - Button current; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.None, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Null(result); - } - - [Fact] - public void Up_Continue_Returns_Up_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - (next = new Button { Name = "Button2" }), - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Last_Control_In_Up_Sibling_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (current = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Last_Child_Of_Sibling() - { - Button current; - Button next; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - (current = new Button { Name = "Button4" }), - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Last_Control_In_Up_Nephew_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (current = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Wraps() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (current = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - new Button { Name = "Button3" }, - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - (next = new Button { Name = "Button6" }), - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Parent() - { - Button current; - - var top = new Decorator - { - Focusable = true, - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Child = current = new Button - { - Name = "Button", - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(top, result); - } - - [Fact] - public void Up_Cycle_Returns_Up_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - (next = new Button { Name = "Button1" }), - (current = new Button { Name = "Button2" }), - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Cycle_Wraps_To_Last() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - (current = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Contained_Returns_Up_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - (next = new Button { Name = "Button1" }), - (current = new Button { Name = "Button2" }), - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Contained_Stops_At_Beginning() - { - Button current; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - (current = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Null(result); - } - - [Fact] - public void Up_Contained_Doesnt_Return_Child_Control() - { - Decorator current; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - (current = new Decorator - { - Focusable = true, - Child = new Button(), - }) - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Null(result); - } - } -} diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs index a090dcd18d..ab0f5e2155 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs @@ -140,37 +140,6 @@ namespace Avalonia.Input.UnitTests Assert.Same(next, result); } - [Fact] - public void Right_Should_Custom_Navigate_From_Outside() - { - Button current; - Button next; - var target = new CustomNavigatingStackPanel - { - Children = - { - new Button { Content = "Button 1" }, - new Button { Content = "Button 2" }, - (next = new Button { Content = "Button 3" }), - }, - NextControl = next, - }; - - var root = new StackPanel - { - Children = - { - (current = new Button { Content = "Outside" }), - target, - }, - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right); - - Assert.Same(next, result); - } - [Fact] public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next() { From 10c2ec64add55f7bceeec68d6140569a781f86ab Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 22 Jul 2018 22:53:40 +0200 Subject: [PATCH 02/22] Implement directional navigation in ItemsControl. --- src/Avalonia.Controls/ItemsControl.cs | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 3cb997f615..998111e8b9 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -323,6 +323,41 @@ namespace Avalonia.Controls LogicalChildren.RemoveAll(toRemove); } + protected override void OnKeyDown(KeyEventArgs e) + { + if (Presenter?.Panel is INavigableContainer container) + { + var focus = FocusManager.Instance; + var current = focus.Current; + NavigationDirection? direction = null; + + switch (e.Key) + { + case Key.Up: direction = NavigationDirection.Up; break; + case Key.Down: direction = NavigationDirection.Down; break; + case Key.Left: direction = NavigationDirection.Left; break; + case Key.Right: direction = NavigationDirection.Right; break; + case Key.Home: direction = NavigationDirection.First; break; + case Key.End: direction = NavigationDirection.Last; break; + case Key.PageUp: direction = NavigationDirection.PageUp; break; + case Key.PageDown: direction = NavigationDirection.PageDown; break; + } + + if (direction != null && current != null) + { + var next = container.GetControl(direction.Value, current); + + if (next != null) + { + focus.Focus(next, NavigationMethod.Directional); + e.Handled = true; + } + } + } + + base.OnKeyDown(e); + } + /// /// Caled when the property changes. /// From 9e2e266d3c3a85c079e11137482b8c717884c699 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 23 Jul 2018 09:54:00 +0200 Subject: [PATCH 03/22] Handle navigation in TreeView. --- src/Avalonia.Controls/ItemsControl.cs | 72 ++++++++++++------- src/Avalonia.Controls/TreeView.cs | 86 +++++++++++++++++++++++ src/Avalonia.Controls/TreeViewItem.cs | 2 +- src/Avalonia.Input/NavigationDirection.cs | 70 ++++++++++++++++++ 4 files changed, 202 insertions(+), 28 deletions(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 998111e8b9..e2d2f0a516 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -15,6 +15,7 @@ using Avalonia.Controls.Utils; using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Metadata; +using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -323,35 +324,37 @@ namespace Avalonia.Controls LogicalChildren.RemoveAll(toRemove); } + /// + /// Handles directional navigation within the . + /// + /// The key events. protected override void OnKeyDown(KeyEventArgs e) { - if (Presenter?.Panel is INavigableContainer container) + var focus = FocusManager.Instance; + var direction = e.Key.ToNavigationDirection(); + var container = Presenter?.Panel as INavigableContainer; + + if (container == null || + focus.Current == null || + direction == null || + direction.Value.IsTab()) { - var focus = FocusManager.Instance; - var current = focus.Current; - NavigationDirection? direction = null; + return; + } - switch (e.Key) - { - case Key.Up: direction = NavigationDirection.Up; break; - case Key.Down: direction = NavigationDirection.Down; break; - case Key.Left: direction = NavigationDirection.Left; break; - case Key.Right: direction = NavigationDirection.Right; break; - case Key.Home: direction = NavigationDirection.First; break; - case Key.End: direction = NavigationDirection.Last; break; - case Key.PageUp: direction = NavigationDirection.PageUp; break; - case Key.PageDown: direction = NavigationDirection.PageDown; break; - } + var current = focus.Current + .GetSelfAndVisualAncestors() + .OfType() + .FirstOrDefault(x => x.VisualParent == container); - if (direction != null && current != null) - { - var next = container.GetControl(direction.Value, current); + if (current != null) + { + var next = container.GetControl(direction.Value, current); - if (next != null) - { - focus.Focus(next, NavigationMethod.Directional); - e.Handled = true; - } + if (next != null) + { + focus.Focus(next, NavigationMethod.Directional); + e.Handled = true; } } @@ -370,6 +373,7 @@ namespace Avalonia.Controls var oldValue = e.OldValue as IEnumerable; var newValue = e.NewValue as IEnumerable; + UpdateItemCount(); RemoveControlItemsFromLogicalChildren(oldValue); AddControlItemsToLogicalChildren(newValue); SubscribeToItems(newValue); @@ -393,10 +397,8 @@ namespace Avalonia.Controls RemoveControlItemsFromLogicalChildren(e.OldItems); break; } - - int? count = (Items as IList)?.Count; - if (count != null) - ItemCount = (int)count; + + UpdateItemCount(); var collection = sender as ICollection; PseudoClasses.Set(":empty", collection == null || collection.Count == 0); @@ -480,5 +482,21 @@ namespace Avalonia.Controls // TODO: Rebuild the item containers. } } + + private void UpdateItemCount() + { + if (Items == null) + { + ItemCount = 0; + } + else if (Items is IList list) + { + ItemCount = list.Count; + } + else + { + ItemCount = Items.Count(); + } + } } } diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 2e1c011685..4575fa767b 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -136,6 +136,92 @@ namespace Avalonia.Controls } } + protected override void OnKeyDown(KeyEventArgs e) + { + var direction = e.Key.ToNavigationDirection(); + + if (direction?.IsDirectional() == true && !e.Handled) + { + if (SelectedItem != null) + { + var next = GetContainerInDirection( + GetContainerFromEventSource(e.Source) as TreeViewItem, + direction.Value, + true); + + if (next != null) + { + FocusManager.Instance.Focus(next, NavigationMethod.Directional); + e.Handled = true; + } + } + else + { + SelectedItem = ElementAt(Items, 0); + } + } + } + + private TreeViewItem GetContainerInDirection( + TreeViewItem from, + NavigationDirection direction, + bool intoChildren) + { + IItemContainerGenerator parentGenerator; + + if (from?.Parent is TreeView treeView) + { + parentGenerator = treeView.ItemContainerGenerator; + } + else if (from?.Parent is TreeViewItem item) + { + parentGenerator = item.ItemContainerGenerator; + } + else + { + return null; + } + + var index = parentGenerator.IndexFromContainer(from); + var parent = from.Parent as ItemsControl; + TreeViewItem result = null; + + switch (direction) + { + case NavigationDirection.Up: + if (index > 0) + { + var previous = (TreeViewItem)parentGenerator.ContainerFromIndex(index - 1); + result = previous.IsExpanded ? + (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) : + previous; + } + else + { + result = from.Parent as TreeViewItem; + } + + break; + + case NavigationDirection.Down: + if (from.IsExpanded && intoChildren) + { + result = (TreeViewItem)from.ItemContainerGenerator.ContainerFromIndex(0); + } + else if (index < parent?.ItemCount - 1) + { + result = (TreeViewItem)parentGenerator.ContainerFromIndex(index + 1); + } + else if (parent is TreeViewItem parentItem) + { + return GetContainerInDirection(parentItem, direction, false); + } + break; + } + + return result; + } + /// protected override void OnPointerPressed(PointerPressedEventArgs e) { diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index 8af3333dd4..0886c05038 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -124,7 +124,7 @@ namespace Avalonia.Controls } } - base.OnKeyDown(e); + // Don't call base.OnKeyDown - let events bubble up to containing TreeView. } } } diff --git a/src/Avalonia.Input/NavigationDirection.cs b/src/Avalonia.Input/NavigationDirection.cs index fbaa7e74c7..406890b767 100644 --- a/src/Avalonia.Input/NavigationDirection.cs +++ b/src/Avalonia.Input/NavigationDirection.cs @@ -58,4 +58,74 @@ namespace Avalonia.Input /// PageDown, } + + public static class NavigationDirectionExtensions + { + /// + /// Checks whether a represents a tab movement. + /// + /// The direction. + /// + /// True if the direction represents a tab movement ( + /// or ); otherwise false. + /// + public static bool IsTab(this NavigationDirection direction) + { + return direction == NavigationDirection.Next || + direction == NavigationDirection.Previous; + } + + /// + /// Checks whether a represents a directional movement. + /// + /// The direction. + /// + /// True if the direction represents a directional movement (any value except + /// and ); + /// otherwise false. + /// + public static bool IsDirectional(this NavigationDirection direction) + { + return direction > NavigationDirection.Previous || + direction <= NavigationDirection.PageDown; + } + + /// + /// Converts a keypress into a . + /// + /// The key. + /// The keyboard modifiers. + /// + /// A if the keypress represents a navigation keypress. + /// + public static NavigationDirection? ToNavigationDirection( + this Key key, + InputModifiers modifiers = InputModifiers.None) + { + switch (key) + { + case Key.Tab: + return (modifiers & InputModifiers.Shift) != 0 ? + NavigationDirection.Next : NavigationDirection.Previous; + case Key.Up: + return NavigationDirection.Up; + case Key.Down: + return NavigationDirection.Down; + case Key.Left: + return NavigationDirection.Left; + case Key.Right: + return NavigationDirection.Right; + case Key.Home: + return NavigationDirection.First; + case Key.End: + return NavigationDirection.Last; + case Key.PageUp: + return NavigationDirection.PageUp; + case Key.PageDown: + return NavigationDirection.PageDown; + default: + return null; + } + } + } } From 1e12b2c37b07cc3e046cb200aa8882f0f75a4a47 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 11 Aug 2018 00:29:13 +0200 Subject: [PATCH 04/22] Don't focus non-focusable controls. When navigating `ItemsControl` with keyboard. --- src/Avalonia.Controls/ItemsControl.cs | 67 +++++++++++------ .../ItemsControlTests.cs | 72 +++++++++++++++++++ 2 files changed, 118 insertions(+), 21 deletions(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index e2d2f0a516..7fdb75a9fc 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -330,31 +330,34 @@ namespace Avalonia.Controls /// The key events. protected override void OnKeyDown(KeyEventArgs e) { - var focus = FocusManager.Instance; - var direction = e.Key.ToNavigationDirection(); - var container = Presenter?.Panel as INavigableContainer; - - if (container == null || - focus.Current == null || - direction == null || - direction.Value.IsTab()) + if (!e.Handled) { - return; - } - - var current = focus.Current - .GetSelfAndVisualAncestors() - .OfType() - .FirstOrDefault(x => x.VisualParent == container); + var focus = FocusManager.Instance; + var direction = e.Key.ToNavigationDirection(); + var container = Presenter?.Panel as INavigableContainer; + + if (container == null || + focus.Current == null || + direction == null || + direction.Value.IsTab()) + { + return; + } - if (current != null) - { - var next = container.GetControl(direction.Value, current); + var current = focus.Current + .GetSelfAndVisualAncestors() + .OfType() + .FirstOrDefault(x => x.VisualParent == container); - if (next != null) + if (current != null) { - focus.Focus(next, NavigationMethod.Directional); - e.Handled = true; + var next = GetNextControl(container, direction.Value, current); + + if (next != null) + { + focus.Focus(next, NavigationMethod.Directional); + e.Handled = true; + } } } @@ -498,5 +501,27 @@ namespace Avalonia.Controls ItemCount = Items.Count(); } } + + protected static IInputElement GetNextControl( + INavigableContainer container, + NavigationDirection direction, + IInputElement from) + { + IInputElement result; + + while (from != null) + { + result = container.GetControl(direction, from); + + if (result?.Focusable == true) + { + return result; + } + + from = result; + } + + return null; + } } } diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 9ef1e9f0d2..3cf886ade4 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -11,6 +11,7 @@ using Avalonia.VisualTree; using Xunit; using System.Collections.ObjectModel; using Avalonia.UnitTests; +using Avalonia.Input; namespace Avalonia.Controls.UnitTests { @@ -494,6 +495,77 @@ namespace Avalonia.Controls.UnitTests Assert.NotNull(NameScope.GetNameScope((TextBlock)container.Child)); } + [Fact] + public void Focuses_Next_Item_On_Key_Down() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var items = new object[] + { + new Button(), + new Button(), + }; + + var target = new ItemsControl + { + Template = GetTemplate(), + Items = items, + }; + + var root = new TestRoot { Child = target }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.Presenter.Panel.Children[0].Focus(); + + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Down, + }); + + Assert.Equal( + target.Presenter.Panel.Children[1], + FocusManager.Instance.Current); + } + } + + [Fact] + public void Does_Not_Focus_Non_Focusable_Item_On_Key_Down() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var items = new object[] + { + new Button(), + new Button { Focusable = false }, + new Button(), + }; + + var target = new ItemsControl + { + Template = GetTemplate(), + Items = items, + }; + + var root = new TestRoot { Child = target }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.Presenter.Panel.Children[0].Focus(); + + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Down, + }); + + Assert.Equal( + target.Presenter.Panel.Children[2], + FocusManager.Instance.Current); + } + } + private class Item { public Item(string value) From 2cf046e3ff7dacff95d80c71fb7cd535acda00c2 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 11 Aug 2018 00:30:19 +0200 Subject: [PATCH 05/22] Update menu item default themes. --- src/Avalonia.Themes.Default/MenuItem.xaml | 2 +- src/Avalonia.Themes.Default/Separator.xaml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Themes.Default/MenuItem.xaml b/src/Avalonia.Themes.Default/MenuItem.xaml index 53965db016..5404117363 100644 --- a/src/Avalonia.Themes.Default/MenuItem.xaml +++ b/src/Avalonia.Themes.Default/MenuItem.xaml @@ -139,4 +139,4 @@ - \ No newline at end of file + diff --git a/src/Avalonia.Themes.Default/Separator.xaml b/src/Avalonia.Themes.Default/Separator.xaml index 3b3a9e9749..6312a14df5 100644 --- a/src/Avalonia.Themes.Default/Separator.xaml +++ b/src/Avalonia.Themes.Default/Separator.xaml @@ -1,6 +1,7 @@ - \ No newline at end of file + From 1293e9af8df8d6309104ec97184ec40d39990975 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 14 Aug 2018 12:49:33 +0200 Subject: [PATCH 06/22] Implemented Menu interactions. --- src/Avalonia.Controls/Canvas.cs | 3 +- src/Avalonia.Controls/IMenu.cs | 21 + src/Avalonia.Controls/IMenuElement.cs | 40 ++ src/Avalonia.Controls/IMenuItem.cs | 41 ++ src/Avalonia.Controls/ItemsControl.cs | 11 +- src/Avalonia.Controls/Menu.cs | 250 ++++----- src/Avalonia.Controls/MenuItem.cs | 292 +++++------ .../Platform/DefaultMenuInteractionHandler.cs | 457 ++++++++++++++++ .../Platform/IMenuInteractionHandler.cs | 22 + .../Primitives/SelectingItemsControl.cs | 36 ++ src/Avalonia.Controls/StackPanel.cs | 58 ++- src/Avalonia.Controls/WrapPanel.cs | 5 +- src/Avalonia.Input/AccessKeyHandler.cs | 37 +- src/Avalonia.Input/IInputElement.cs | 2 +- src/Avalonia.Input/IMainMenu.cs | 7 + src/Avalonia.Input/INavigableContainer.cs | 3 +- src/Avalonia.Input/InputElement.cs | 2 +- .../Navigation/TabNavigation.cs | 2 +- src/Avalonia.Themes.Default/MenuItem.xaml | 5 - .../DefaultMenuInteractionHandlerTests.cs | 489 ++++++++++++++++++ .../VirtualizingStackPanelTests.cs | 2 +- 21 files changed, 1447 insertions(+), 338 deletions(-) create mode 100644 src/Avalonia.Controls/IMenu.cs create mode 100644 src/Avalonia.Controls/IMenuElement.cs create mode 100644 src/Avalonia.Controls/IMenuItem.cs create mode 100644 src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs create mode 100644 src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs diff --git a/src/Avalonia.Controls/Canvas.cs b/src/Avalonia.Controls/Canvas.cs index 8a80d6bdf7..5c9a97cb27 100644 --- a/src/Avalonia.Controls/Canvas.cs +++ b/src/Avalonia.Controls/Canvas.cs @@ -136,8 +136,9 @@ namespace Avalonia.Controls /// /// The movement direction. /// The control from which movement begins. + /// Whether to wrap around when the first or last item is reached. /// The control. - IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) + IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap) { // TODO: Implement this return null; diff --git a/src/Avalonia.Controls/IMenu.cs b/src/Avalonia.Controls/IMenu.cs new file mode 100644 index 0000000000..e118ec043c --- /dev/null +++ b/src/Avalonia.Controls/IMenu.cs @@ -0,0 +1,21 @@ +using System; +using Avalonia.Controls.Platform; + +namespace Avalonia.Controls +{ + /// + /// Represents a or . + /// + public interface IMenu : IMenuElement + { + /// + /// Gets the menu interaction handler. + /// + IMenuInteractionHandler InteractionHandler { get; } + + /// + /// Gets a value indicating whether the menu is open. + /// + bool IsOpen { get; } + } +} diff --git a/src/Avalonia.Controls/IMenuElement.cs b/src/Avalonia.Controls/IMenuElement.cs new file mode 100644 index 0000000000..c9fc04dcc8 --- /dev/null +++ b/src/Avalonia.Controls/IMenuElement.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using Avalonia.Input; + +namespace Avalonia.Controls +{ + /// + /// Represents an or . + /// + public interface IMenuElement : IControl + { + /// + /// Gets or sets the currently selected submenu item. + /// + IMenuItem SelectedItem { get; set; } + + /// + /// Gets the submenu items. + /// + IEnumerable SubItems { get; } + + /// + /// Opens the menu or menu item. + /// + void Open(); + + /// + /// Closes the menu or menu item. + /// + void Close(); + + /// + /// Moves the submenu selection in the specified direction. + /// + /// The direction. + /// Whether to wrap after the first or last item. + /// True if the selection was moved; otherwise false. + bool MoveSelection(NavigationDirection direction, bool wrap); + } +} diff --git a/src/Avalonia.Controls/IMenuItem.cs b/src/Avalonia.Controls/IMenuItem.cs new file mode 100644 index 0000000000..2657b1949f --- /dev/null +++ b/src/Avalonia.Controls/IMenuItem.cs @@ -0,0 +1,41 @@ +using System; + +namespace Avalonia.Controls +{ + /// + /// Represents a . + /// + public interface IMenuItem : IMenuElement + { + /// + /// Gets or sets a value that indicates whether the item has a submenu. + /// + bool HasSubMenu { get; } + + /// + /// Gets a value indicating whether the mouse is currently over the menu item's submenu. + /// + bool IsPointerOverSubMenu { get; } + + /// + /// Gets or sets a value that indicates whether the submenu of the is + /// open. + /// + bool IsSubMenuOpen { get; set; } + + /// + /// Gets a value that indicates whether the is a top-level main menu item. + /// + bool IsTopLevel { get; } + + /// + /// Gets the parent . + /// + new IMenuElement Parent { get; } + + /// + /// Raises a click event on the menu item. + /// + void RaiseClick(); + } +} diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 7fdb75a9fc..676e0af3de 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -351,7 +351,7 @@ namespace Avalonia.Controls if (current != null) { - var next = GetNextControl(container, direction.Value, current); + var next = GetNextControl(container, direction.Value, current, false); if (next != null) { @@ -505,13 +505,14 @@ namespace Avalonia.Controls protected static IInputElement GetNextControl( INavigableContainer container, NavigationDirection direction, - IInputElement from) + IInputElement from, + bool wrap) { IInputElement result; - while (from != null) + do { - result = container.GetControl(direction, from); + result = container.GetControl(direction, from, wrap); if (result?.Focusable == true) { @@ -519,7 +520,7 @@ namespace Avalonia.Controls } from = result; - } + } while (from != null); return null; } diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index 994af9dab8..edd7ed489e 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -2,30 +2,23 @@ // 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; -using System.Reactive.Disposables; using Avalonia.Controls.Generators; +using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; -using Avalonia.Input.Raw; using Avalonia.Interactivity; using Avalonia.LogicalTree; -using Avalonia.Rendering; namespace Avalonia.Controls { /// /// A top-level menu control. /// - public class Menu : SelectingItemsControl, IFocusScope, IMainMenu + public class Menu : SelectingItemsControl, IFocusScope, IMainMenu, IMenu { - /// - /// Defines the default items panel used by a . - /// - private static readonly ITemplate DefaultPanel = - new FuncTemplate(() => new StackPanel { Orientation = Orientation.Horizontal }); - /// /// Defines the property. /// @@ -34,12 +27,42 @@ namespace Avalonia.Controls nameof(IsOpen), o => o.IsOpen); + /// + /// Defines the event. + /// + public static readonly RoutedEvent MenuOpenedEvent = + RoutedEvent.Register(nameof(MenuOpened), RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent MenuClosedEvent = + RoutedEvent.Register(nameof(MenuClosed), RoutingStrategies.Bubble); + + private static readonly ITemplate DefaultPanel = + new FuncTemplate(() => new StackPanel { Orientation = Orientation.Horizontal }); + private readonly IMenuInteractionHandler _interaction; private bool _isOpen; /// - /// Tracks event handlers added to the root of the visual tree. + /// Initializes a new instance of the class. + /// + public Menu() + { + _interaction = AvaloniaLocator.Current.GetService() ?? + new DefaultMenuInteractionHandler(); + } + + /// + /// Initializes a new instance of the class. /// - private IDisposable _subscription; + /// The menu iteraction handler. + public Menu(IMenuInteractionHandler interactionHandler) + { + Contract.Requires(interactionHandler != null); + + _interaction = interactionHandler; + } /// /// Initializes static members of the class. @@ -47,7 +70,6 @@ namespace Avalonia.Controls static Menu() { ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel); - MenuItem.ClickEvent.AddClassHandler(x => x.OnMenuClick, handledEventsToo: true); MenuItem.SubmenuOpenedEvent.AddClassHandler(x => x.OnSubmenuOpened); } @@ -60,18 +82,52 @@ namespace Avalonia.Controls private set { SetAndRaise(IsOpenProperty, ref _isOpen, value); } } - /// - /// Gets the selected container. - /// - private MenuItem SelectedMenuItem + /// + IMenuInteractionHandler IMenu.InteractionHandler => _interaction; + + /// + IMenuItem IMenuElement.SelectedItem { get { var index = SelectedIndex; return (index != -1) ? - (MenuItem)ItemContainerGenerator.ContainerFromIndex(index) : + (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) : null; } + set + { + SelectedIndex = ItemContainerGenerator.IndexFromContainer(value); + } + } + + /// + IEnumerable IMenuElement.SubItems + { + get + { + return ItemContainerGenerator.Containers + .Select(x => x.ContainerControl) + .OfType(); + } + } + + /// + /// Occurs when a is opened. + /// + public event EventHandler MenuOpened + { + add { AddHandler(MenuOpenedEvent, value); } + remove { RemoveHandler(MenuOpenedEvent, value); } + } + + /// + /// Occurs when a is closed. + /// + public event EventHandler MenuClosed + { + add { AddHandler(MenuClosedEvent, value); } + remove { RemoveHandler(MenuClosedEvent, value); } } /// @@ -79,13 +135,22 @@ namespace Avalonia.Controls /// public void Close() { - foreach (MenuItem i in this.GetLogicalChildren()) + if (IsOpen) { - i.IsSubMenuOpen = false; - } + foreach (var i in ((IMenu)this).SubItems) + { + i.Close(); + } + + IsOpen = false; + SelectedIndex = -1; - IsOpen = false; - SelectedIndex = -1; + RaiseEvent(new RoutedEventArgs + { + RoutedEvent = MenuClosedEvent, + Source = this, + }); + } } /// @@ -93,9 +158,25 @@ namespace Avalonia.Controls /// public void Open() { - SelectedIndex = 0; - SelectedMenuItem.Focus(); - IsOpen = true; + if (!IsOpen) + { + IsOpen = true; + + RaiseEvent(new RoutedEventArgs + { + RoutedEvent = MenuOpenedEvent, + Source = this, + }); + } + } + + /// + bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap); + + /// + protected override IItemContainerGenerator CreateItemContainerGenerator() + { + return new ItemContainerGenerator(this, MenuItem.HeaderProperty, null); } /// @@ -103,79 +184,27 @@ namespace Avalonia.Controls { base.OnAttachedToVisualTree(e); - var topLevel = (TopLevel)e.Root; - var window = e.Root as Window; - - if (window != null) - window.Deactivated += Deactivated; - - var pointerPress = topLevel.AddHandler( - PointerPressedEvent, - TopLevelPreviewPointerPress, - RoutingStrategies.Tunnel); - - _subscription = new CompositeDisposable( - pointerPress, - Disposable.Create(() => - { - if (window != null) - window.Deactivated -= Deactivated; - }), - InputManager.Instance.Process.Subscribe(ListenForNonClientClick)); - var inputRoot = e.Root as IInputRoot; if (inputRoot?.AccessKeyHandler != null) { inputRoot.AccessKeyHandler.MainMenu = this; } + + _interaction.Attach(this); } /// protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); - _subscription.Dispose(); + _interaction.Detach(this); } /// - protected override IItemContainerGenerator CreateItemContainerGenerator() - { - return new ItemContainerGenerator(this, MenuItem.HeaderProperty, null); - } - - /// - /// Called when a key is pressed within the menu. - /// - /// The event args. protected override void OnKeyDown(KeyEventArgs e) { - bool menuWasOpen = SelectedMenuItem?.IsSubMenuOpen ?? false; - - base.OnKeyDown(e); - - if (menuWasOpen) - { - // If a menu item was open and we navigate to a new one with the arrow keys, open - // that menu and select the first item. - var selection = SelectedMenuItem; - - if (selection != null && !selection.IsSubMenuOpen) - { - selection.IsSubMenuOpen = true; - selection.SelectedIndex = 0; - } - } - } - - /// - /// Called when the menu loses focus. - /// - /// The event args. - protected override void OnLostFocus(RoutedEventArgs e) - { - base.OnLostFocus(e); - SelectedItem = null; + // Don't handle here: let the interaction handler handle it. } /// @@ -184,9 +213,7 @@ namespace Avalonia.Controls /// The event args. protected virtual void OnSubmenuOpened(RoutedEventArgs e) { - var menuItem = e.Source as MenuItem; - - if (menuItem != null && menuItem.Parent == this) + if (e.Source is MenuItem menuItem && menuItem.Parent == this) { foreach (var child in this.GetLogicalChildren().OfType()) { @@ -199,58 +226,5 @@ namespace Avalonia.Controls IsOpen = true; } - - /// - /// Called when the top-level window is deactivated. - /// - /// The sender. - /// The event args. - private void Deactivated(object sender, EventArgs e) - { - Close(); - } - - /// - /// Listens for non-client clicks and closes the menu when one is detected. - /// - /// The raw event. - private void ListenForNonClientClick(RawInputEventArgs e) - { - var mouse = e as RawMouseEventArgs; - - if (mouse?.Type == RawMouseEventType.NonClientLeftButtonDown) - { - Close(); - } - } - - /// - /// Called when a submenu is clicked somewhere in the menu. - /// - /// The event args. - private void OnMenuClick(RoutedEventArgs e) - { - Close(); - FocusManager.Instance.Focus(null); - e.Handled = true; - } - - /// - /// Called when the pointer is pressed anywhere on the window. - /// - /// The sender. - /// The event args. - private void TopLevelPreviewPointerPress(object sender, PointerPressedEventArgs e) - { - if (IsOpen) - { - var control = e.Source as ILogical; - - if (!this.IsLogicalParentOf(control)) - { - Close(); - } - } - } } } diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index cec653e045..dde689e89c 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -2,6 +2,7 @@ // 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; using System.Windows.Input; using Avalonia.Controls.Generators; @@ -11,14 +12,13 @@ using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; -using Avalonia.Threading; namespace Avalonia.Controls { /// /// A menu item control. /// - public class MenuItem : HeaderedSelectingItemsControl, ISelectable + public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable { /// /// Defines the property. @@ -62,6 +62,18 @@ namespace Avalonia.Controls public static readonly RoutedEvent ClickEvent = RoutedEvent.Register(nameof(Click), RoutingStrategies.Bubble); + /// + /// Defines the event. + /// + public static readonly RoutedEvent PointerEnterItemEvent = + RoutedEvent.Register(nameof(PointerEnterItem), RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent PointerLeaveItemEvent = + RoutedEvent.Register(nameof(PointerLeaveItem), RoutingStrategies.Bubble); + /// /// Defines the event. /// @@ -74,11 +86,6 @@ namespace Avalonia.Controls private static readonly ITemplate DefaultPanel = new FuncTemplate(() => new StackPanel()); - /// - /// The timer used to display submenus. - /// - private IDisposable _submenuTimer; - /// /// The submenu popup. /// @@ -93,16 +100,15 @@ namespace Avalonia.Controls CommandProperty.Changed.Subscribe(CommandChanged); FocusableProperty.OverrideDefaultValue(true); IconProperty.Changed.AddClassHandler(x => x.IconChanged); + IsSelectedProperty.Changed.AddClassHandler(x => x.IsSelectedChanged); ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); ClickEvent.AddClassHandler(x => x.OnClick); SubmenuOpenedEvent.AddClassHandler(x => x.OnSubmenuOpened); IsSubMenuOpenProperty.Changed.AddClassHandler(x => x.SubMenuOpenChanged); - AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler(x => x.AccessKeyPressed); } public MenuItem() { - } /// @@ -114,6 +120,30 @@ namespace Avalonia.Controls remove { RemoveHandler(ClickEvent, value); } } + /// + /// Occurs when the pointer enters a menu item. + /// + /// + /// A bubbling version of the event for menu items. + /// + public event EventHandler PointerEnterItem + { + add { AddHandler(PointerEnterItemEvent, value); } + remove { RemoveHandler(PointerEnterItemEvent, value); } + } + + /// + /// Raised when the pointer leaves a menu item. + /// + /// + /// A bubbling version of the event for menu items. + /// + public event EventHandler PointerLeaveItem + { + add { AddHandler(PointerLeaveItemEvent, value); } + remove { RemoveHandler(PointerLeaveItemEvent, value); } + } + /// /// Occurs when a 's submenu is opened. /// @@ -185,10 +215,71 @@ namespace Avalonia.Controls public bool HasSubMenu => !Classes.Contains(":empty"); /// - /// Gets a value that indicates whether the is a top-level menu item. + /// Gets a value that indicates whether the is a top-level main menu item. /// public bool IsTopLevel => Parent is Menu; + /// + bool IMenuItem.IsPointerOverSubMenu => _popup.PopupRoot?.IsPointerOver ?? false; + + /// + IMenuElement IMenuItem.Parent => Parent as IMenuElement; + + /// + bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap); + + /// + IMenuItem IMenuElement.SelectedItem + { + get + { + var index = SelectedIndex; + return (index != -1) ? + (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) : + null; + } + set + { + SelectedIndex = ItemContainerGenerator.IndexFromContainer(value); + } + } + + /// + IEnumerable IMenuElement.SubItems + { + get + { + return ItemContainerGenerator.Containers + .Select(x => x.ContainerControl) + .OfType(); + } + } + + /// + /// Opens the submenu. + /// + /// + /// This has the same effect as setting to true. + /// + public void Open() => IsSubMenuOpen = true; + + /// + /// Closes the submenu. + /// + /// + /// This has the same effect as setting to false. + /// + public void Close() => IsSubMenuOpen = false; + + /// + void IMenuItem.RaiseClick() => RaiseEvent(new RoutedEventArgs(ClickEvent)); + + /// + protected override IItemContainerGenerator CreateItemContainerGenerator() + { + return new MenuItemContainerGenerator(this); + } + /// /// Called when the is clicked. /// @@ -202,163 +293,43 @@ namespace Avalonia.Controls } } - /// - /// Called when the recieves focus. - /// - /// The event args. + /// protected override void OnGotFocus(GotFocusEventArgs e) { base.OnGotFocus(e); - IsSelected = true; + e.Handled = UpdateSelectionFromEventSource(e.Source, true); } /// - protected override IItemContainerGenerator CreateItemContainerGenerator() - { - return new MenuItemContainerGenerator(this); - } - - /// - /// Called when a key is pressed in the . - /// - /// The event args. protected override void OnKeyDown(KeyEventArgs e) { - // Some keypresses we want to pass straight to the parent MenuItem/Menu without giving - // this MenuItem the chance to handle them. This is usually e.g. when the submenu is - // closed so passing them to the base would try to move the selection in a hidden - // submenu. - var passStraightToParent = true; - - switch (e.Key) - { - case Key.Left: - if (!IsTopLevel && IsSubMenuOpen) - { - IsSubMenuOpen = false; - e.Handled = true; - } - - passStraightToParent = IsTopLevel || !IsSubMenuOpen; - break; - - case Key.Right: - if (!IsTopLevel && HasSubMenu && !IsSubMenuOpen) - { - SelectedIndex = 0; - IsSubMenuOpen = true; - e.Handled = true; - } - - passStraightToParent = IsTopLevel || !IsSubMenuOpen; - break; - - case Key.Enter: - if (HasSubMenu) - { - goto case Key.Right; - } - else - { - RaiseEvent(new RoutedEventArgs(ClickEvent)); - e.Handled = true; - } - - break; - - case Key.Escape: - if (IsSubMenuOpen) - { - IsSubMenuOpen = false; - e.Handled = true; - } - - break; - } - - if (!passStraightToParent) - { - base.OnKeyDown(e); - } + // Don't handle here: let event bubble up to menu. } - /// - /// Called when the pointer enters the . - /// - /// The event args. + /// protected override void OnPointerEnter(PointerEventArgs e) { base.OnPointerEnter(e); - var menu = Parent as Menu; - - if (menu != null) - { - if (menu.IsOpen) - { - IsSubMenuOpen = true; - } - } - else if (HasSubMenu && !IsSubMenuOpen) - { - _submenuTimer = DispatcherTimer.Run( - () => IsSubMenuOpen = true, - TimeSpan.FromMilliseconds(400)); - } - else + RaiseEvent(new PointerEventArgs { - var parentItem = Parent as MenuItem; - if (parentItem != null) - { - foreach (var sibling in parentItem.Items - .OfType() - .Where(x => x != this && x.IsSubMenuOpen)) - { - sibling.CloseSubmenus(); - sibling.IsSubMenuOpen = false; - sibling.IsSelected = false; - } - } - } + Device = e.Device, + RoutedEvent = PointerEnterItemEvent, + Source = this, + }); } - /// - /// Called when the pointer leaves the . - /// - /// The event args. + /// protected override void OnPointerLeave(PointerEventArgs e) { base.OnPointerLeave(e); - if (_submenuTimer != null) + RaiseEvent(new PointerEventArgs { - _submenuTimer.Dispose(); - _submenuTimer = null; - } - } - - /// - /// Called when the pointer is pressed over the . - /// - /// The event args. - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - - if (!HasSubMenu) - { - RaiseEvent(new RoutedEventArgs(ClickEvent)); - } - else if (IsTopLevel) - { - IsSubMenuOpen = !IsSubMenuOpen; - } - else - { - IsSubMenuOpen = true; - } - - e.Handled = true; + Device = e.Device, + RoutedEvent = PointerLeaveItemEvent, + Source = this, + }); } /// @@ -392,25 +363,6 @@ namespace Avalonia.Controls _popup.Closed += PopupClosed; } - /// - /// Called when the menu item's access key is pressed. - /// - /// The event args. - private void AccessKeyPressed(RoutedEventArgs e) - { - if (HasSubMenu) - { - SelectedIndex = 0; - IsSubMenuOpen = true; - } - else - { - RaiseEvent(new RoutedEventArgs(ClickEvent)); - } - - e.Handled = true; - } - /// /// Closes all submenus of the menu item. /// @@ -476,6 +428,18 @@ namespace Avalonia.Controls } } + /// + /// Called when the property changes. + /// + /// The property change event. + private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e) + { + if ((bool)e.NewValue) + { + Focus(); + } + } + /// /// Called when the property changes. /// diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs new file mode 100644 index 0000000000..f65d3a4c72 --- /dev/null +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -0,0 +1,457 @@ +using System; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Rendering; +using Avalonia.Threading; + +namespace Avalonia.Controls.Platform +{ + /// + /// Provides the default keyboard and pointer interaction for menus. + /// + public class DefaultMenuInteractionHandler : IMenuInteractionHandler + { + private IDisposable _inputManagerSubscription; + private IRenderRoot _root; + + public DefaultMenuInteractionHandler() + : this(Input.InputManager.Instance, DefaultDelayRun) + { + } + + public DefaultMenuInteractionHandler( + IInputManager inputManager, + Action delayRun) + { + InputManager = inputManager; + DelayRun = delayRun; + } + + public virtual void Attach(IMenu menu) + { + if (Menu != null) + { + throw new NotSupportedException("DefaultMenuInteractionHandler is already attached."); + } + + Menu = menu; + Menu.GotFocus += GotFocus; + Menu.LostFocus += LostFocus; + Menu.KeyDown += KeyDown; + Menu.PointerPressed += PointerPressed; + Menu.PointerReleased += PointerReleased; + Menu.AddHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed); + Menu.AddHandler(Avalonia.Controls.Menu.MenuOpenedEvent, this.MenuOpened); + Menu.AddHandler(MenuItem.PointerEnterItemEvent, PointerEnter); + Menu.AddHandler(MenuItem.PointerLeaveItemEvent, PointerLeave); + + _root = Menu.VisualRoot; + + if (_root is InputElement inputRoot) + { + inputRoot.AddHandler(InputElement.PointerPressedEvent, RootPointerPressed, RoutingStrategies.Tunnel); + } + + if (_root is WindowBase window) + { + window.Deactivated += WindowDeactivated; + } + + _inputManagerSubscription = InputManager.Process.Subscribe(RawInput); + } + + public virtual void Detach(IMenu menu) + { + if (Menu != menu) + { + throw new NotSupportedException("DefaultMenuInteractionHandler is not attached to the menu."); + } + + Menu.GotFocus -= GotFocus; + Menu.LostFocus -= LostFocus; + Menu.KeyDown -= KeyDown; + Menu.PointerPressed -= PointerPressed; + Menu.PointerReleased -= PointerReleased; + Menu.RemoveHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed); + Menu.RemoveHandler(Avalonia.Controls.Menu.MenuOpenedEvent, this.MenuOpened); + Menu.RemoveHandler(MenuItem.PointerEnterItemEvent, PointerEnter); + Menu.RemoveHandler(MenuItem.PointerLeaveItemEvent, PointerLeave); + + if (_root is InputElement inputRoot) + { + inputRoot.RemoveHandler(InputElement.PointerPressedEvent, RootPointerPressed); + } + + if (_root is WindowBase root) + { + root.Deactivated -= WindowDeactivated; + } + + _inputManagerSubscription.Dispose(); + + Menu = null; + _root = null; + } + + protected Action DelayRun { get; } + + protected IInputManager InputManager { get; } + + protected IMenu Menu { get; private set; } + + protected static TimeSpan MenuShowDelay { get; } = TimeSpan.FromMilliseconds(400); + + protected internal virtual void GotFocus(object sender, GotFocusEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (item?.Parent != null) + { + item.SelectedItem = item; + } + } + + protected internal virtual void LostFocus(object sender, RoutedEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (item != null) + { + item.SelectedItem = null; + } + } + + protected internal virtual void KeyDown(object sender, KeyEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (item != null) + { + KeyDown(item, e); + } + } + + protected internal virtual void KeyDown(IMenuItem item, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Up: + case Key.Down: + if (item.IsTopLevel) + { + if (item.HasSubMenu && !item.IsSubMenuOpen) + { + Open(item, true); + e.Handled = true; + } + } + else + { + goto default; + } + break; + + case Key.Left: + if (item?.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen) + { + parent.Close(); + parent.Focus(); + e.Handled = true; + } + else + { + goto default; + } + break; + + case Key.Right: + if (!item.IsTopLevel && item.HasSubMenu) + { + Open(item, true); + e.Handled = true; + } + else + { + goto default; + } + break; + + case Key.Enter: + if (!item.HasSubMenu) + { + Click(item); + } + else + { + Open(item, true); + } + + e.Handled = true; + break; + + case Key.Escape: + if (item.Parent != null) + { + item.Parent.Close(); + item.Parent.Focus(); + e.Handled = true; + } + break; + + default: + var direction = e.Key.ToNavigationDirection(); + + if (direction.HasValue) + { + if (item.Parent?.MoveSelection(direction.Value, true) == true) + { + // If the the parent is an IMenu which successfully moved its selection, + // and the current menu is open then close the current menu and open the + // new menu. + if (item.IsSubMenuOpen && item.Parent is IMenu) + { + item.Close(); + Open(item.Parent.SelectedItem, true); + } + e.Handled = true; + } + } + + break; + } + + if (!e.Handled && item.Parent is IMenuItem parentItem) + { + KeyDown(parentItem, e); + } + } + + protected internal virtual void AccessKeyPressed(object sender, RoutedEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (item == null) + { + return; + } + + if (item.HasSubMenu) + { + Open(item, true); + } + else + { + Click(item); + } + + e.Handled = true; + } + + protected internal virtual void PointerEnter(object sender, PointerEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (item?.Parent == null) + { + return; + } + + if (item.IsTopLevel) + { + if (item.Parent.SelectedItem?.IsSubMenuOpen == true) + { + item.Parent.SelectedItem.Close(); + SelectItemAndAncestors(item); + Open(item, false); + } + else + { + SelectItemAndAncestors(item); + } + } + else + { + SelectItemAndAncestors(item); + + if (item.HasSubMenu) + { + OpenWithDelay(item); + } + else if (item.Parent != null) + { + foreach (var sibling in item.Parent.SubItems) + { + if (sibling.IsSubMenuOpen) + { + CloseWithDelay(sibling); + } + } + } + } + } + + protected internal virtual void PointerLeave(object sender, PointerEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (item?.Parent == null) + { + return; + } + + if (item.IsTopLevel) + { + if (!((IMenu)item.Parent).IsOpen && item.Parent.SelectedItem == item) + { + item.Parent.SelectedItem = null; + } + } + else if (!item.HasSubMenu) + { + item.Parent.SelectedItem = null; + } + } + + protected internal virtual void PointerPressed(object sender, PointerPressedEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (e.MouseButton == MouseButton.Left && item?.HasSubMenu == true) + { + Open(item, false); + e.Handled = true; + } + } + + protected internal virtual void PointerReleased(object sender, PointerReleasedEventArgs e) + { + var item = GetMenuItem(e.Source as IControl); + + if (e.MouseButton == MouseButton.Left && item.HasSubMenu == false) + { + Click(item); + e.Handled = true; + } + } + + protected internal virtual void MenuOpened(object sender, RoutedEventArgs e) + { + if (e.Source == Menu) + { + Menu.MoveSelection(NavigationDirection.First, true); + } + } + + protected internal virtual void RawInput(RawInputEventArgs e) + { + var mouse = e as RawMouseEventArgs; + + if (mouse?.Type == RawMouseEventType.NonClientLeftButtonDown) + { + Menu.Close(); + } + } + + protected internal virtual void RootPointerPressed(object sender, PointerPressedEventArgs e) + { + if (Menu?.IsOpen == true) + { + var control = e.Source as ILogical; + + if (!Menu.IsLogicalParentOf(control)) + { + Menu.Close(); + } + } + } + + protected internal virtual void WindowDeactivated(object sender, EventArgs e) + { + Menu.Close(); + } + + protected void Click(IMenuItem item) + { + item.RaiseClick(); + CloseMenu(item); + } + + protected void CloseMenu(IMenuItem item) + { + var current = (IMenuElement)item; + + while (current != null && !(current is IMenu)) + { + current = (current as IMenuItem).Parent; + } + + current?.Close(); + } + + protected void CloseWithDelay(IMenuItem item) + { + void Execute() + { + if (item.Parent?.SelectedItem != item) + { + item.Close(); + } + } + + DelayRun(Execute, MenuShowDelay); + } + + protected void Open(IMenuItem item, bool selectFirst) + { + item.Open(); + + if (selectFirst) + { + item.MoveSelection(NavigationDirection.First, true); + } + } + + protected void OpenWithDelay(IMenuItem item) + { + void Execute() + { + if (item.Parent?.SelectedItem == item) + { + Open(item, false); + } + } + + DelayRun(Execute, MenuShowDelay); + } + + protected void SelectItemAndAncestors(IMenuItem item) + { + var current = item; + + while (current?.Parent != null) + { + current.Parent.SelectedItem = current; + current = current.Parent as IMenuItem; + } + } + + protected static IMenuItem GetMenuItem(IControl item) + { + while (true) + { + if (item == null) + return null; + if (item is IMenuItem menuItem) + return menuItem; + item = item.Parent; + } + } + + private static void DefaultDelayRun(Action action, TimeSpan timeSpan) + { + DispatcherTimer.RunOnce(action, timeSpan); + } + } +} diff --git a/src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs new file mode 100644 index 0000000000..342d3dd1c9 --- /dev/null +++ b/src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs @@ -0,0 +1,22 @@ +using System; +using Avalonia.Input; + +namespace Avalonia.Controls.Platform +{ + /// + /// Handles user interaction for menus. + /// + public interface IMenuInteractionHandler + { + /// + /// Attaches the interaction handler to a menu. + /// + /// The menu. + void Attach(IMenu menu); + + /// + /// Detaches the interaction handler from the attached menu. + /// + void Detach(IMenu menu); + } +} diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index c8425a0f80..5451cf0701 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -457,6 +457,42 @@ namespace Avalonia.Controls.Primitives } } + /// + /// Moves the selection in the specified direction relative to the current selection. + /// + /// The direction to move. + /// Whether to wrap when the selection reaches the first or last item. + /// True if the selection was moved; otherwise false. + protected bool MoveSelection(NavigationDirection direction, bool wrap) + { + var from = SelectedIndex != -1 ? ItemContainerGenerator.ContainerFromIndex(SelectedIndex) : null; + return MoveSelection(from, direction, wrap); + } + + /// + /// Moves the selection in the specified direction relative to the specified container. + /// + /// The container which serves as a starting point for the movement. + /// The direction to move. + /// Whether to wrap when the selection reaches the first or last item. + /// True if the selection was moved; otherwise false. + protected bool MoveSelection(IControl from, NavigationDirection direction, bool wrap) + { + if (Presenter?.Panel is INavigableContainer container && + GetNextControl(container, direction, from, wrap) is IControl next) + { + var index = ItemContainerGenerator.IndexFromContainer(next); + + if (index != -1) + { + SelectedIndex = index; + return true; + } + } + + return false; + } + /// /// Updates the selection for an item based on user interaction. /// diff --git a/src/Avalonia.Controls/StackPanel.cs b/src/Avalonia.Controls/StackPanel.cs index b0ccd8a3d1..645cdbd926 100644 --- a/src/Avalonia.Controls/StackPanel.cs +++ b/src/Avalonia.Controls/StackPanel.cs @@ -56,11 +56,49 @@ namespace Avalonia.Controls /// /// The movement direction. /// The control from which movement begins. + /// Whether to wrap around when the first or last item is reached. /// The control. - IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) + IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap) { - var fromControl = from as IControl; - return (fromControl != null) ? GetControlInDirection(direction, fromControl) : null; + var result = GetControlInDirection(direction, from as IControl); + + if (result == null && wrap) + { + if (Orientation == Orientation.Vertical) + { + switch (direction) + { + case NavigationDirection.Up: + case NavigationDirection.Previous: + case NavigationDirection.PageUp: + result = GetControlInDirection(NavigationDirection.Last, null); + break; + case NavigationDirection.Down: + case NavigationDirection.Next: + case NavigationDirection.PageDown: + result = GetControlInDirection(NavigationDirection.First, null); + break; + } + } + else + { + switch (direction) + { + case NavigationDirection.Left: + case NavigationDirection.Previous: + case NavigationDirection.PageUp: + result = GetControlInDirection(NavigationDirection.Last, null); + break; + case NavigationDirection.Right: + case NavigationDirection.Next: + case NavigationDirection.PageDown: + result = GetControlInDirection(NavigationDirection.First, null); + break; + } + } + } + + return result; } /// @@ -72,7 +110,7 @@ namespace Avalonia.Controls protected virtual IInputElement GetControlInDirection(NavigationDirection direction, IControl from) { var horiz = Orientation == Orientation.Horizontal; - int index = Children.IndexOf((IControl)from); + int index = from != null ? Children.IndexOf(from) : -1; switch (direction) { @@ -83,22 +121,22 @@ namespace Avalonia.Controls index = Children.Count - 1; break; case NavigationDirection.Next: - ++index; + if (index != -1) ++index; break; case NavigationDirection.Previous: - --index; + if (index != -1) --index; break; case NavigationDirection.Left: - index = horiz ? index - 1 : -1; + if (index != -1) index = horiz ? index - 1 : -1; break; case NavigationDirection.Right: - index = horiz ? index + 1 : -1; + if (index != -1) index = horiz ? index + 1 : -1; break; case NavigationDirection.Up: - index = horiz ? -1 : index - 1; + if (index != -1) index = horiz ? -1 : index - 1; break; case NavigationDirection.Down: - index = horiz ? -1 : index + 1; + if (index != -1) index = horiz ? -1 : index + 1; break; default: index = -1; diff --git a/src/Avalonia.Controls/WrapPanel.cs b/src/Avalonia.Controls/WrapPanel.cs index 745de95bca..84d3cc791e 100644 --- a/src/Avalonia.Controls/WrapPanel.cs +++ b/src/Avalonia.Controls/WrapPanel.cs @@ -47,8 +47,9 @@ namespace Avalonia.Controls /// /// The movement direction. /// The control from which movement begins. + /// Whether to wrap around when the first or last item is reached. /// The control. - IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) + IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap) { var horiz = Orientation == Orientation.Horizontal; int index = Children.IndexOf((IControl)from); @@ -250,4 +251,4 @@ namespace Avalonia.Controls } } } -} \ No newline at end of file +} diff --git a/src/Avalonia.Input/AccessKeyHandler.cs b/src/Avalonia.Input/AccessKeyHandler.cs index 7baa4103d7..b78e5a9f98 100644 --- a/src/Avalonia.Input/AccessKeyHandler.cs +++ b/src/Avalonia.Input/AccessKeyHandler.cs @@ -53,10 +53,32 @@ namespace Avalonia.Input /// private IInputElement _restoreFocusElement; + /// + /// The window's main menu. + /// + private IMainMenu _mainMenu; + /// /// Gets or sets the window's main menu. /// - public IMainMenu MainMenu { get; set; } + public IMainMenu MainMenu + { + get => _mainMenu; + set + { + if (_mainMenu != null) + { + _mainMenu.MenuClosed -= MainMenuClosed; + } + + _mainMenu = value; + + if (_mainMenu != null) + { + _mainMenu.MenuClosed += MainMenuClosed; + } + } + } /// /// Sets the owner of the access key handler. @@ -160,13 +182,7 @@ namespace Avalonia.Input { bool menuIsOpen = MainMenu?.IsOpen == true; - if (e.Key == Key.Escape && menuIsOpen) - { - // When the Escape key is pressed with the main menu open, close it. - CloseMenu(); - e.Handled = true; - } - else if ((e.Modifiers & InputModifiers.Alt) != 0 || menuIsOpen) + if ((e.Modifiers & InputModifiers.Alt) != 0 || menuIsOpen) { // If any other key is pressed with the Alt key held down, or the main menu is open, // find all controls who have registered that access key. @@ -245,5 +261,10 @@ namespace Avalonia.Input MainMenu.Close(); _owner.ShowAccessKeys = _showingAccessKeys = false; } + + private void MainMenuClosed(object sender, EventArgs e) + { + _owner.ShowAccessKeys = false; + } } } diff --git a/src/Avalonia.Input/IInputElement.cs b/src/Avalonia.Input/IInputElement.cs index 5acb6aa777..c9924dbffb 100644 --- a/src/Avalonia.Input/IInputElement.cs +++ b/src/Avalonia.Input/IInputElement.cs @@ -15,7 +15,7 @@ namespace Avalonia.Input /// /// Occurs when the control receives focus. /// - event EventHandler GotFocus; + event EventHandler GotFocus; /// /// Occurs when the control loses focus. diff --git a/src/Avalonia.Input/IMainMenu.cs b/src/Avalonia.Input/IMainMenu.cs index 39d19a0a76..a3373191a8 100644 --- a/src/Avalonia.Input/IMainMenu.cs +++ b/src/Avalonia.Input/IMainMenu.cs @@ -1,6 +1,8 @@ // 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 Avalonia.Interactivity; using Avalonia.VisualTree; namespace Avalonia.Input @@ -24,5 +26,10 @@ namespace Avalonia.Input /// Opens the menu in response to the Alt/F10 key. /// void Open(); + + /// + /// Occurs when the main menu closes. + /// + event EventHandler MenuClosed; } } diff --git a/src/Avalonia.Input/INavigableContainer.cs b/src/Avalonia.Input/INavigableContainer.cs index 13d734bd0b..df434bca70 100644 --- a/src/Avalonia.Input/INavigableContainer.cs +++ b/src/Avalonia.Input/INavigableContainer.cs @@ -13,7 +13,8 @@ namespace Avalonia.Input /// /// The movement direction. /// The control from which movement begins. + /// Whether to wrap around when the first or last item is reached. /// The control. - IInputElement GetControl(NavigationDirection direction, IInputElement from); + IInputElement GetControl(NavigationDirection direction, IInputElement from, bool wrap); } } diff --git a/src/Avalonia.Input/InputElement.cs b/src/Avalonia.Input/InputElement.cs index 82e626f9c6..3aff5d0a8b 100644 --- a/src/Avalonia.Input/InputElement.cs +++ b/src/Avalonia.Input/InputElement.cs @@ -177,7 +177,7 @@ namespace Avalonia.Input /// /// Occurs when the control receives focus. /// - public event EventHandler GotFocus + public event EventHandler GotFocus { add { AddHandler(GotFocusEvent, value); } remove { RemoveHandler(GotFocusEvent, value); } diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs index a9d5b83073..18db2a9173 100644 --- a/src/Avalonia.Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Input/Navigation/TabNavigation.cs @@ -168,7 +168,7 @@ namespace Avalonia.Input.Navigation { while (element != null) { - element = navigable.GetControl(direction, element); + element = navigable.GetControl(direction, element, false); if (element != null && element.CanFocus()) { diff --git a/src/Avalonia.Themes.Default/MenuItem.xaml b/src/Avalonia.Themes.Default/MenuItem.xaml index 5404117363..8a2ed2a802 100644 --- a/src/Avalonia.Themes.Default/MenuItem.xaml +++ b/src/Avalonia.Themes.Default/MenuItem.xaml @@ -127,11 +127,6 @@ - - diff --git a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs new file mode 100644 index 0000000000..fd4aea47a3 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs @@ -0,0 +1,489 @@ +using System; +using Avalonia.Controls.Platform; +using Avalonia.Input; +using Moq; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Platform +{ + public class DefaultMenuInteractionHandlerTests + { + public class TopLevel + { + [Fact] + public void Up_Opens_MenuItem_With_SubMenu() + { + var target = new DefaultMenuInteractionHandler(); + var item = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); + var e = new KeyEventArgs { Key = Key.Up, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(item).Verify(x => x.Open()); + Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Down_Opens_MenuItem_With_SubMenu() + { + var target = new DefaultMenuInteractionHandler(); + var item = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); + var e = new KeyEventArgs { Key = Key.Down, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(item).Verify(x => x.Open()); + Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Right_Selects_Next_MenuItem() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(x => x.MoveSelection(NavigationDirection.Right, true) == true); + var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu); + var e = new KeyEventArgs { Key = Key.Right, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(menu).Verify(x => x.MoveSelection(NavigationDirection.Right, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Left_Selects_Previous_MenuItem() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(x => x.MoveSelection(NavigationDirection.Left, true) == true); + var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu); + var e = new KeyEventArgs { Key = Key.Left, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(menu).Verify(x => x.MoveSelection(NavigationDirection.Left, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Enter_On_Item_With_No_SubMenu_Causes_Click() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu); + var e = new KeyEventArgs { Key = Key.Enter, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(item).Verify(x => x.RaiseClick()); + Mock.Get(menu).Verify(x => x.Close()); + Assert.True(e.Handled); + } + + [Fact] + public void Enter_On_Item_With_SubMenu_Opens_SubMenu() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var item = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var e = new KeyEventArgs { Key = Key.Enter, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(item).Verify(x => x.Open()); + Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Escape_Closes_Parent_Menu() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu); + var e = new KeyEventArgs { Key = Key.Escape, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(menu).Verify(x => x.Close()); + Assert.True(e.Handled); + } + + [Fact] + public void PointerEnter_Opens_Item_When_Old_Item_Is_Open() + { + var target = new DefaultMenuInteractionHandler(); + var menu = new Mock(); + var item = Mock.Of(x => + x.IsSubMenuOpen == true && + x.IsTopLevel == true && + x.HasSubMenu == true && + x.Parent == menu.Object); + var nextItem = Mock.Of(x => + x.IsTopLevel == true && + x.HasSubMenu == true && + x.Parent == menu.Object); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = nextItem }; + + menu.SetupGet(x => x.SelectedItem).Returns(item); + + target.PointerEnter(nextItem, e); + + Mock.Get(item).Verify(x => x.Close()); + menu.VerifySet(x => x.SelectedItem = nextItem); + Mock.Get(nextItem).Verify(x => x.Open()); + Mock.Get(nextItem).Verify(x => x.MoveSelection(NavigationDirection.First, true), Times.Never); + Assert.False(e.Handled); + + } + + [Fact] + public void PointerLeave_Deselects_Item_When_Menu_Not_Open() + { + var target = new DefaultMenuInteractionHandler(); + var menu = new Mock(); + var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu.Object); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + + menu.SetupGet(x => x.SelectedItem).Returns(item); + target.PointerLeave(item, e); + + menu.VerifySet(x => x.SelectedItem = null); + Assert.False(e.Handled); + } + + [Fact] + public void PointerLeave_Doesnt_Deselect_Item_When_Menu_Open() + { + var target = new DefaultMenuInteractionHandler(); + var menu = new Mock(); + var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu.Object); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + + menu.SetupGet(x => x.IsOpen).Returns(true); + menu.SetupGet(x => x.SelectedItem).Returns(item); + target.PointerLeave(item, e); + + menu.VerifySet(x => x.SelectedItem = null, Times.Never); + Assert.False(e.Handled); + } + } + + public class NonTopLevel + { + [Fact] + public void Up_Selects_Previous_MenuItem() + { + var target = new DefaultMenuInteractionHandler(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new KeyEventArgs { Key = Key.Up, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(parentItem).Verify(x => x.MoveSelection(NavigationDirection.Up, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Down_Selects_Next_MenuItem() + { + var target = new DefaultMenuInteractionHandler(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new KeyEventArgs { Key = Key.Down, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(parentItem).Verify(x => x.MoveSelection(NavigationDirection.Down, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Left_Closes_Parent_SubMenu() + { + var target = new DefaultMenuInteractionHandler(); + var parentItem = Mock.Of(x => x.HasSubMenu == true && x.IsSubMenuOpen == true); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new KeyEventArgs { Key = Key.Left, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(parentItem).Verify(x => x.Close()); + Mock.Get(parentItem).Verify(x => x.Focus()); + Assert.True(e.Handled); + } + + [Fact] + public void Right_With_SubMenu_Items_Opens_SubMenu() + { + var target = new DefaultMenuInteractionHandler(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); + var item = Mock.Of(x => x.Parent == parentItem && x.HasSubMenu == true); + var e = new KeyEventArgs { Key = Key.Right, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(item).Verify(x => x.Open()); + Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Right_On_TopLevel_Child_Navigates_TopLevel_Selection() + { + var target = new DefaultMenuInteractionHandler(); + var menu = new Mock(); + var parentItem = Mock.Of(x => + x.IsSubMenuOpen == true && + x.IsTopLevel == true && + x.HasSubMenu == true && + x.Parent == menu.Object); + var nextItem = Mock.Of(x => + x.IsTopLevel == true && + x.HasSubMenu == true && + x.Parent == menu.Object); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new KeyEventArgs { Key = Key.Right, Source = item }; + + menu.Setup(x => x.MoveSelection(NavigationDirection.Right, true)) + .Callback(() => menu.SetupGet(x => x.SelectedItem).Returns(nextItem)) + .Returns(true); + + target.KeyDown(item, e); + + menu.Verify(x => x.MoveSelection(NavigationDirection.Right, true)); + Mock.Get(parentItem).Verify(x => x.Close()); + Mock.Get(nextItem).Verify(x => x.Open()); + Mock.Get(nextItem).Verify(x => x.MoveSelection(NavigationDirection.First, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Enter_On_Item_With_No_SubMenu_Causes_Click() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new KeyEventArgs { Key = Key.Enter, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(item).Verify(x => x.RaiseClick()); + Mock.Get(menu).Verify(x => x.Close()); + Assert.True(e.Handled); + } + + [Fact] + public void Enter_On_Item_With_SubMenu_Opens_SubMenu() + { + var target = new DefaultMenuInteractionHandler(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); + var item = Mock.Of(x => x.Parent == parentItem && x.HasSubMenu == true); + var e = new KeyEventArgs { Key = Key.Enter, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(item).Verify(x => x.Open()); + Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true)); + Assert.True(e.Handled); + } + + [Fact] + public void Escape_Closes_Parent_MenuItem() + { + var target = new DefaultMenuInteractionHandler(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new KeyEventArgs { Key = Key.Escape, Source = item }; + + target.KeyDown(item, e); + + Mock.Get(parentItem).Verify(x => x.Close()); + Mock.Get(parentItem).Verify(x => x.Focus()); + Assert.True(e.Handled); + } + + [Fact] + public void PointerEnter_Selects_Item() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item }; + + target.PointerEnter(item, e); + + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item); + Assert.False(e.Handled); + } + + [Fact] + public void PointerEnter_Opens_Submenu_After_Delay() + { + var timer = new TestTimer(); + var target = new DefaultMenuInteractionHandler(null, timer.RunOnce); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem && x.HasSubMenu == true); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item }; + + target.PointerEnter(item, e); + Mock.Get(item).Verify(x => x.Open(), Times.Never); + + timer.Pulse(); + Mock.Get(item).Verify(x => x.Open()); + + Assert.False(e.Handled); + } + + [Fact] + public void PointerEnter_Closes_Sibling_Submenu_After_Delay() + { + var timer = new TestTimer(); + var target = new DefaultMenuInteractionHandler(null, timer.RunOnce); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem); + var sibling = Mock.Of(x => x.Parent == parentItem && x.HasSubMenu == true && x.IsSubMenuOpen == true); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item }; + + Mock.Get(parentItem).SetupGet(x => x.SubItems).Returns(new[] { item, sibling }); + + target.PointerEnter(item, e); + Mock.Get(sibling).Verify(x => x.Close(), Times.Never); + + timer.Pulse(); + Mock.Get(sibling).Verify(x => x.Close()); + + Assert.False(e.Handled); + } + + [Fact] + public void PointerLeave_Deselects_Item() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + + target.PointerLeave(item, e); + + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null); + Assert.False(e.Handled); + } + + [Fact] + public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem && x.HasSubMenu == true && x.IsPointerOverSubMenu == true); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + + target.PointerLeave(item, e); + + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null, Times.Never); + Assert.False(e.Handled); + } + + [Fact] + public void PointerReleased_On_Item_With_No_SubMenu_Causes_Click() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem); + var e = new PointerReleasedEventArgs { MouseButton = MouseButton.Left, Source = item }; + + target.PointerReleased(item, e); + + Mock.Get(item).Verify(x => x.RaiseClick()); + Mock.Get(menu).Verify(x => x.Close()); + Assert.True(e.Handled); + } + + [Fact] + public void Selection_Is_Correct_When_Pointer_Temporarily_Exits_Item_To_Select_SubItem() + { + var timer = new TestTimer(); + var target = new DefaultMenuInteractionHandler(null, timer.RunOnce); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem && x.HasSubMenu == true); + var childItem = Mock.Of(x => x.Parent == item); + var enter = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item }; + var leave = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + + // Pointer enters item; item is selected. + target.PointerEnter(item, enter); + Assert.True(timer.ActionIsQueued); + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item); + Mock.Get(parentItem).ResetCalls(); + + // SubMenu shown after a delay. + timer.Pulse(); + Mock.Get(item).Verify(x => x.Open()); + Mock.Get(item).SetupGet(x => x.IsSubMenuOpen).Returns(true); + Mock.Get(item).ResetCalls(); + + // Pointer briefly exits item, but submenu remains open. + target.PointerLeave(item, leave); + Mock.Get(item).Verify(x => x.Close(), Times.Never); + Mock.Get(item).ResetCalls(); + + // Pointer enters child item; is selected. + enter.Source = childItem; + target.PointerEnter(childItem, enter); + Mock.Get(item).VerifySet(x => x.SelectedItem = childItem); + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item); + Mock.Get(item).ResetCalls(); + Mock.Get(parentItem).ResetCalls(); + } + + [Fact] + public void PointerPressed_On_Item_With_SubMenu_Causes_Opens_Submenu() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem && x.HasSubMenu == true); + var e = new PointerPressedEventArgs { MouseButton = MouseButton.Left, Source = item }; + + target.PointerPressed(item, e); + + Mock.Get(item).Verify(x => x.Open()); + Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true), Times.Never); + Assert.True(e.Handled); + } + } + + private class TestTimer + { + private Action _action; + + public bool ActionIsQueued => _action != null; + + public void Pulse() + { + _action(); + _action = null; + } + + public void RunOnce(Action action, TimeSpan timeSpan) + { + if (_action != null) + { + throw new NotSupportedException("Action already set."); + } + + _action = action; + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index b0ae3df8a2..70a40faed3 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -219,7 +219,7 @@ namespace Avalonia.Controls.UnitTests scrollable.Setup(x => x.IsLogicalScrollEnabled).Returns(true); ((ISetLogicalParent)target).SetParent(presenter.Object); - ((INavigableContainer)target).GetControl(NavigationDirection.Next, from); + ((INavigableContainer)target).GetControl(NavigationDirection.Next, from, false); scrollable.Verify(x => x.GetControlInDirection(NavigationDirection.Next, from)); } From e5662a8a18cbfeb795c6dc9038e677b89cb9702e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 14 Aug 2018 13:16:28 +0200 Subject: [PATCH 07/22] Implement ContextMenu interactions. --- src/Avalonia.Controls/ContextMenu.cs | 148 +++++++++++++++++---------- 1 file changed, 96 insertions(+), 52 deletions(-) diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 13f00bdc87..0accb284b6 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -1,16 +1,18 @@ +using System; +using System.Reactive.Linq; +using System.Linq; +using System.ComponentModel; +using Avalonia.Controls.Platform; +using System.Collections.Generic; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Controls.Primitives; + namespace Avalonia.Controls { - using Input; - using Interactivity; - using LogicalTree; - using Primitives; - using System; - using System.Reactive.Linq; - using System.Linq; - using System.ComponentModel; - - public class ContextMenu : SelectingItemsControl + public class ContextMenu : SelectingItemsControl, IMenu { + private readonly IMenuInteractionHandler _interaction; private bool _isOpen; private Popup _popup; @@ -20,6 +22,25 @@ namespace Avalonia.Controls public static readonly DirectProperty IsOpenProperty = AvaloniaProperty.RegisterDirect(nameof(IsOpen), o => o.IsOpen); + /// + /// Initializes a new instance of the class. + /// + public ContextMenu() + { + _interaction = AvaloniaLocator.Current.GetService() ?? + new DefaultMenuInteractionHandler(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The menu iteraction handler. + public ContextMenu(IMenuInteractionHandler interactionHandler) + { + Contract.Requires(interactionHandler != null); + + _interaction = interactionHandler; + } /// /// Initializes static members of the class. @@ -27,8 +48,6 @@ namespace Avalonia.Controls static ContextMenu() { ContextMenuProperty.Changed.Subscribe(ContextMenuChanged); - - MenuItem.ClickEvent.AddClassHandler(x => x.OnContextMenuClick, handledEventsToo: true); } /// @@ -36,6 +55,36 @@ namespace Avalonia.Controls /// public bool IsOpen => _isOpen; + /// + IMenuInteractionHandler IMenu.InteractionHandler => _interaction; + + /// + IMenuItem IMenuElement.SelectedItem + { + get + { + var index = SelectedIndex; + return (index != -1) ? + (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) : + null; + } + set + { + SelectedIndex = ItemContainerGenerator.IndexFromContainer(value); + } + } + + /// + IEnumerable IMenuElement.SubItems + { + get + { + return ItemContainerGenerator.Containers + .Select(x => x.ContainerControl) + .OfType(); + } + } + /// /// Occurs when the value of the /// @@ -50,7 +99,6 @@ namespace Avalonia.Controls /// public event CancelEventHandler ContextMenuClosing; - /// /// Called when the property changes on a control. /// @@ -71,62 +119,53 @@ namespace Avalonia.Controls } /// - /// Called when a submenu is clicked somewhere in the menu. + /// Opens the menu. /// - /// The event args. - private void OnContextMenuClick(RoutedEventArgs e) - { - Hide(); - FocusManager.Instance.Focus(null); - e.Handled = true; - } + public void Open() => Open(null); /// - /// Closes the menu. + /// Opens a context menu on the specified control. /// - public void Hide() + /// The control. + public void Open(Control control) { - if (_popup != null && _popup.IsVisible) + if (_popup == null) { - _popup.IsOpen = false; + _popup = new Popup() + { + PlacementMode = PlacementMode.Pointer, + PlacementTarget = control, + StaysOpen = false, + ObeyScreenEdges = true + }; + + _popup.Closed += PopupClosed; + _interaction.Attach(this); } - SelectedIndex = -1; + ((ISetLogicalParent)_popup).SetParent(control); + _popup.Child = this; + _popup.IsOpen = true; - SetAndRaise(IsOpenProperty, ref _isOpen, false); + SetAndRaise(IsOpenProperty, ref _isOpen, true); } /// - /// Shows a context menu for the specified control. + /// Closes the menu. /// - /// The control. - private void Show(Control control) + public void Close() { - if (control != null) + if (_popup != null && _popup.IsVisible) { - if (_popup == null) - { - _popup = new Popup() - { - PlacementMode = PlacementMode.Pointer, - PlacementTarget = control, - StaysOpen = false, - ObeyScreenEdges = true - }; - - _popup.Closed += PopupClosed; - } - - ((ISetLogicalParent)_popup).SetParent(control); - _popup.Child = this; + _popup.IsOpen = false; + } - _popup.IsOpen = true; + SelectedIndex = -1; - SetAndRaise(IsOpenProperty, ref _isOpen, true); - } + SetAndRaise(IsOpenProperty, ref _isOpen, false); } - private static void PopupClosed(object sender, EventArgs e) + private void PopupClosed(object sender, EventArgs e) { var contextMenu = (sender as Popup)?.Child as ContextMenu; @@ -152,7 +191,7 @@ namespace Avalonia.Controls if (contextMenu.CancelClosing()) return; - control.ContextMenu.Hide(); + control.ContextMenu.Close(); e.Handled = true; } @@ -161,7 +200,7 @@ namespace Avalonia.Controls if (contextMenu.CancelOpening()) return; - contextMenu.Show(control); + contextMenu.Open(control); e.Handled = true; } } @@ -179,5 +218,10 @@ namespace Avalonia.Controls ContextMenuOpening?.Invoke(this, eventArgs); return eventArgs.Cancel; } + + bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) + { + throw new NotImplementedException(); + } } } From 0fb1780f75ff4fc9ec1540578206383e717855b9 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 14 Aug 2018 13:40:03 +0200 Subject: [PATCH 08/22] Don't deselect sibling item on PointerLeave. --- .../Platform/DefaultMenuInteractionHandler.cs | 15 +++++++++------ .../DefaultMenuInteractionHandlerTests.cs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index f65d3a4c72..3cd3094483 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -301,17 +301,20 @@ namespace Avalonia.Controls.Platform return; } - if (item.IsTopLevel) + if (item.Parent.SelectedItem == item) { - if (!((IMenu)item.Parent).IsOpen && item.Parent.SelectedItem == item) + if (item.IsTopLevel) + { + if (!((IMenu)item.Parent).IsOpen) + { + item.Parent.SelectedItem = null; + } + } + else if (!item.HasSubMenu) { item.Parent.SelectedItem = null; } } - else if (!item.HasSubMenu) - { - item.Parent.SelectedItem = null; - } } protected internal virtual void PointerPressed(object sender, PointerPressedEventArgs e) diff --git a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs index fd4aea47a3..e17279013d 100644 --- a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs @@ -371,12 +371,30 @@ namespace Avalonia.Controls.UnitTests.Platform var item = Mock.Of(x => x.Parent == parentItem); var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(item); target.PointerLeave(item, e); Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null); Assert.False(e.Handled); } + [Fact] + public void PointerLeave_Doesnt_Deselect_Sibling() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem); + var sibling = Mock.Of(x => x.Parent == parentItem); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + + Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(sibling); + target.PointerLeave(item, e); + + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null, Times.Never); + Assert.False(e.Handled); + } + [Fact] public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu() { From c7bf1ecb4bbfa8f8ffc92d23958049fe4e23dc03 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 14 Aug 2018 21:14:54 +0200 Subject: [PATCH 09/22] Fix null checking. --- .../Platform/DefaultMenuInteractionHandler.cs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 3cd3094483..a44495b90c 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -135,6 +135,8 @@ namespace Avalonia.Controls.Platform protected internal virtual void KeyDown(IMenuItem item, KeyEventArgs e) { + Contract.Requires(item != null); + switch (e.Key) { case Key.Up: @@ -154,7 +156,7 @@ namespace Avalonia.Controls.Platform break; case Key.Left: - if (item?.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen) + if (item.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen) { parent.Close(); parent.Focus(); @@ -203,20 +205,17 @@ namespace Avalonia.Controls.Platform default: var direction = e.Key.ToNavigationDirection(); - if (direction.HasValue) + if (direction.HasValue && item.Parent?.MoveSelection(direction.Value, true) == true) { - if (item.Parent?.MoveSelection(direction.Value, true) == true) + // If the the parent is an IMenu which successfully moved its selection, + // and the current menu is open then close the current menu and open the + // new menu. + if (item.IsSubMenuOpen && item.Parent is IMenu) { - // If the the parent is an IMenu which successfully moved its selection, - // and the current menu is open then close the current menu and open the - // new menu. - if (item.IsSubMenuOpen && item.Parent is IMenu) - { - item.Close(); - Open(item.Parent.SelectedItem, true); - } - e.Handled = true; + item.Close(); + Open(item.Parent.SelectedItem, true); } + e.Handled = true; } break; @@ -387,7 +386,7 @@ namespace Avalonia.Controls.Platform while (current != null && !(current is IMenu)) { - current = (current as IMenuItem).Parent; + current = (current as IMenuItem)?.Parent; } current?.Close(); From 434bf0d3db186c94da88e0779890577c761d9b7e Mon Sep 17 00:00:00 2001 From: Nicolas Musset Date: Tue, 14 Aug 2018 20:28:55 +0900 Subject: [PATCH 10/22] Support invoking a function in the dispatcher. --- src/Avalonia.Base/Threading/Dispatcher.cs | 13 +- src/Avalonia.Base/Threading/IDispatcher.cs | 11 +- src/Avalonia.Base/Threading/JobRunner.cs | 168 +++++++++++++----- .../Avalonia.UnitTests/ImmediateDispatcher.cs | 6 + 4 files changed, 144 insertions(+), 54 deletions(-) diff --git a/src/Avalonia.Base/Threading/Dispatcher.cs b/src/Avalonia.Base/Threading/Dispatcher.cs index cf7acb3e8a..aa2a7a7a8e 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.cs @@ -69,7 +69,7 @@ namespace Avalonia.Threading /// public void RunJobs() { - _jobRunner?.RunJobs(null); + _jobRunner.RunJobs(null); } /// @@ -82,14 +82,21 @@ namespace Avalonia.Threading public Task InvokeAsync(Action action, DispatcherPriority priority = DispatcherPriority.Normal) { Contract.Requires(action != null); - return _jobRunner?.InvokeAsync(action, priority); + return _jobRunner.InvokeAsync(action, priority); + } + + /// + public Task InvokeAsync(Func function, DispatcherPriority priority = DispatcherPriority.Normal) + { + Contract.Requires(function != null); + return _jobRunner.InvokeAsync(function, priority); } /// public void Post(Action action, DispatcherPriority priority = DispatcherPriority.Normal) { Contract.Requires(action != null); - _jobRunner?.Post(action, priority); + _jobRunner.Post(action, priority); } /// diff --git a/src/Avalonia.Base/Threading/IDispatcher.cs b/src/Avalonia.Base/Threading/IDispatcher.cs index 4009dcdeab..1fdc9da5fe 100644 --- a/src/Avalonia.Base/Threading/IDispatcher.cs +++ b/src/Avalonia.Base/Threading/IDispatcher.cs @@ -28,12 +28,17 @@ namespace Avalonia.Threading void Post(Action action, DispatcherPriority priority = DispatcherPriority.Normal); /// - /// Post action that will be invoked on main thread + /// Posts an action that will be invoked on the dispatcher thread. /// /// The method. /// The priority with which to invoke the method. - // TODO: The naming of this method is confusing: the Async suffix usually means return a task. - // Remove this and rename InvokeTaskAsync as InvokeAsync. See #816. Task InvokeAsync(Action action, DispatcherPriority priority = DispatcherPriority.Normal); + + /// + /// Posts a function that will be invoked on the dispatcher thread. + /// + /// The method. + /// The priority with which to invoke the method. + Task InvokeAsync(Func function, DispatcherPriority priority = DispatcherPriority.Normal); } } \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/JobRunner.cs b/src/Avalonia.Base/Threading/JobRunner.cs index c2040a0982..b922370e84 100644 --- a/src/Avalonia.Base/Threading/JobRunner.cs +++ b/src/Avalonia.Base/Threading/JobRunner.cs @@ -14,32 +14,16 @@ namespace Avalonia.Threading /// internal class JobRunner { - - private IPlatformThreadingInterface _platform; - private Queue[] _queues = Enumerable.Range(0, (int) DispatcherPriority.MaxValue + 1) - .Select(_ => new Queue()).ToArray(); + private readonly Queue[] _queues = Enumerable.Range(0, (int) DispatcherPriority.MaxValue + 1) + .Select(_ => new Queue()).ToArray(); public JobRunner(IPlatformThreadingInterface platform) { _platform = platform; } - Job GetNextJob(DispatcherPriority minimumPriority) - { - for (int c = (int) DispatcherPriority.MaxValue; c >= (int) minimumPriority; c--) - { - var q = _queues[c]; - lock (q) - { - if (q.Count > 0) - return q.Dequeue(); - } - } - return null; - } - /// /// Runs continuations pushed on the loop. /// @@ -52,24 +36,8 @@ namespace Avalonia.Threading var job = GetNextJob(minimumPriority); if (job == null) return; - - if (job.TaskCompletionSource == null) - { - job.Action(); - } - else - { - try - { - job.Action(); - job.TaskCompletionSource.SetResult(null); - } - catch (Exception e) - { - job.TaskCompletionSource.SetException(e); - } - } + job.Run(); } } @@ -83,7 +51,20 @@ namespace Avalonia.Threading { var job = new Job(action, priority, false); AddJob(job); - return job.TaskCompletionSource.Task; + return job.Task; + } + + /// + /// Invokes a method on the main loop. + /// + /// The method. + /// The priority with which to invoke the method. + /// A task that can be used to track the method's execution. + public Task InvokeAsync(Func function, DispatcherPriority priority) + { + var job = new Job(function, priority); + AddJob(job); + return job.Task; } /// @@ -105,9 +86,9 @@ namespace Avalonia.Threading _platform = AvaloniaLocator.Current.GetService(); } - private void AddJob(Job job) + private void AddJob(IJob job) { - var needWake = false; + bool needWake; var queue = _queues[(int) job.Priority]; lock (queue) { @@ -118,38 +99,129 @@ namespace Avalonia.Threading _platform?.Signal(job.Priority); } + private IJob GetNextJob(DispatcherPriority minimumPriority) + { + for (int c = (int) DispatcherPriority.MaxValue; c >= (int) minimumPriority; c--) + { + var q = _queues[c]; + lock (q) + { + if (q.Count > 0) + return q.Dequeue(); + } + } + return null; + } + + private interface IJob + { + /// + /// Gets the job priority. + /// + DispatcherPriority Priority { get; } + + /// + /// Runs the job. + /// + void Run(); + } + /// /// A job to run. /// - private class Job + private sealed class Job : IJob { + /// + /// The method to call. + /// + private readonly Action _action; + /// + /// The task completion source. + /// + private readonly TaskCompletionSource _taskCompletionSource; + /// /// Initializes a new instance of the class. /// /// The method to call. /// The job priority. - /// Do not wrap excepption in TaskCompletionSource + /// Do not wrap exception in TaskCompletionSource public Job(Action action, DispatcherPriority priority, bool throwOnUiThread) { - Action = action; + _action = action; Priority = priority; - TaskCompletionSource = throwOnUiThread ? null : new TaskCompletionSource(); + _taskCompletionSource = throwOnUiThread ? null : new TaskCompletionSource(); } + /// + public DispatcherPriority Priority { get; } + /// - /// Gets the method to call. + /// The task. /// - public Action Action { get; } + public Task Task => _taskCompletionSource?.Task; + + /// + void IJob.Run() + { + if (_taskCompletionSource == null) + { + _action(); + return; + } + try + { + _action(); + _taskCompletionSource.SetResult(null); + } + catch (Exception e) + { + _taskCompletionSource.SetException(e); + } + } + } + + /// + /// A job to run. + /// + private sealed class Job : IJob + { + private readonly Func _function; + private readonly TaskCompletionSource _taskCompletionSource; /// - /// Gets the job priority. + /// Initializes a new instance of the class. /// - public DispatcherPriority Priority { get; } + /// The method to call. + /// The job priority. + public Job(Func function, DispatcherPriority priority) + { + _function = function; + Priority = priority; + _taskCompletionSource = new TaskCompletionSource(); + } + /// + public DispatcherPriority Priority { get; } + /// - /// Gets the task completion source. + /// The task. /// - public TaskCompletionSource TaskCompletionSource { get; } + public Task Task => _taskCompletionSource.Task; + + /// + void IJob.Run() + { + try + { + var result = _function(); + _taskCompletionSource.SetResult(result); + } + catch (Exception e) + { + _taskCompletionSource.SetException(e); + } + } } } } diff --git a/tests/Avalonia.UnitTests/ImmediateDispatcher.cs b/tests/Avalonia.UnitTests/ImmediateDispatcher.cs index 92f64bde6f..44d8c78054 100644 --- a/tests/Avalonia.UnitTests/ImmediateDispatcher.cs +++ b/tests/Avalonia.UnitTests/ImmediateDispatcher.cs @@ -25,6 +25,12 @@ namespace Avalonia.UnitTests return Task.FromResult(null); } + public Task InvokeAsync(Func function, DispatcherPriority priority = DispatcherPriority.Normal) + { + var result = function(); + return Task.FromResult(result); + } + public void VerifyAccess() { } From 47676e6be24ee654307ac216c5cf83feb5fda682 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Sat, 18 Aug 2018 14:46:26 +0100 Subject: [PATCH 11/22] fix null reference exception. --- src/Avalonia.Controls/MenuItem.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index dde689e89c..91f427936c 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -368,9 +368,12 @@ namespace Avalonia.Controls /// private void CloseSubmenus() { - foreach (var child in Items.OfType()) + if (Items != null) { - child.IsSubMenuOpen = false; + foreach (var child in Items.OfType()) + { + child.IsSubMenuOpen = false; + } } } From f1e422866a3c4e04defcdbcd1abca98306c4cc02 Mon Sep 17 00:00:00 2001 From: jp2masa Date: Tue, 21 Aug 2018 00:51:20 +0100 Subject: [PATCH 12/22] Implemented UniformGrid. (#1819) --- .../Primitives/UniformGrid.cs | 161 ++++++++++++++++++ .../Primitives/UniformGridTests.cs | 144 ++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/Avalonia.Controls/Primitives/UniformGrid.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Primitives/UniformGridTests.cs diff --git a/src/Avalonia.Controls/Primitives/UniformGrid.cs b/src/Avalonia.Controls/Primitives/UniformGrid.cs new file mode 100644 index 0000000000..f3580eee10 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/UniformGrid.cs @@ -0,0 +1,161 @@ +using System; + +namespace Avalonia.Controls.Primitives +{ + /// + /// A with uniform column and row sizes. + /// + public class UniformGrid : Panel + { + /// + /// Defines the property. + /// + public static readonly StyledProperty RowsProperty = + AvaloniaProperty.Register(nameof(Rows)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ColumnsProperty = + AvaloniaProperty.Register(nameof(Columns)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FirstColumnProperty = + AvaloniaProperty.Register(nameof(FirstColumn)); + + private int _rows; + private int _columns; + + /// + /// Specifies the row count. If set to 0, row count will be calculated automatically. + /// + public int Rows + { + get => GetValue(RowsProperty); + set => SetValue(RowsProperty, value); + } + + /// + /// Specifies the column count. If set to 0, column count will be calculated automatically. + /// + public int Columns + { + get => GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + + /// + /// Specifies, for the first row, the column where the items should start. + /// + public int FirstColumn + { + get => GetValue(FirstColumnProperty); + set => SetValue(FirstColumnProperty, value); + } + + protected override Size MeasureOverride(Size availableSize) + { + UpdateRowsAndColumns(); + + var maxWidth = 0d; + var maxHeight = 0d; + + var childAvailableSize = new Size(availableSize.Width / _columns, availableSize.Height / _rows); + + foreach (var child in Children) + { + child.Measure(childAvailableSize); + + if (child.DesiredSize.Width > maxWidth) + { + maxWidth = child.DesiredSize.Width; + } + + if (child.DesiredSize.Height > maxHeight) + { + maxHeight = child.DesiredSize.Height; + } + } + + return new Size(maxWidth * _columns, maxHeight * _rows); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var x = FirstColumn; + var y = 0; + + var width = finalSize.Width / _columns; + var height = finalSize.Height / _rows; + + foreach (var child in Children) + { + if (!child.IsVisible) + { + continue; + } + + child.Arrange(new Rect(x * width, y * height, width, height)); + + x++; + + if (x >= _columns) + { + x = 0; + y++; + } + } + + return finalSize; + } + + private void UpdateRowsAndColumns() + { + _rows = Rows; + _columns = Columns; + + if (FirstColumn >= Columns) + { + FirstColumn = 0; + } + + var itemCount = FirstColumn; + + foreach (var child in Children) + { + if (child.IsVisible) + { + itemCount++; + } + } + + if (_rows == 0) + { + if (_columns == 0) + { + _rows = _columns = (int)Math.Ceiling(Math.Sqrt(itemCount)); + } + else + { + _rows = Math.DivRem(itemCount, _columns, out int rem); + + if (rem != 0) + { + _rows++; + } + } + } + else if (_columns == 0) + { + _columns = Math.DivRem(itemCount, _rows, out int rem); + + if (rem != 0) + { + _columns++; + } + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/UniformGridTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/UniformGridTests.cs new file mode 100644 index 0000000000..340bd09611 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Primitives/UniformGridTests.cs @@ -0,0 +1,144 @@ +using Avalonia.Controls.Primitives; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Primitives +{ + public class UniformGridTests + { + [Fact] + public void Grid_Columns_Equals_Rows_For_Auto_Columns_And_Rows() + { + var target = new UniformGrid() + { + Children = + { + new Border { Width = 50, Height = 70 }, + new Border { Width = 30, Height = 50 }, + new Border { Width = 80, Height = 90 } + } + }; + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + // 2 * 2 grid + Assert.Equal(new Size(2 * 80, 2 * 90), target.Bounds.Size); + } + + [Fact] + public void Grid_Expands_Vertically_For_Columns_With_Auto_Rows() + { + var target = new UniformGrid() + { + Columns = 2, + Children = + { + new Border { Width = 50, Height = 70 }, + new Border { Width = 30, Height = 50 }, + new Border { Width = 80, Height = 90 }, + new Border { Width = 20, Height = 30 }, + new Border { Width = 40, Height = 60 } + } + }; + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + // 2 * 3 grid + Assert.Equal(new Size(2 * 80, 3 * 90), target.Bounds.Size); + } + + [Fact] + public void Grid_Extends_For_Columns_And_First_Column_With_Auto_Rows() + { + var target = new UniformGrid() + { + Columns = 3, + FirstColumn = 2, + Children = + { + new Border { Width = 50, Height = 70 }, + new Border { Width = 30, Height = 50 }, + new Border { Width = 80, Height = 90 }, + new Border { Width = 20, Height = 30 }, + new Border { Width = 40, Height = 60 } + } + }; + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + // 3 * 3 grid + Assert.Equal(new Size(3 * 80, 3 * 90), target.Bounds.Size); + } + + [Fact] + public void Grid_Expands_Horizontally_For_Rows_With_Auto_Columns() + { + var target = new UniformGrid() + { + Rows = 2, + Children = + { + new Border { Width = 50, Height = 70 }, + new Border { Width = 30, Height = 50 }, + new Border { Width = 80, Height = 90 }, + new Border { Width = 20, Height = 30 }, + new Border { Width = 40, Height = 60 } + } + }; + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + // 3 * 2 grid + Assert.Equal(new Size(3 * 80, 2 * 90), target.Bounds.Size); + } + + [Fact] + public void Grid_Size_Is_Limited_By_Rows_And_Columns() + { + var target = new UniformGrid() + { + Columns = 2, + Rows = 2, + Children = + { + new Border { Width = 50, Height = 70 }, + new Border { Width = 30, Height = 50 }, + new Border { Width = 80, Height = 90 }, + new Border { Width = 20, Height = 30 }, + new Border { Width = 40, Height = 60 } + } + }; + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + // 2 * 2 grid + Assert.Equal(new Size(2 * 80, 2 * 90), target.Bounds.Size); + } + + [Fact] + public void Not_Visible_Children_Are_Ignored() + { + var target = new UniformGrid() + { + Children = + { + new Border { Width = 50, Height = 70 }, + new Border { Width = 30, Height = 50 }, + new Border { Width = 80, Height = 90, IsVisible = false }, + new Border { Width = 20, Height = 30 }, + new Border { Width = 40, Height = 60 } + } + }; + + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + // 2 * 2 grid + Assert.Equal(new Size(2 * 50, 2 * 70), target.Bounds.Size); + } + } +} From 8c31c71d1e3189921f608b1d9850c0158416efef Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 21 Aug 2018 16:47:24 +0300 Subject: [PATCH 13/22] References updates Updates for Rx, ReactiveUI and Microsoft CSharp nuget references. --- build/Microsoft.CSharp.props | 2 +- build/Microsoft.Reactive.Testing.props | 2 +- build/ReactiveUI.props | 2 +- build/Rx.props | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/build/Microsoft.CSharp.props b/build/Microsoft.CSharp.props index d0fa63bc3e..4f738dd254 100644 --- a/build/Microsoft.CSharp.props +++ b/build/Microsoft.CSharp.props @@ -1,5 +1,5 @@ - + diff --git a/build/Microsoft.Reactive.Testing.props b/build/Microsoft.Reactive.Testing.props index 5ee6df708e..33b8d8a7ca 100644 --- a/build/Microsoft.Reactive.Testing.props +++ b/build/Microsoft.Reactive.Testing.props @@ -1,5 +1,5 @@  - + diff --git a/build/ReactiveUI.props b/build/ReactiveUI.props index 11afefa8ad..acdfdd215a 100644 --- a/build/ReactiveUI.props +++ b/build/ReactiveUI.props @@ -1,5 +1,5 @@ - + diff --git a/build/Rx.props b/build/Rx.props index 7078e31195..77c7e69c94 100644 --- a/build/Rx.props +++ b/build/Rx.props @@ -1,9 +1,9 @@  - - - - - + + + + + From b13908681df3c01758167e348593636c6579c857 Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 21 Aug 2018 19:39:46 +0300 Subject: [PATCH 14/22] Downgrade to Rx 4.0 Because of MonoAndroid we can't use Rx 4.1 (System.Threading.Tasks.Extensions is duplicated in mscorlib) --- build/Microsoft.Reactive.Testing.props | 2 +- build/Rx.props | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build/Microsoft.Reactive.Testing.props b/build/Microsoft.Reactive.Testing.props index 33b8d8a7ca..777bc4bb53 100644 --- a/build/Microsoft.Reactive.Testing.props +++ b/build/Microsoft.Reactive.Testing.props @@ -1,5 +1,5 @@  - + diff --git a/build/Rx.props b/build/Rx.props index 77c7e69c94..f4affcacac 100644 --- a/build/Rx.props +++ b/build/Rx.props @@ -1,9 +1,9 @@  - - - - - + + + + + From 18f36ab3bc34c0a47e93da5089697851e3bcc099 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 21 Aug 2018 15:30:34 +0200 Subject: [PATCH 15/22] Fix #1782: Implement GroupName for RadioButton --- .../ControlCatalog/Pages/RadioButtonPage.xaml | 13 ++ src/Avalonia.Controls/RadioButton.cs | 201 +++++++++++++++++- .../RadioButtonTests.cs | 38 ++++ 3 files changed, 242 insertions(+), 10 deletions(-) diff --git a/samples/ControlCatalog/Pages/RadioButtonPage.xaml b/samples/ControlCatalog/Pages/RadioButtonPage.xaml index 0882817a9a..9525f6187e 100644 --- a/samples/ControlCatalog/Pages/RadioButtonPage.xaml +++ b/samples/ControlCatalog/Pages/RadioButtonPage.xaml @@ -22,6 +22,19 @@ Three States: Option 3 Disabled + + Group A: Option 1 + Group A: Disabled + Group B: Option 1 + Group B: Option 3 + + + Group A: Option 2 + Group B: Option 2 + Group B: Option 4 + \ No newline at end of file diff --git a/src/Avalonia.Controls/RadioButton.cs b/src/Avalonia.Controls/RadioButton.cs index 945335b8f7..f8bcd8b478 100644 --- a/src/Avalonia.Controls/RadioButton.cs +++ b/src/Avalonia.Controls/RadioButton.cs @@ -2,19 +2,144 @@ // 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; using Avalonia.Controls.Primitives; +using Avalonia.Rendering; using Avalonia.VisualTree; namespace Avalonia.Controls { public class RadioButton : ToggleButton { + private class RadioButtonGroupManager + { + public static readonly RadioButtonGroupManager Default = new RadioButtonGroupManager(); + static readonly List<(WeakReference Root, RadioButtonGroupManager Manager)> s_registeredVisualRoots + = new List<(WeakReference Root, RadioButtonGroupManager Manager)>(); + + readonly Dictionary>> s_registeredGroups + = new Dictionary>>(); + + public static RadioButtonGroupManager GetOrCreateForRoot(IRenderRoot root) + { + if (root == null) + return Default; + lock (s_registeredVisualRoots) + { + int i = 0; + while (i < s_registeredVisualRoots.Count) + { + var item = s_registeredVisualRoots[i].Root; + if (!item.TryGetTarget(out var target)) + { + s_registeredVisualRoots.RemoveAt(i); + continue; + } + if (root == target) + break; + i++; + } + RadioButtonGroupManager manager; + if (i >= s_registeredVisualRoots.Count) + { + manager = new RadioButtonGroupManager(); + s_registeredVisualRoots.Add((new WeakReference(root), manager)); + } + else + { + manager = s_registeredVisualRoots[i].Manager; + } + return manager; + } + } + + public void Add(RadioButton radioButton) + { + lock (s_registeredGroups) + { + string groupName = radioButton.GroupName; + if (!s_registeredGroups.TryGetValue(groupName, out var group)) + { + group = new List>(); + s_registeredGroups.Add(groupName, group); + } + group.Add(new WeakReference(radioButton)); + } + } + + public void Remove(RadioButton radioButton, string oldGroupName) + { + lock (s_registeredGroups) + { + if (!string.IsNullOrEmpty(oldGroupName) && s_registeredGroups.TryGetValue(oldGroupName, out var group)) + { + int i = 0; + while (i < group.Count) + { + if (!group[i].TryGetTarget(out var button) || button == radioButton) + { + group.RemoveAt(i); + continue; + } + i++; + } + if (group.Count == 0) + { + s_registeredGroups.Remove(oldGroupName); + } + } + } + } + + public void SetChecked(RadioButton radioButton) + { + lock (s_registeredGroups) + { + string groupName = radioButton.GroupName; + if (s_registeredGroups.TryGetValue(groupName, out var group)) + { + int i = 0; + while (i < group.Count) + { + if (!group[i].TryGetTarget(out var current)) + { + group.RemoveAt(i); + continue; + } + if (current != radioButton && current.IsChecked.GetValueOrDefault()) + current.IsChecked = false; + i++; + } + if (group.Count == 0) + { + s_registeredGroups.Remove(groupName); + } + } + } + } + } + + public static readonly DirectProperty GroupNameProperty = + AvaloniaProperty.RegisterDirect( + nameof(GroupName), + o => o.GroupName, + (o, v) => o.GroupName = v); + + private string _groupName; + private RadioButtonGroupManager _groupManager; + public RadioButton() { this.GetObservable(IsCheckedProperty).Subscribe(IsCheckedChanged); } + public string GroupName + { + get { return _groupName; } + set { SetGroupName(value); } + } + protected override void Toggle() { if (!IsChecked.GetValueOrDefault()) @@ -23,21 +148,77 @@ namespace Avalonia.Controls } } - private void IsCheckedChanged(bool? value) + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { - var parent = this.GetVisualParent(); + if (!string.IsNullOrEmpty(GroupName)) + { + var manager = RadioButtonGroupManager.GetOrCreateForRoot(e.Root); + if (manager != _groupManager) + { + _groupManager.Remove(this, _groupName); + _groupManager = manager; + manager.Add(this); + } + } + base.OnAttachedToVisualTree(e); + } - if (value.GetValueOrDefault() && parent != null) + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + if (!string.IsNullOrEmpty(GroupName) && _groupManager != null) { - var siblings = parent - .GetVisualChildren() - .OfType() - .Where(x => x != this); + _groupManager.Remove(this, _groupName); + } + } - foreach (var sibling in siblings) + private void SetGroupName(string newGroupName) + { + string oldGroupName = GroupName; + if (newGroupName != oldGroupName) + { + if (!string.IsNullOrEmpty(oldGroupName) && _groupManager != null) + { + _groupManager.Remove(this, oldGroupName); + } + _groupName = newGroupName; + if (!string.IsNullOrEmpty(newGroupName)) + { + if (_groupManager == null) + { + _groupManager = RadioButtonGroupManager.GetOrCreateForRoot(this.GetVisualRoot()); + } + _groupManager.Add(this); + } + } + } + + private void IsCheckedChanged(bool? value) + { + string groupName = GroupName; + if (string.IsNullOrEmpty(groupName)) + { + var parent = this.GetVisualParent(); + + if (value.GetValueOrDefault() && parent != null) + { + var siblings = parent + .GetVisualChildren() + .OfType() + .Where(x => x != this); + + foreach (var sibling in siblings) + { + if (sibling.IsChecked.GetValueOrDefault()) + sibling.IsChecked = false; + } + } + } + else + { + if (value.GetValueOrDefault() && _groupManager != null) { - if (sibling.IsChecked.GetValueOrDefault()) - sibling.IsChecked = false; + _groupManager.SetChecked(this); } } } diff --git a/tests/Avalonia.Controls.UnitTests/RadioButtonTests.cs b/tests/Avalonia.Controls.UnitTests/RadioButtonTests.cs index 2d9dca93f5..7c5249b2c4 100644 --- a/tests/Avalonia.Controls.UnitTests/RadioButtonTests.cs +++ b/tests/Avalonia.Controls.UnitTests/RadioButtonTests.cs @@ -32,5 +32,43 @@ namespace Avalonia.Controls.UnitTests Assert.True(radioButton1.IsChecked); Assert.Null(radioButton2.IsChecked); } + + [Fact] + public void RadioButton_In_Same_Group_Is_Unchecked() + { + var parent = new Panel(); + + var panel1 = new Panel(); + var panel2 = new Panel(); + + parent.Children.Add(panel1); + parent.Children.Add(panel2); + + var radioButton1 = new RadioButton(); + radioButton1.GroupName = "A"; + radioButton1.IsChecked = false; + + var radioButton2 = new RadioButton(); + radioButton2.GroupName = "A"; + radioButton2.IsChecked = true; + + var radioButton3 = new RadioButton(); + radioButton3.GroupName = "A"; + radioButton3.IsChecked = false; + + panel1.Children.Add(radioButton1); + panel1.Children.Add(radioButton2); + panel2.Children.Add(radioButton3); + + Assert.False(radioButton1.IsChecked); + Assert.True(radioButton2.IsChecked); + Assert.False(radioButton3.IsChecked); + + radioButton3.IsChecked = true; + + Assert.False(radioButton1.IsChecked); + Assert.False(radioButton2.IsChecked); + Assert.True(radioButton3.IsChecked); + } } } From 9555a914a910c0d1ad482b9ea63053a1fcfc1a25 Mon Sep 17 00:00:00 2001 From: William David Cossey Date: Wed, 22 Aug 2018 20:11:32 +0200 Subject: [PATCH 16/22] OS compatibility check for SetProcessDpiAwareness (Windows 8.1 or newer) (#1816) --- src/Windows/Avalonia.Win32/Win32Platform.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index a5088e794c..9afb1218af 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -50,7 +50,12 @@ namespace Avalonia.Win32 // Declare that this process is aware of per monitor DPI if (UnmanagedMethods.ShCoreAvailable) { - UnmanagedMethods.SetProcessDpiAwareness(UnmanagedMethods.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE); + var osVersion = Environment.OSVersion.Version; + if (osVersion.Major > 6 || (osVersion.Major == 6 && osVersion.Minor > 2)) + { + UnmanagedMethods.SetProcessDpiAwareness(UnmanagedMethods.PROCESS_DPI_AWARENESS + .PROCESS_PER_MONITOR_DPI_AWARE); + } } CreateMessageWindow(); From 739fdc3b9bebacb01ed83c4919bbf2982a0bf6f6 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 22 Aug 2018 22:10:34 +0200 Subject: [PATCH 17/22] Use ConditionalWeakTable instead of a custom list of (IRenderRoot, RadioButtonGroupManager). --- src/Avalonia.Controls/RadioButton.cs | 35 ++++------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/src/Avalonia.Controls/RadioButton.cs b/src/Avalonia.Controls/RadioButton.cs index f8bcd8b478..a1d353f135 100644 --- a/src/Avalonia.Controls/RadioButton.cs +++ b/src/Avalonia.Controls/RadioButton.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using Avalonia.Controls.Primitives; using Avalonia.Rendering; using Avalonia.VisualTree; @@ -15,9 +16,9 @@ namespace Avalonia.Controls private class RadioButtonGroupManager { public static readonly RadioButtonGroupManager Default = new RadioButtonGroupManager(); - static readonly List<(WeakReference Root, RadioButtonGroupManager Manager)> s_registeredVisualRoots - = new List<(WeakReference Root, RadioButtonGroupManager Manager)>(); - + static readonly ConditionalWeakTable s_registeredVisualRoots + = new ConditionalWeakTable(); + readonly Dictionary>> s_registeredGroups = new Dictionary>>(); @@ -25,33 +26,7 @@ namespace Avalonia.Controls { if (root == null) return Default; - lock (s_registeredVisualRoots) - { - int i = 0; - while (i < s_registeredVisualRoots.Count) - { - var item = s_registeredVisualRoots[i].Root; - if (!item.TryGetTarget(out var target)) - { - s_registeredVisualRoots.RemoveAt(i); - continue; - } - if (root == target) - break; - i++; - } - RadioButtonGroupManager manager; - if (i >= s_registeredVisualRoots.Count) - { - manager = new RadioButtonGroupManager(); - s_registeredVisualRoots.Add((new WeakReference(root), manager)); - } - else - { - manager = s_registeredVisualRoots[i].Manager; - } - return manager; - } + return s_registeredVisualRoots.GetValue(root, key => new RadioButtonGroupManager()); } public void Add(RadioButton radioButton) From ca34bf9aba582e101d65a4ccf68e0e530c5cf73d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 26 Aug 2018 19:37:27 +0200 Subject: [PATCH 18/22] Don't assume Items are MenuItems. They may be created via `DataTemplate`s. --- src/Avalonia.Controls/MenuItem.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 91f427936c..7b57783c5a 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -342,7 +342,7 @@ namespace Avalonia.Controls if (menuItem != null && menuItem.Parent == this) { - foreach (var child in Items.OfType()) + foreach (var child in ((IMenuItem)this).SubItems) { if (child != menuItem && child.IsSubMenuOpen) { @@ -368,12 +368,9 @@ namespace Avalonia.Controls /// private void CloseSubmenus() { - if (Items != null) + foreach (var child in ((IMenuItem)this).SubItems) { - foreach (var child in Items.OfType()) - { - child.IsSubMenuOpen = false; - } + child.IsSubMenuOpen = false; } } From 4e84b1e487f08453e88a653e53104c967aa22cc1 Mon Sep 17 00:00:00 2001 From: wojciech krysiak Date: Mon, 27 Aug 2018 19:04:17 +0200 Subject: [PATCH 19/22] Made sure that only the required assemblies are copied to the appropriate package directories + included net461 package output. --- scripts/ReplaceNugetCache.ps1 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/ReplaceNugetCache.ps1 b/scripts/ReplaceNugetCache.ps1 index a03d442bff..6de50f978d 100644 --- a/scripts/ReplaceNugetCache.ps1 +++ b/scripts/ReplaceNugetCache.ps1 @@ -1,5 +1,6 @@ +copy ..\samples\ControlCatalog.Desktop\bin\Debug\net461\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\net461\ copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\netcoreapp2.0\ -copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\netstandard2.0\ -copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia.gtk3\$args\lib\netstandard2.0\ -copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia.win32\$args\lib\netstandard2.0\ -copy ..\samples\ControlCatalog.NetCore.\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia.skia\$args\lib\netstandard2.0\ +copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia**.dll ~\.nuget\packages\avalonia\$args\lib\netstandard2.0\ +copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia.Gtk3.dll ~\.nuget\packages\avalonia.gtk3\$args\lib\netstandard2.0\ +copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia.Win32.dll ~\.nuget\packages\avalonia.win32\$args\lib\netstandard2.0\ +copy ..\samples\ControlCatalog.NetCore\bin\Debug\netcoreapp2.0\Avalonia.Skia.dll ~\.nuget\packages\avalonia.skia\$args\lib\netstandard2.0\ From d72ca725cde7639d66c58a899530f6ba45f423f5 Mon Sep 17 00:00:00 2001 From: wojciech krysiak Date: Mon, 27 Aug 2018 19:27:19 +0200 Subject: [PATCH 20/22] Brought RelativeSource=Self behavior in line with other RelativeSources -Direct fix for AnimationKeyFrame using itself as self-reference --- src/Markup/Avalonia.Markup/Data/Binding.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs index 4f18c682b4..cb43873cee 100644 --- a/src/Markup/Avalonia.Markup/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup/Data/Binding.cs @@ -150,7 +150,9 @@ namespace Avalonia.Data } else if (RelativeSource.Mode == RelativeSourceMode.Self) { - observer = CreateSourceObserver(target, node); + observer = CreateSourceObserver( + (target as IStyledElement) ?? (anchor as IStyledElement), + node); } else if (RelativeSource.Mode == RelativeSourceMode.TemplatedParent) { From 49f36d4ac60d3ce2adf0e9a0f9a08f95c2eeb2cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Marki=C4=8D?= Date: Sat, 1 Sep 2018 12:56:03 +0200 Subject: [PATCH 21/22] Adds reference to nightly build feed --- readme.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/readme.md b/readme.md index 345ad7fe9b..f345cbd9df 100644 --- a/readme.md +++ b/readme.md @@ -35,6 +35,9 @@ Install-Package Avalonia.Desktop Try out the latest build of Avalonia available for download here: https://ci.appveyor.com/project/AvaloniaUI/Avalonia/branch/master/artifacts +or use nightly build feeds as described here: +https://github.com/AvaloniaUI/Avalonia/wiki/Using-nightly-build-feed + ## Documentation As mentioned above, Avalonia is still in beta and as such there's not much documentation yet. You can take a look at the [getting started page](http://avaloniaui.net/docs/quickstart/) for an overview of how to get started but probably the best thing to do for now is to already know a little bit about WPF/Silverlight/UWP/XAML and ask questions in our [Gitter room](https://gitter.im/AvaloniaUI/Avalonia). From f797c1d6c10832811632117e6a0d70fcfdf6e2a2 Mon Sep 17 00:00:00 2001 From: Jeremy Koritzinsky Date: Sat, 1 Sep 2018 09:17:54 -0700 Subject: [PATCH 22/22] Fix Avalonia.Android output path in packages.cake. VS 15.8 changed the output directory for Android projects. We need this change to match the new behavior. --- packages.cake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages.cake b/packages.cake index d633230189..9defa3004c 100644 --- a/packages.cake +++ b/packages.cake @@ -303,7 +303,7 @@ public class Packages { new NuSpecContent { Source = "Avalonia.Android.dll", Target = "lib/MonoAndroid10" } }, - BasePath = context.Directory("./src/Android/Avalonia.Android/bin/" + parameters.DirSuffix + "/monoandroid44/"), + BasePath = context.Directory("./src/Android/Avalonia.Android/bin/" + parameters.DirSuffix + "/monoandroid44/MonoAndroid44/"), OutputDirectory = parameters.NugetRoot }, ///////////////////////////////////////////////////////////////////////////////