From 1293e9af8df8d6309104ec97184ec40d39990975 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 14 Aug 2018 12:49:33 +0200 Subject: [PATCH] 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)); }