Browse Source

Implemented Menu interactions.

repro-ienumerable-menu-navigation
Steven Kirk 8 years ago
parent
commit
1293e9af8d
  1. 3
      src/Avalonia.Controls/Canvas.cs
  2. 21
      src/Avalonia.Controls/IMenu.cs
  3. 40
      src/Avalonia.Controls/IMenuElement.cs
  4. 41
      src/Avalonia.Controls/IMenuItem.cs
  5. 11
      src/Avalonia.Controls/ItemsControl.cs
  6. 250
      src/Avalonia.Controls/Menu.cs
  7. 292
      src/Avalonia.Controls/MenuItem.cs
  8. 457
      src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs
  9. 22
      src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs
  10. 36
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  11. 58
      src/Avalonia.Controls/StackPanel.cs
  12. 5
      src/Avalonia.Controls/WrapPanel.cs
  13. 37
      src/Avalonia.Input/AccessKeyHandler.cs
  14. 2
      src/Avalonia.Input/IInputElement.cs
  15. 7
      src/Avalonia.Input/IMainMenu.cs
  16. 3
      src/Avalonia.Input/INavigableContainer.cs
  17. 2
      src/Avalonia.Input/InputElement.cs
  18. 2
      src/Avalonia.Input/Navigation/TabNavigation.cs
  19. 5
      src/Avalonia.Themes.Default/MenuItem.xaml
  20. 489
      tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs
  21. 2
      tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

3
src/Avalonia.Controls/Canvas.cs

@ -136,8 +136,9 @@ namespace Avalonia.Controls
/// </summary>
/// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns>
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from)
IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
{
// TODO: Implement this
return null;

21
src/Avalonia.Controls/IMenu.cs

@ -0,0 +1,21 @@
using System;
using Avalonia.Controls.Platform;
namespace Avalonia.Controls
{
/// <summary>
/// Represents a <see cref="Menu"/> or <see cref="ContextMenu"/>.
/// </summary>
public interface IMenu : IMenuElement
{
/// <summary>
/// Gets the menu interaction handler.
/// </summary>
IMenuInteractionHandler InteractionHandler { get; }
/// <summary>
/// Gets a value indicating whether the menu is open.
/// </summary>
bool IsOpen { get; }
}
}

40
src/Avalonia.Controls/IMenuElement.cs

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using Avalonia.Input;
namespace Avalonia.Controls
{
/// <summary>
/// Represents an <see cref="IMenu"/> or <see cref="IMenuItem"/>.
/// </summary>
public interface IMenuElement : IControl
{
/// <summary>
/// Gets or sets the currently selected submenu item.
/// </summary>
IMenuItem SelectedItem { get; set; }
/// <summary>
/// Gets the submenu items.
/// </summary>
IEnumerable<IMenuItem> SubItems { get; }
/// <summary>
/// Opens the menu or menu item.
/// </summary>
void Open();
/// <summary>
/// Closes the menu or menu item.
/// </summary>
void Close();
/// <summary>
/// Moves the submenu selection in the specified direction.
/// </summary>
/// <param name="direction">The direction.</param>
/// <param name="wrap">Whether to wrap after the first or last item.</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
bool MoveSelection(NavigationDirection direction, bool wrap);
}
}

41
src/Avalonia.Controls/IMenuItem.cs

@ -0,0 +1,41 @@
using System;
namespace Avalonia.Controls
{
/// <summary>
/// Represents a <see cref="MenuItem"/>.
/// </summary>
public interface IMenuItem : IMenuElement
{
/// <summary>
/// Gets or sets a value that indicates whether the item has a submenu.
/// </summary>
bool HasSubMenu { get; }
/// <summary>
/// Gets a value indicating whether the mouse is currently over the menu item's submenu.
/// </summary>
bool IsPointerOverSubMenu { get; }
/// <summary>
/// Gets or sets a value that indicates whether the submenu of the <see cref="MenuItem"/> is
/// open.
/// </summary>
bool IsSubMenuOpen { get; set; }
/// <summary>
/// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level main menu item.
/// </summary>
bool IsTopLevel { get; }
/// <summary>
/// Gets the parent <see cref="IMenuElement"/>.
/// </summary>
new IMenuElement Parent { get; }
/// <summary>
/// Raises a click event on the menu item.
/// </summary>
void RaiseClick();
}
}

11
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;
}

