From 4a5e11f6aaee79a3183aa7d703b60c4fa912a971 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 27 Feb 2019 09:53:54 +0100 Subject: [PATCH] Fix keyboard interaction for ContextMenu. --- src/Avalonia.Controls/ContextMenu.cs | 7 ++ src/Avalonia.Controls/Menu.cs | 2 +- src/Avalonia.Controls/MenuBase.cs | 5 +- .../Platform/DefaultMenuInteractionHandler.cs | 80 +++++++++++-------- src/Avalonia.Input/InputElement.cs | 2 +- .../DefaultMenuInteractionHandlerTests.cs | 71 +++++++++------- 6 files changed, 102 insertions(+), 65 deletions(-) diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 92f195b79e..92293a32d6 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -24,6 +24,7 @@ namespace Avalonia.Controls /// Initializes a new instance of the class. /// public ContextMenu() + : this(new DefaultMenuInteractionHandler(true)) { } @@ -99,6 +100,7 @@ namespace Avalonia.Controls ObeyScreenEdges = true }; + _popup.Opened += PopupOpened; _popup.Closed += PopupClosed; } @@ -127,6 +129,11 @@ namespace Avalonia.Controls return new MenuItemContainerGenerator(this); } + private void PopupOpened(object sender, EventArgs e) + { + Focus(); + } + private void PopupClosed(object sender, EventArgs e) { var contextMenu = (sender as Popup)?.Child as ContextMenu; diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index 290e16d8c8..b0fb3f2b3b 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -11,7 +11,7 @@ namespace Avalonia.Controls /// /// A top-level menu control. /// - public class Menu : MenuBase, IFocusScope, IMainMenu + public class Menu : MenuBase, IMainMenu { private static readonly ITemplate DefaultPanel = new FuncTemplate(() => new StackPanel { Orientation = Orientation.Horizontal }); diff --git a/src/Avalonia.Controls/MenuBase.cs b/src/Avalonia.Controls/MenuBase.cs index 836e3609c4..d6eb40360b 100644 --- a/src/Avalonia.Controls/MenuBase.cs +++ b/src/Avalonia.Controls/MenuBase.cs @@ -17,7 +17,7 @@ namespace Avalonia.Controls /// /// Base class for menu controls. /// - public abstract class MenuBase : SelectingItemsControl, IMenu + public abstract class MenuBase : SelectingItemsControl, IFocusScope, IMenu { /// /// Defines the property. @@ -46,8 +46,7 @@ namespace Avalonia.Controls /// public MenuBase() { - InteractionHandler = AvaloniaLocator.Current.GetService() ?? - new DefaultMenuInteractionHandler(); + InteractionHandler = new DefaultMenuInteractionHandler(false); } /// diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 6b03e67897..942104d61b 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -13,18 +13,21 @@ namespace Avalonia.Controls.Platform /// public class DefaultMenuInteractionHandler : IMenuInteractionHandler { + private readonly bool _isContextMenu; private IDisposable _inputManagerSubscription; private IRenderRoot _root; - public DefaultMenuInteractionHandler() - : this(Input.InputManager.Instance, DefaultDelayRun) + public DefaultMenuInteractionHandler(bool isContextMenu) + : this(isContextMenu, Input.InputManager.Instance, DefaultDelayRun) { } public DefaultMenuInteractionHandler( + bool isContextMenu, IInputManager inputManager, Action delayRun) { + _isContextMenu = isContextMenu; InputManager = inputManager; DelayRun = delayRun; } @@ -59,7 +62,7 @@ namespace Avalonia.Controls.Platform window.Deactivated += WindowDeactivated; } - _inputManagerSubscription = InputManager.Process.Subscribe(RawInput); + _inputManagerSubscription = InputManager?.Process.Subscribe(RawInput); } public virtual void Detach(IMenu menu) @@ -125,23 +128,16 @@ namespace Avalonia.Controls.Platform protected internal virtual void KeyDown(object sender, KeyEventArgs e) { - var item = GetMenuItem(e.Source as IControl); - - if (item != null) - { - KeyDown(item, e); - } + KeyDown(GetMenuItem(e.Source as IControl), e); } protected internal virtual void KeyDown(IMenuItem item, KeyEventArgs e) { - Contract.Requires(item != null); - switch (e.Key) { case Key.Up: case Key.Down: - if (item.IsTopLevel) + if (item?.IsTopLevel == true) { if (item.HasSubMenu && !item.IsSubMenuOpen) { @@ -156,7 +152,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(); @@ -169,7 +165,7 @@ namespace Avalonia.Controls.Platform break; case Key.Right: - if (!item.IsTopLevel && item.HasSubMenu) + if (item != null && !item.IsTopLevel && item.HasSubMenu) { Open(item, true); e.Handled = true; @@ -181,47 +177,65 @@ namespace Avalonia.Controls.Platform break; case Key.Enter: - if (!item.HasSubMenu) + if (item != null) { - Click(item); - } - else - { - Open(item, true); - } + if (!item.HasSubMenu) + { + Click(item); + } + else + { + Open(item, true); + } - e.Handled = true; + e.Handled = true; + } break; case Key.Escape: - if (item.Parent != null) + if (item?.Parent != null) { item.Parent.Close(); item.Parent.Focus(); - e.Handled = true; } + else + { + Menu.Close(); + } + + e.Handled = true; break; default: var direction = e.Key.ToNavigationDirection(); - if (direction.HasValue && item.Parent?.MoveSelection(direction.Value, true) == true) + if (direction.HasValue) { - // 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 (item == null && _isContextMenu) { - item.Close(); - Open(item.Parent.SelectedItem, true); + if (Menu.MoveSelection(direction.Value, true) == true) + { + e.Handled = true; + } + } + else 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; } - e.Handled = true; } break; } - if (!e.Handled && item.Parent is IMenuItem parentItem) + if (!e.Handled && item?.Parent is IMenuItem parentItem) { KeyDown(parentItem, e); } diff --git a/src/Avalonia.Input/InputElement.cs b/src/Avalonia.Input/InputElement.cs index e7fea94d3d..07e04486ec 100644 --- a/src/Avalonia.Input/InputElement.cs +++ b/src/Avalonia.Input/InputElement.cs @@ -375,7 +375,7 @@ namespace Avalonia.Input /// public void Focus() { - FocusManager.Instance.Focus(this); + FocusManager.Instance?.Focus(this); } /// diff --git a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs index e17279013d..87b235dce7 100644 --- a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs @@ -13,7 +13,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Up_Opens_MenuItem_With_SubMenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); var item = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); var e = new KeyEventArgs { Key = Key.Up, Source = item }; @@ -27,7 +27,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Down_Opens_MenuItem_With_SubMenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); var item = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true); var e = new KeyEventArgs { Key = Key.Down, Source = item }; @@ -41,7 +41,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Right_Selects_Next_MenuItem() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -55,7 +55,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Left_Selects_Previous_MenuItem() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -69,7 +69,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Enter_On_Item_With_No_SubMenu_Causes_Click() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); var menu = Mock.Of(); var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu); var e = new KeyEventArgs { Key = Key.Enter, Source = item }; @@ -84,7 +84,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Enter_On_Item_With_SubMenu_Opens_SubMenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -99,7 +99,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Escape_Closes_Parent_Menu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); var menu = Mock.Of(); var item = Mock.Of(x => x.IsTopLevel == true && x.Parent == menu); var e = new KeyEventArgs { Key = Key.Escape, Source = item }; @@ -113,7 +113,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerEnter_Opens_Item_When_Old_Item_Is_Open() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); var menu = new Mock(); var item = Mock.Of(x => x.IsSubMenuOpen == true && @@ -141,7 +141,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerLeave_Deselects_Item_When_Menu_Not_Open() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -156,7 +156,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerLeave_Doesnt_Deselect_Item_When_Menu_Open() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -175,7 +175,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Up_Selects_Previous_MenuItem() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -189,7 +189,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Down_Selects_Next_MenuItem() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -203,7 +203,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Left_Closes_Parent_SubMenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -218,7 +218,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Right_With_SubMenu_Items_Opens_SubMenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -233,7 +233,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Right_On_TopLevel_Child_Navigates_TopLevel_Selection() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); var menu = new Mock(); var parentItem = Mock.Of(x => x.IsSubMenuOpen == true && @@ -263,7 +263,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Enter_On_Item_With_No_SubMenu_Causes_Click() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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); @@ -279,7 +279,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Enter_On_Item_With_SubMenu_Opens_SubMenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -294,7 +294,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void Escape_Closes_Parent_MenuItem() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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 }; @@ -309,7 +309,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerEnter_Selects_Item() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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); @@ -325,7 +325,7 @@ namespace Avalonia.Controls.UnitTests.Platform public void PointerEnter_Opens_Submenu_After_Delay() { var timer = new TestTimer(); - var target = new DefaultMenuInteractionHandler(null, timer.RunOnce); + var target = new DefaultMenuInteractionHandler(false, 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); @@ -344,7 +344,7 @@ namespace Avalonia.Controls.UnitTests.Platform public void PointerEnter_Closes_Sibling_Submenu_After_Delay() { var timer = new TestTimer(); - var target = new DefaultMenuInteractionHandler(null, timer.RunOnce); + var target = new DefaultMenuInteractionHandler(false, 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); @@ -365,7 +365,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerLeave_Deselects_Item() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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); @@ -381,7 +381,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerLeave_Doesnt_Deselect_Sibling() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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); @@ -398,7 +398,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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); @@ -413,7 +413,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerReleased_On_Item_With_No_SubMenu_Causes_Click() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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); @@ -430,7 +430,7 @@ namespace Avalonia.Controls.UnitTests.Platform 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 target = new DefaultMenuInteractionHandler(false, 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); @@ -467,7 +467,7 @@ namespace Avalonia.Controls.UnitTests.Platform [Fact] public void PointerPressed_On_Item_With_SubMenu_Causes_Opens_Submenu() { - var target = new DefaultMenuInteractionHandler(); + var target = new DefaultMenuInteractionHandler(false); 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); @@ -481,6 +481,23 @@ namespace Avalonia.Controls.UnitTests.Platform } } + public class ContextMenu + { + [Fact] + public void Down_Selects_Selects_First_MenuItem_When_No_Selection() + { + var target = new DefaultMenuInteractionHandler(true); + var contextMenu = Mock.Of(x => x.MoveSelection(NavigationDirection.Down, true) == true); + var e = new KeyEventArgs { Key = Key.Down, Source = contextMenu }; + + target.Attach(contextMenu); + target.KeyDown(contextMenu, e); + + Mock.Get(contextMenu).Verify(x => x.MoveSelection(NavigationDirection.Down, true)); + Assert.True(e.Handled); + } + } + private class TestTimer { private Action _action;