250
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
{
/// <summary>
/// A top-level menu control.
/// </summary>
public class Menu : SelectingItemsControl, IFocusScope, IMainMenu
public class Menu : SelectingItemsControl, IFocusScope, IMainMenu, IMenu
{
/// <summary>
/// Defines the default items panel used by a <see cref="Menu"/>.
/// </summary>
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
/// <summary>
/// Defines the <see cref="IsOpen"/> property.
/// </summary>
@ -34,12 +27,42 @@ namespace Avalonia.Controls
nameof(IsOpen),
o => o.IsOpen);
/// <summary>
/// Defines the <see cref="MenuOpened"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuOpenedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuOpened), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="MenuClosed"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuClosedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuClosed), RoutingStrategies.Bubble);
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
private readonly IMenuInteractionHandler _interaction;
private bool _isOpen;
/// <summary>
/// Tracks event handlers added to the root of the visual tree.
/// Initializes a new instance of the <see cref="Menu"/> class.
/// </summary>
public Menu()
{
_interaction = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ??
new DefaultMenuInteractionHandler();
}
/// <summary>
/// Initializes a new instance of the <see cref="Menu"/> class.
/// </summary>
private IDisposable _subscription;
/// <param name="interactionHandler">The menu iteraction handler.</param>
public Menu(IMenuInteractionHandler interactionHandler)
{
Contract.Requires<ArgumentNullException>(interactionHandler != null);
_interaction = interactionHandler;
}
/// <summary>
/// Initializes static members of the <see cref="Menu"/> class.
@ -47,7 +70,6 @@ namespace Avalonia.Controls
static Menu()
{
ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel);
MenuItem.ClickEvent.AddClassHandler<Menu>(x => x.OnMenuClick, handledEventsToo: true);
MenuItem.SubmenuOpenedEvent.AddClassHandler<Menu>(x => x.OnSubmenuOpened);
}
@ -60,18 +82,52 @@ namespace Avalonia.Controls
private set { SetAndRaise(IsOpenProperty, ref _isOpen, value); }
}
/// <summary>
/// Gets the selected <see cref="MenuItem"/> container.
/// </summary>
private MenuItem SelectedMenuItem
/// <inheritdoc/>
IMenuInteractionHandler IMenu.InteractionHandler => _interaction;
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{
get
{
var index = SelectedIndex;
return (index != -1) ?
(MenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
(IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null;
}
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary>
/// Occurs when a <see cref="Menu"/> is opened.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuOpened
{
add { AddHandler(MenuOpenedEvent, value); }
remove { RemoveHandler(MenuOpenedEvent, value); }
}
/// <summary>
/// Occurs when a <see cref="Menu"/> is closed.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuClosed
{
add { AddHandler(MenuClosedEvent, value); }
remove { RemoveHandler(MenuClosedEvent, value); }
}
/// <summary>
@ -79,13 +135,22 @@ namespace Avalonia.Controls
/// </summary>
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,
});
}
}
/// <summary>
@ -93,9 +158,25 @@ namespace Avalonia.Controls
/// </summary>
public void Open()
{
SelectedIndex = 0;
SelectedMenuItem.Focus();
IsOpen = true;
if (!IsOpen)
{
IsOpen = true;
RaiseEvent(new RoutedEventArgs
{
RoutedEvent = MenuOpenedEvent,
Source = this,
});
}
}
/// <inheritdoc/>
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
}
/// <inheritdoc/>
@ -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);
}
/// <inheritdoc/>
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_subscription.Dispose();
_interaction.Detach(this);
}
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
}
/// <summary>
/// Called when a key is pressed within the menu.
/// </summary>
/// <param name="e">The event args.</param>
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;
}
}
}
/// <summary>
/// Called when the menu loses focus.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnLostFocus(RoutedEventArgs e)
{
base.OnLostFocus(e);
SelectedItem = null;
// Don't handle here: let the interaction handler handle it.
}
/// <summary>
@ -184,9 +213,7 @@ namespace Avalonia.Controls
/// <param name="e">The event args.</param>
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<MenuItem>())
{
@ -199,58 +226,5 @@ namespace Avalonia.Controls
IsOpen = true;
}
/// <summary>
/// Called when the top-level window is deactivated.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The event args.</param>
private void Deactivated(object sender, EventArgs e)
{
Close();
}
/// <summary>
/// Listens for non-client clicks and closes the menu when one is detected.
/// </summary>
/// <param name="e">The raw event.</param>
private void ListenForNonClientClick(RawInputEventArgs e)
{
var mouse = e as RawMouseEventArgs;
if (mouse?.Type == RawMouseEventType.NonClientLeftButtonDown)
{
Close();
}
}
/// <summary>
/// Called when a submenu is clicked somewhere in the menu.
/// </summary>
/// <param name="e">The event args.</param>
private void OnMenuClick(RoutedEventArgs e)
{
Close();
FocusManager.Instance.Focus(null);
e.Handled = true;
}
/// <summary>
/// Called when the pointer is pressed anywhere on the window.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The event args.</param>
private void TopLevelPreviewPointerPress(object sender, PointerPressedEventArgs e)
{
if (IsOpen)
{
var control = e.Source as ILogical;
if (!this.IsLogicalParentOf(control))
{
Close();
}
}
}
}
}

292
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
{
/// <summary>
/// A menu item control.
/// </summary>
public class MenuItem : HeaderedSelectingItemsControl, ISelectable
public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable
{
/// <summary>
/// Defines the <see cref="Command"/> property.
@ -62,6 +62,18 @@ namespace Avalonia.Controls
public static readonly RoutedEvent<RoutedEventArgs> ClickEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(Click), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="PointerEnterItem"/> event.
/// </summary>
public static readonly RoutedEvent<PointerEventArgs> PointerEnterItemEvent =
RoutedEvent.Register<InputElement, PointerEventArgs>(nameof(PointerEnterItem), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="PointerLeaveItem"/> event.
/// </summary>
public static readonly RoutedEvent<PointerEventArgs> PointerLeaveItemEvent =
RoutedEvent.Register<InputElement, PointerEventArgs>(nameof(PointerLeaveItem), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="SubmenuOpened"/> event.
/// </summary>
@ -74,11 +86,6 @@ namespace Avalonia.Controls
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel());
/// <summary>
/// The timer used to display submenus.
/// </summary>
private IDisposable _submenuTimer;
/// <summary>
/// The submenu popup.
/// </summary>
@ -93,16 +100,15 @@ namespace Avalonia.Controls
CommandProperty.Changed.Subscribe(CommandChanged);
FocusableProperty.OverrideDefaultValue<MenuItem>(true);
IconProperty.Changed.AddClassHandler<MenuItem>(x => x.IconChanged);
IsSelectedProperty.Changed.AddClassHandler<MenuItem>(x => x.IsSelectedChanged);
ItemsPanelProperty.OverrideDefaultValue<MenuItem>(DefaultPanel);
ClickEvent.AddClassHandler<MenuItem>(x => x.OnClick);
SubmenuOpenedEvent.AddClassHandler<MenuItem>(x => x.OnSubmenuOpened);
IsSubMenuOpenProperty.Changed.AddClassHandler<MenuItem>(x => x.SubMenuOpenChanged);
AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<MenuItem>(x => x.AccessKeyPressed);
}
public MenuItem()
{
}
/// <summary>
@ -114,6 +120,30 @@ namespace Avalonia.Controls
remove { RemoveHandler(ClickEvent, value); }
}
/// <summary>
/// Occurs when the pointer enters a menu item.
/// </summary>
/// <remarks>
/// A bubbling version of the <see cref="InputElement.PointerEnter"/> event for menu items.
/// </remarks>
public event EventHandler<PointerEventArgs> PointerEnterItem
{
add { AddHandler(PointerEnterItemEvent, value); }
remove { RemoveHandler(PointerEnterItemEvent, value); }
}
/// <summary>
/// Raised when the pointer leaves a menu item.
/// </summary>
/// <remarks>
/// A bubbling version of the <see cref="InputElement.PointerLeave"/> event for menu items.
/// </remarks>
public event EventHandler<PointerEventArgs> PointerLeaveItem
{
add { AddHandler(PointerLeaveItemEvent, value); }
remove { RemoveHandler(PointerLeaveItemEvent, value); }
}
/// <summary>
/// Occurs when a <see cref="MenuItem"/>'s submenu is opened.
/// </summary>
@ -185,10 +215,71 @@ namespace Avalonia.Controls
public bool HasSubMenu => !Classes.Contains(":empty");
/// <summary>
/// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level menu item.
/// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level main menu item.
/// </summary>
public bool IsTopLevel => Parent is Menu;
/// <inheritdoc/>
bool IMenuItem.IsPointerOverSubMenu => _popup.PopupRoot?.IsPointerOver ?? false;
/// <inheritdoc/>
IMenuElement IMenuItem.Parent => Parent as IMenuElement;
/// <inheritdoc/>
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{
get
{
var index = SelectedIndex;
return (index != -1) ?
(IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null;
}
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary>
/// Opens the submenu.
/// </summary>
/// <remarks>
/// This has the same effect as setting <see cref="IsSubMenuOpen"/> to true.
/// </remarks>
public void Open() => IsSubMenuOpen = true;
/// <summary>
/// Closes the submenu.
/// </summary>
/// <remarks>
/// This has the same effect as setting <see cref="IsSubMenuOpen"/> to false.
/// </remarks>
public void Close() => IsSubMenuOpen = false;
/// <inheritdoc/>
void IMenuItem.RaiseClick() => RaiseEvent(new RoutedEventArgs(ClickEvent));
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new MenuItemContainerGenerator(this);
}
/// <summary>
/// Called when the <see cref="MenuItem"/> is clicked.
/// </summary>
@ -202,163 +293,43 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Called when the <see cref="MenuItem"/> recieves focus.
/// </summary>
/// <param name="e">The event args.</param>
/// <inheritdoc/>
protected override void OnGotFocus(GotFocusEventArgs e)
{
base.OnGotFocus(e);
IsSelected = true;
e.Handled = UpdateSelectionFromEventSource(e.Source, true);
}
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new MenuItemContainerGenerator(this);
}
/// <summary>
/// Called when a key is pressed in the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
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.
}
/// <summary>
/// Called when the pointer enters the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
/// <inheritdoc/>
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<MenuItem>()
.Where(x => x != this && x.IsSubMenuOpen))
{
sibling.CloseSubmenus();
sibling.IsSubMenuOpen = false;
sibling.IsSelected = false;
}
}
}
Device = e.Device,
RoutedEvent = PointerEnterItemEvent,
Source = this,
});
}
/// <summary>
/// Called when the pointer leaves the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
/// <inheritdoc/>
protected override void OnPointerLeave(PointerEventArgs e)
{
base.OnPointerLeave(e);
if (_submenuTimer != null)
RaiseEvent(new PointerEventArgs
{
_submenuTimer.Dispose();
_submenuTimer = null;
}
}
/// <summary>
/// Called when the pointer is pressed over the <see cref="MenuItem"/>.
/// </summary>
/// <param name="e">The event args.</param>
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,
});
}
/// <summary>
@ -392,25 +363,6 @@ namespace Avalonia.Controls
_popup.Closed += PopupClosed;
}
/// <summary>
/// Called when the menu item's access key is pressed.
/// </summary>
/// <param name="e">The event args.</param>
private void AccessKeyPressed(RoutedEventArgs e)
{
if (HasSubMenu)
{
SelectedIndex = 0;
IsSubMenuOpen = true;
}
else
{
RaiseEvent(new RoutedEventArgs(ClickEvent));
}
e.Handled = true;
}
/// <summary>
/// Closes all submenus of the menu item.
/// </summary>
@ -476,6 +428,18 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Called when the <see cref="IsSelected"/> property changes.
/// </summary>
/// <param name="e">The property change event.</param>
private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e)
{
if ((bool)e.NewValue)
{
Focus();
}
}
/// <summary>
/// Called when the <see cref="IsSubMenuOpen"/> property changes.
/// </summary>

457
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
{
/// <summary>
/// Provides the default keyboard and pointer interaction for menus.
/// </summary>
public class DefaultMenuInteractionHandler : IMenuInteractionHandler
{
private IDisposable _inputManagerSubscription;
private IRenderRoot _root;
public DefaultMenuInteractionHandler()
: this(Input.InputManager.Instance, DefaultDelayRun)
{
}
public DefaultMenuInteractionHandler(
IInputManager inputManager,
Action<Action, TimeSpan> 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<Action, TimeSpan> 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);
}
}
}

22
src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs

@ -0,0 +1,22 @@
using System;
using Avalonia.Input;
namespace Avalonia.Controls.Platform
{
/// <summary>
/// Handles user interaction for menus.
/// </summary>
public interface IMenuInteractionHandler
{
/// <summary>
/// Attaches the interaction handler to a menu.
/// </summary>
/// <param name="menu">The menu.</param>
void Attach(IMenu menu);
/// <summary>
/// Detaches the interaction handler from the attached menu.
/// </summary>
void Detach(IMenu menu);
}
}

36
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -457,6 +457,42 @@ namespace Avalonia.Controls.Primitives
}
}
/// <summary>
/// Moves the selection in the specified direction relative to the current selection.
/// </summary>
/// <param name="direction">The direction to move.</param>
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
protected bool MoveSelection(NavigationDirection direction, bool wrap)
{
var from = SelectedIndex != -1 ? ItemContainerGenerator.ContainerFromIndex(SelectedIndex) : null;
return MoveSelection(from, direction, wrap);
}
/// <summary>
/// Moves the selection in the specified direction relative to the specified container.
/// </summary>
/// <param name="from">The container which serves as a starting point for the movement.</param>
/// <param name="direction">The direction to move.</param>
/// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
/// <returns>True if the selection was moved; otherwise false.</returns>
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;
}
/// <summary>
/// Updates the selection for an item based on user interaction.
/// </summary>

58
src/Avalonia.Controls/StackPanel.cs

@ -56,11 +56,49 @@ namespace Avalonia.Controls
/// </summary>
/// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns>
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;
}
/// <summary>
@ -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;

5
src/Avalonia.Controls/WrapPanel.cs

@ -47,8 +47,9 @@ namespace Avalonia.Controls
/// </summary>
/// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns>
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
}
}
}
}
}

37
src/Avalonia.Input/AccessKeyHandler.cs

@ -53,10 +53,32 @@ namespace Avalonia.Input
/// </summary>
private IInputElement _restoreFocusElement;
/// <summary>
/// The window's main menu.
/// </summary>
private IMainMenu _mainMenu;
/// <summary>
/// Gets or sets the window's main menu.
/// </summary>
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;
}
}
}
/// <summary>
/// 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;
}
}
}

2
src/Avalonia.Input/IInputElement.cs

@ -15,7 +15,7 @@ namespace Avalonia.Input
/// <summary>
/// Occurs when the control receives focus.
/// </summary>
event EventHandler<RoutedEventArgs> GotFocus;
event EventHandler<GotFocusEventArgs> GotFocus;
/// <summary>
/// Occurs when the control loses focus.

7
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.
/// </summary>
void Open();
/// <summary>
/// Occurs when the main menu closes.
/// </summary>
event EventHandler<RoutedEventArgs> MenuClosed;
}
}

3
src/Avalonia.Input/INavigableContainer.cs

@ -13,7 +13,8 @@ namespace Avalonia.Input
/// </summary>
/// <param name="direction">The movement direction.</param>
/// <param name="from">The control from which movement begins.</param>
/// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
/// <returns>The control.</returns>
IInputElement GetControl(NavigationDirection direction, IInputElement from);
IInputElement GetControl(NavigationDirection direction, IInputElement from, bool wrap);
}
}

2
src/Avalonia.Input/InputElement.cs

@ -177,7 +177,7 @@ namespace Avalonia.Input
/// <summary>
/// Occurs when the control receives focus.
/// </summary>
public event EventHandler<RoutedEventArgs> GotFocus
public event EventHandler<GotFocusEventArgs> GotFocus
{
add { AddHandler(GotFocusEvent, value); }
remove { RemoveHandler(GotFocusEvent, value); }

2
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())
{

5
src/Avalonia.Themes.Default/MenuItem.xaml

@ -127,11 +127,6 @@
<Setter Property="BorderBrush" Value="{DynamicResource ThemeAccentBrush}"/>
</Style>
<Style Selector="MenuItem:pointerover /template/ Border#root">
<Setter Property="Background" Value="{DynamicResource ThemeAccentBrush4}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeAccentBrush}"/>
</Style>
<Style Selector="MenuItem:empty /template/ Path#rightArrow">
<Setter Property="IsVisible" Value="False"/>
</Style>

489
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<IMenuItem>(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<IMenuItem>(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<IMenu>(x => x.MoveSelection(NavigationDirection.Right, true) == true);
var item = Mock.Of<IMenuItem>(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<IMenu>(x => x.MoveSelection(NavigationDirection.Left, true) == true);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var item = Mock.Of<IMenuItem>(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<IMenu>();
var item = Mock.Of<IMenuItem>(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<IMenu>();
var item = Mock.Of<IMenuItem>(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<IMenu>();
var item = Mock.Of<IMenuItem>(x =>
x.IsSubMenuOpen == true &&
x.IsTopLevel == true &&
x.HasSubMenu == true &&
x.Parent == menu.Object);
var nextItem = Mock.Of<IMenuItem>(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<IMenu>();
var item = Mock.Of<IMenuItem>(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<IMenu>();
var item = Mock.Of<IMenuItem>(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<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(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<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(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<IMenuItem>(x => x.HasSubMenu == true && x.IsSubMenuOpen == true);
var item = Mock.Of<IMenuItem>(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<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x =>
x.IsSubMenuOpen == true &&
x.IsTopLevel == true &&
x.HasSubMenu == true &&
x.Parent == menu.Object);
var nextItem = Mock.Of<IMenuItem>(x =>
x.IsTopLevel == true &&
x.HasSubMenu == true &&
x.Parent == menu.Object);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(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<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(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<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var sibling = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var childItem = Mock.Of<IMenuItem>(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<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(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;
}
}
}
}

2
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));
}

Loading…
Cancel
Save