diff --git a/src/Avalonia.Controls/Canvas.cs b/src/Avalonia.Controls/Canvas.cs index 8a80d6bdf74..5c9a97cb27f 100644 --- a/src/Avalonia.Controls/Canvas.cs +++ b/src/Avalonia.Controls/Canvas.cs @@ -136,8 +136,9 @@ public static void SetBottom(AvaloniaObject element, double value) /// /// 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/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 13f00bdc87a..0accb284b6f 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -1,16 +1,18 @@ +using System; +using System.Reactive.Linq; +using System.Linq; +using System.ComponentModel; +using Avalonia.Controls.Platform; +using System.Collections.Generic; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Controls.Primitives; + namespace Avalonia.Controls { - using Input; - using Interactivity; - using LogicalTree; - using Primitives; - using System; - using System.Reactive.Linq; - using System.Linq; - using System.ComponentModel; - - public class ContextMenu : SelectingItemsControl + public class ContextMenu : SelectingItemsControl, IMenu { + private readonly IMenuInteractionHandler _interaction; private bool _isOpen; private Popup _popup; @@ -20,6 +22,25 @@ public class ContextMenu : SelectingItemsControl public static readonly DirectProperty IsOpenProperty = AvaloniaProperty.RegisterDirect(nameof(IsOpen), o => o.IsOpen); + /// + /// Initializes a new instance of the class. + /// + public ContextMenu() + { + _interaction = AvaloniaLocator.Current.GetService() ?? + new DefaultMenuInteractionHandler(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The menu iteraction handler. + public ContextMenu(IMenuInteractionHandler interactionHandler) + { + Contract.Requires(interactionHandler != null); + + _interaction = interactionHandler; + } /// /// Initializes static members of the class. @@ -27,8 +48,6 @@ public class ContextMenu : SelectingItemsControl static ContextMenu() { ContextMenuProperty.Changed.Subscribe(ContextMenuChanged); - - MenuItem.ClickEvent.AddClassHandler(x => x.OnContextMenuClick, handledEventsToo: true); } /// @@ -36,6 +55,36 @@ static ContextMenu() /// public bool IsOpen => _isOpen; + /// + IMenuInteractionHandler IMenu.InteractionHandler => _interaction; + + /// + IMenuItem IMenuElement.SelectedItem + { + get + { + var index = SelectedIndex; + return (index != -1) ? + (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) : + null; + } + set + { + SelectedIndex = ItemContainerGenerator.IndexFromContainer(value); + } + } + + /// + IEnumerable IMenuElement.SubItems + { + get + { + return ItemContainerGenerator.Containers + .Select(x => x.ContainerControl) + .OfType(); + } + } + /// /// Occurs when the value of the /// @@ -50,7 +99,6 @@ static ContextMenu() /// public event CancelEventHandler ContextMenuClosing; - /// /// Called when the property changes on a control. /// @@ -71,62 +119,53 @@ private static void ContextMenuChanged(AvaloniaPropertyChangedEventArgs e) } /// - /// Called when a submenu is clicked somewhere in the menu. + /// Opens the menu. /// - /// The event args. - private void OnContextMenuClick(RoutedEventArgs e) - { - Hide(); - FocusManager.Instance.Focus(null); - e.Handled = true; - } + public void Open() => Open(null); /// - /// Closes the menu. + /// Opens a context menu on the specified control. /// - public void Hide() + /// The control. + public void Open(Control control) { - if (_popup != null && _popup.IsVisible) + if (_popup == null) { - _popup.IsOpen = false; + _popup = new Popup() + { + PlacementMode = PlacementMode.Pointer, + PlacementTarget = control, + StaysOpen = false, + ObeyScreenEdges = true + }; + + _popup.Closed += PopupClosed; + _interaction.Attach(this); } - SelectedIndex = -1; + ((ISetLogicalParent)_popup).SetParent(control); + _popup.Child = this; + _popup.IsOpen = true; - SetAndRaise(IsOpenProperty, ref _isOpen, false); + SetAndRaise(IsOpenProperty, ref _isOpen, true); } /// - /// Shows a context menu for the specified control. + /// Closes the menu. /// - /// The control. - private void Show(Control control) + public void Close() { - if (control != null) + if (_popup != null && _popup.IsVisible) { - if (_popup == null) - { - _popup = new Popup() - { - PlacementMode = PlacementMode.Pointer, - PlacementTarget = control, - StaysOpen = false, - ObeyScreenEdges = true - }; - - _popup.Closed += PopupClosed; - } - - ((ISetLogicalParent)_popup).SetParent(control); - _popup.Child = this; + _popup.IsOpen = false; + } - _popup.IsOpen = true; + SelectedIndex = -1; - SetAndRaise(IsOpenProperty, ref _isOpen, true); - } + SetAndRaise(IsOpenProperty, ref _isOpen, false); } - private static void PopupClosed(object sender, EventArgs e) + private void PopupClosed(object sender, EventArgs e) { var contextMenu = (sender as Popup)?.Child as ContextMenu; @@ -152,7 +191,7 @@ private static void ControlPointerReleased(object sender, PointerReleasedEventAr if (contextMenu.CancelClosing()) return; - control.ContextMenu.Hide(); + control.ContextMenu.Close(); e.Handled = true; } @@ -161,7 +200,7 @@ private static void ControlPointerReleased(object sender, PointerReleasedEventAr if (contextMenu.CancelOpening()) return; - contextMenu.Show(control); + contextMenu.Open(control); e.Handled = true; } } @@ -179,5 +218,10 @@ private bool CancelOpening() ContextMenuOpening?.Invoke(this, eventArgs); return eventArgs.Cancel; } + + bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) + { + throw new NotImplementedException(); + } } } diff --git a/src/Avalonia.Controls/IMenu.cs b/src/Avalonia.Controls/IMenu.cs new file mode 100644 index 00000000000..e118ec043cb --- /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 00000000000..c9fc04dcc8b --- /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 00000000000..2657b1949fb --- /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 3cb997f6157..676e0af3de3 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -15,6 +15,7 @@ using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Metadata; +using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -323,6 +324,46 @@ protected virtual void OnContainersRecycled(ItemContainerEventArgs e) LogicalChildren.RemoveAll(toRemove); } + /// + /// Handles directional navigation within the . + /// + /// The key events. + protected override void OnKeyDown(KeyEventArgs e) + { + if (!e.Handled) + { + var focus = FocusManager.Instance; + var direction = e.Key.ToNavigationDirection(); + var container = Presenter?.Panel as INavigableContainer; + + if (container == null || + focus.Current == null || + direction == null || + direction.Value.IsTab()) + { + return; + } + + var current = focus.Current + .GetSelfAndVisualAncestors() + .OfType() + .FirstOrDefault(x => x.VisualParent == container); + + if (current != null) + { + var next = GetNextControl(container, direction.Value, current, false); + + if (next != null) + { + focus.Focus(next, NavigationMethod.Directional); + e.Handled = true; + } + } + } + + base.OnKeyDown(e); + } + /// /// Caled when the property changes. /// @@ -335,6 +376,7 @@ protected virtual void ItemsChanged(AvaloniaPropertyChangedEventArgs e) var oldValue = e.OldValue as IEnumerable; var newValue = e.NewValue as IEnumerable; + UpdateItemCount(); RemoveControlItemsFromLogicalChildren(oldValue); AddControlItemsToLogicalChildren(newValue); SubscribeToItems(newValue); @@ -358,10 +400,8 @@ protected virtual void ItemsCollectionChanged(object sender, NotifyCollectionCha RemoveControlItemsFromLogicalChildren(e.OldItems); break; } - - int? count = (Items as IList)?.Count; - if (count != null) - ItemCount = (int)count; + + UpdateItemCount(); var collection = sender as ICollection; PseudoClasses.Set(":empty", collection == null || collection.Count == 0); @@ -445,5 +485,44 @@ private void ItemTemplateChanged(AvaloniaPropertyChangedEventArgs e) // TODO: Rebuild the item containers. } } + + private void UpdateItemCount() + { + if (Items == null) + { + ItemCount = 0; + } + else if (Items is IList list) + { + ItemCount = list.Count; + } + else + { + ItemCount = Items.Count(); + } + } + + protected static IInputElement GetNextControl( + INavigableContainer container, + NavigationDirection direction, + IInputElement from, + bool wrap) + { + IInputElement result; + + do + { + result = container.GetControl(direction, from, wrap); + + if (result?.Focusable == true) + { + return result; + } + + from = result; + } while (from != null); + + return null; + } } } diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index 994af9dab8a..edd7ed489e5 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 @@ public class Menu : SelectingItemsControl, IFocusScope, IMainMenu 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 @@ public class Menu : SelectingItemsControl, IFocusScope, IMainMenu 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 @@ public bool IsOpen 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 @@ private MenuItem SelectedMenuItem /// 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 @@ public void Close() /// 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 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { 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 @@ protected override void OnLostFocus(RoutedEventArgs e) /// 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 @@ protected virtual void OnSubmenuOpened(RoutedEventArgs e) 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 96f6fb59b04..7b57783c5af 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.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 @@ public class MenuItem : HeaderedSelectingItemsControl, ISelectable 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. /// @@ -72,15 +84,7 @@ public class MenuItem : HeaderedSelectingItemsControl, ISelectable /// The default value for the property. /// private static readonly ITemplate DefaultPanel = - new FuncTemplate(() => new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - }); - - /// - /// The timer used to display submenus. - /// - private IDisposable _submenuTimer; + new FuncTemplate(() => new StackPanel()); /// /// The submenu popup. @@ -96,16 +100,15 @@ static MenuItem() 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() { - } /// @@ -117,6 +120,30 @@ public event EventHandler Click 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. /// @@ -188,10 +215,71 @@ public bool IsSubMenuOpen 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. /// @@ -205,163 +293,43 @@ protected virtual void OnClick(RoutedEventArgs e) } } - /// - /// 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) + RaiseEvent(new PointerEventArgs { - _submenuTimer = DispatcherTimer.Run( - () => IsSubMenuOpen = true, - TimeSpan.FromMilliseconds(400)); - } - else - { - 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, + }); } /// @@ -374,7 +342,7 @@ protected virtual void OnSubmenuOpened(RoutedEventArgs e) if (menuItem != null && menuItem.Parent == this) { - foreach (var child in Items.OfType()) + foreach (var child in ((IMenuItem)this).SubItems) { if (child != menuItem && child.IsSubMenuOpen) { @@ -395,31 +363,12 @@ protected override void OnTemplateApplied(TemplateAppliedEventArgs e) _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. /// private void CloseSubmenus() { - foreach (var child in Items.OfType()) + foreach (var child in ((IMenuItem)this).SubItems) { child.IsSubMenuOpen = false; } @@ -479,6 +428,18 @@ private void IconChanged(AvaloniaPropertyChangedEventArgs e) } } + /// + /// 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 00000000000..a44495b90ca --- /dev/null +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -0,0 +1,459 @@ +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) + { + Contract.Requires(item != null); + + 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 && 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.Parent.SelectedItem == item) + { + if (item.IsTopLevel) + { + if (!((IMenu)item.Parent).IsOpen) + { + 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 00000000000..342d3dd1c96 --- /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/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index f8d62a1cbf3..500c7aa1877 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -143,13 +143,6 @@ protected override void PanelCreated(IPanel panel) Virtualizer = ItemVirtualizer.Create(this); ((ILogicalScrollable)this).InvalidateScroll?.Invoke(); - if (!Panel.IsSet(KeyboardNavigation.DirectionalNavigationProperty)) - { - KeyboardNavigation.SetDirectionalNavigation( - (InputElement)Panel, - KeyboardNavigationMode.Contained); - } - KeyboardNavigation.SetTabNavigation( (InputElement)Panel, KeyboardNavigation.GetTabNavigation(this)); @@ -175,4 +168,4 @@ private void VirtualizationModeChanged(AvaloniaPropertyChangedEventArgs e) ((ILogicalScrollable)this).InvalidateScroll?.Invoke(); } } -} \ No newline at end of file +} diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index c8425a0f80f..5451cf07017 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -457,6 +457,42 @@ protected override void OnDataContextEndUpdate() } } + /// + /// 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 b0ccd8a3d1a..645cdbd9263 100644 --- a/src/Avalonia.Controls/StackPanel.cs +++ b/src/Avalonia.Controls/StackPanel.cs @@ -56,11 +56,49 @@ public Orientation Orientation /// /// 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 @@ IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInp 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 @@ protected virtual IInputElement GetControlInDirection(NavigationDirection direct 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/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 2e1c011685c..4575fa767b0 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -136,6 +136,92 @@ protected override void OnGotFocus(GotFocusEventArgs e) } } + protected override void OnKeyDown(KeyEventArgs e) + { + var direction = e.Key.ToNavigationDirection(); + + if (direction?.IsDirectional() == true && !e.Handled) + { + if (SelectedItem != null) + { + var next = GetContainerInDirection( + GetContainerFromEventSource(e.Source) as TreeViewItem, + direction.Value, + true); + + if (next != null) + { + FocusManager.Instance.Focus(next, NavigationMethod.Directional); + e.Handled = true; + } + } + else + { + SelectedItem = ElementAt(Items, 0); + } + } + } + + private TreeViewItem GetContainerInDirection( + TreeViewItem from, + NavigationDirection direction, + bool intoChildren) + { + IItemContainerGenerator parentGenerator; + + if (from?.Parent is TreeView treeView) + { + parentGenerator = treeView.ItemContainerGenerator; + } + else if (from?.Parent is TreeViewItem item) + { + parentGenerator = item.ItemContainerGenerator; + } + else + { + return null; + } + + var index = parentGenerator.IndexFromContainer(from); + var parent = from.Parent as ItemsControl; + TreeViewItem result = null; + + switch (direction) + { + case NavigationDirection.Up: + if (index > 0) + { + var previous = (TreeViewItem)parentGenerator.ContainerFromIndex(index - 1); + result = previous.IsExpanded ? + (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) : + previous; + } + else + { + result = from.Parent as TreeViewItem; + } + + break; + + case NavigationDirection.Down: + if (from.IsExpanded && intoChildren) + { + result = (TreeViewItem)from.ItemContainerGenerator.ContainerFromIndex(0); + } + else if (index < parent?.ItemCount - 1) + { + result = (TreeViewItem)parentGenerator.ContainerFromIndex(index + 1); + } + else if (parent is TreeViewItem parentItem) + { + return GetContainerInDirection(parentItem, direction, false); + } + break; + } + + return result; + } + /// protected override void OnPointerPressed(PointerPressedEventArgs e) { diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index bed27ef033a..0886c050388 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -32,10 +32,7 @@ public class TreeViewItem : HeaderedItemsControl, ISelectable ListBoxItem.IsSelectedProperty.AddOwner(); private static readonly ITemplate DefaultPanel = - new FuncTemplate(() => new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - }); + new FuncTemplate(() => new StackPanel()); private TreeView _treeView; private bool _isExpanded; @@ -127,7 +124,7 @@ protected override void OnKeyDown(KeyEventArgs e) } } - base.OnKeyDown(e); + // Don't call base.OnKeyDown - let events bubble up to containing TreeView. } } } diff --git a/src/Avalonia.Controls/WrapPanel.cs b/src/Avalonia.Controls/WrapPanel.cs index 745de95bcad..84d3cc791e0 100644 --- a/src/Avalonia.Controls/WrapPanel.cs +++ b/src/Avalonia.Controls/WrapPanel.cs @@ -47,8 +47,9 @@ public Orientation Orientation /// /// 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 @@ public Size ToSize() } } } -} \ No newline at end of file +} diff --git a/src/Avalonia.Input/AccessKeyHandler.cs b/src/Avalonia.Input/AccessKeyHandler.cs index 7baa4103d7f..b78e5a9f982 100644 --- a/src/Avalonia.Input/AccessKeyHandler.cs +++ b/src/Avalonia.Input/AccessKeyHandler.cs @@ -53,10 +53,32 @@ public class AccessKeyHandler : IAccessKeyHandler /// 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 @@ protected virtual void OnKeyDown(object sender, KeyEventArgs e) { 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 @@ private void CloseMenu() 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 5acb6aa7776..c9924dbffbd 100644 --- a/src/Avalonia.Input/IInputElement.cs +++ b/src/Avalonia.Input/IInputElement.cs @@ -15,7 +15,7 @@ public interface IInputElement : IInteractive, IVisual /// /// 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 39d19a0a764..a3373191a81 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 @@ public interface IMainMenu : IVisual /// 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 13d734bd0be..df434bca70b 100644 --- a/src/Avalonia.Input/INavigableContainer.cs +++ b/src/Avalonia.Input/INavigableContainer.cs @@ -13,7 +13,8 @@ public interface INavigableContainer /// /// 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 82e626f9c63..3aff5d0a8be 100644 --- a/src/Avalonia.Input/InputElement.cs +++ b/src/Avalonia.Input/InputElement.cs @@ -177,7 +177,7 @@ static InputElement() /// /// 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/KeyboardNavigation.cs b/src/Avalonia.Input/KeyboardNavigation.cs index cbd9a74f4cb..0277876e24f 100644 --- a/src/Avalonia.Input/KeyboardNavigation.cs +++ b/src/Avalonia.Input/KeyboardNavigation.cs @@ -8,19 +8,6 @@ namespace Avalonia.Input /// public static class KeyboardNavigation { - /// - /// Defines the DirectionalNavigation attached property. - /// - /// - /// The DirectionalNavigation attached property defines how pressing arrow keys causes - /// focus to be navigated between the children of the container. - /// - public static readonly AttachedProperty DirectionalNavigationProperty = - AvaloniaProperty.RegisterAttached( - "DirectionalNavigation", - typeof(KeyboardNavigation), - KeyboardNavigationMode.None); - /// /// Defines the TabNavigation attached property. /// @@ -46,26 +33,6 @@ public static class KeyboardNavigation "TabOnceActiveElement", typeof(KeyboardNavigation)); - /// - /// Gets the for a container. - /// - /// The container. - /// The for the container. - public static KeyboardNavigationMode GetDirectionalNavigation(InputElement element) - { - return element.GetValue(DirectionalNavigationProperty); - } - - /// - /// Sets the for a container. - /// - /// The container. - /// The for the container. - public static void SetDirectionalNavigation(InputElement element, KeyboardNavigationMode value) - { - element.SetValue(DirectionalNavigationProperty, value); - } - /// /// Gets the for a container. /// diff --git a/src/Avalonia.Input/KeyboardNavigationHandler.cs b/src/Avalonia.Input/KeyboardNavigationHandler.cs index bf2b61d08bf..bc3098a7fbc 100644 --- a/src/Avalonia.Input/KeyboardNavigationHandler.cs +++ b/src/Avalonia.Input/KeyboardNavigationHandler.cs @@ -85,7 +85,7 @@ public static IInputElement GetNext( } else { - return DirectionalNavigation.GetNext(element, direction); + throw new NotSupportedException(); } } @@ -122,47 +122,12 @@ protected virtual void OnKeyDown(object sender, KeyEventArgs e) { var current = FocusManager.Instance.Current; - if (current != null) + if (current != null && e.Key == Key.Tab) { - NavigationDirection? direction = null; - - switch (e.Key) - { - case Key.Tab: - direction = (e.Modifiers & InputModifiers.Shift) == 0 ? - NavigationDirection.Next : NavigationDirection.Previous; - break; - case Key.Up: - direction = NavigationDirection.Up; - break; - case Key.Down: - direction = NavigationDirection.Down; - break; - case Key.Left: - direction = NavigationDirection.Left; - break; - case Key.Right: - direction = NavigationDirection.Right; - break; - case Key.PageUp: - direction = NavigationDirection.PageUp; - break; - case Key.PageDown: - direction = NavigationDirection.PageDown; - break; - case Key.Home: - direction = NavigationDirection.First; - break; - case Key.End: - direction = NavigationDirection.Last; - break; - } - - if (direction.HasValue) - { - Move(current, direction.Value, e.Modifiers); - e.Handled = true; - } + var direction = (e.Modifiers & InputModifiers.Shift) == 0 ? + NavigationDirection.Next : NavigationDirection.Previous; + Move(current, direction, e.Modifiers); + e.Handled = true; } } } diff --git a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs b/src/Avalonia.Input/Navigation/DirectionalNavigation.cs deleted file mode 100644 index 75cb3a39e8d..00000000000 --- a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using Avalonia.VisualTree; - -namespace Avalonia.Input.Navigation -{ - /// - /// The implementation for default directional navigation. - /// - public static class DirectionalNavigation - { - /// - /// Gets the next control in the specified navigation direction. - /// - /// The element. - /// The navigation direction. - /// - /// The next element in the specified direction, or null if - /// was the last in the requested direction. - /// - public static IInputElement GetNext( - IInputElement element, - NavigationDirection direction) - { - Contract.Requires(element != null); - Contract.Requires( - direction != NavigationDirection.Next && - direction != NavigationDirection.Previous); - - var container = element.GetVisualParent(); - - if (container != null) - { - var mode = KeyboardNavigation.GetDirectionalNavigation((InputElement)container); - - switch (mode) - { - case KeyboardNavigationMode.Continue: - return GetNextInContainer(element, container, direction) ?? - GetFirstInNextContainer(element, element, direction); - case KeyboardNavigationMode.Cycle: - return GetNextInContainer(element, container, direction) ?? - GetFocusableDescendant(container, direction); - case KeyboardNavigationMode.Contained: - return GetNextInContainer(element, container, direction); - default: - return null; - } - } - else - { - return GetFocusableDescendants(element).FirstOrDefault(); - } - } - - /// - /// Returns a value indicting whether the specified direction is forward. - /// - /// The direction. - /// True if the direction is forward. - private static bool IsForward(NavigationDirection direction) - { - return direction == NavigationDirection.Next || - direction == NavigationDirection.Last || - direction == NavigationDirection.Right || - direction == NavigationDirection.Down; - } - - /// - /// Gets the first or last focusable descendant of the specified element. - /// - /// The element. - /// The direction to search. - /// The element or null if not found.## - private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction) - { - return IsForward(direction) ? - GetFocusableDescendants(container).FirstOrDefault() : - GetFocusableDescendants(container).LastOrDefault(); - } - - /// - /// Gets the focusable descendants of the specified element. - /// - /// The element. - /// The element's focusable descendants. - private static IEnumerable GetFocusableDescendants(IInputElement element) - { - var children = element.GetVisualChildren().OfType(); - - foreach (var child in children) - { - if (child.CanFocus()) - { - yield return child; - } - - if (child.CanFocusDescendants()) - { - foreach (var descendant in GetFocusableDescendants(child)) - { - yield return descendant; - } - } - } - } - - /// - /// Gets the next item that should be focused in the specified container. - /// - /// The starting element/ - /// The container. - /// The direction. - /// The next element, or null if the element is the last. - private static IInputElement GetNextInContainer( - IInputElement element, - IInputElement container, - NavigationDirection direction) - { - if (direction == NavigationDirection.Down) - { - var descendant = GetFocusableDescendants(element).FirstOrDefault(); - - if (descendant != null) - { - return descendant; - } - } - - if (container != null) - { - var navigable = container as INavigableContainer; - - if (navigable != null) - { - while (element != null) - { - element = navigable.GetControl(direction, element); - - if (element != null && element.CanFocus()) - { - break; - } - } - } - else - { - // TODO: Do a spatial search here if the container doesn't implement - // INavigableContainer. - element = null; - } - - if (element != null && direction == NavigationDirection.Up) - { - var descendant = GetFocusableDescendants(element).LastOrDefault(); - - if (descendant != null) - { - return descendant; - } - } - - return element; - } - - return null; - } - - /// - /// Gets the first item that should be focused in the next container. - /// - /// The element being navigated away from. - /// The container. - /// The direction of the search. - /// The first element, or null if there are no more elements. - private static IInputElement GetFirstInNextContainer( - IInputElement element, - IInputElement container, - NavigationDirection direction) - { - var parent = container.GetVisualParent(); - var isForward = IsForward(direction); - IInputElement next = null; - - if (parent != null) - { - if (!isForward && parent.CanFocus()) - { - return parent; - } - - var siblings = parent.GetVisualChildren() - .OfType() - .Where(FocusExtensions.CanFocusDescendants); - var sibling = isForward ? - siblings.SkipWhile(x => x != container).Skip(1).FirstOrDefault() : - siblings.TakeWhile(x => x != container).LastOrDefault(); - - if (sibling != null) - { - if (sibling is ICustomKeyboardNavigation custom) - { - var (handled, customNext) = custom.GetNext(element, direction); - - if (handled) - { - return customNext; - } - } - - if (sibling.CanFocus()) - { - next = sibling; - } - else - { - next = isForward ? - GetFocusableDescendants(sibling).FirstOrDefault() : - GetFocusableDescendants(sibling).LastOrDefault(); - } - } - - if (next == null) - { - next = GetFirstInNextContainer(element, parent, direction); - } - } - else - { - next = isForward ? - GetFocusableDescendants(container).FirstOrDefault() : - GetFocusableDescendants(container).LastOrDefault(); - } - - return next; - } - } -} diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs index a9d5b830735..18db2a91733 100644 --- a/src/Avalonia.Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Input/Navigation/TabNavigation.cs @@ -168,7 +168,7 @@ private static IInputElement GetNextInContainer( { while (element != null) { - element = navigable.GetControl(direction, element); + element = navigable.GetControl(direction, element, false); if (element != null && element.CanFocus()) { diff --git a/src/Avalonia.Input/NavigationDirection.cs b/src/Avalonia.Input/NavigationDirection.cs index fbaa7e74c71..406890b767e 100644 --- a/src/Avalonia.Input/NavigationDirection.cs +++ b/src/Avalonia.Input/NavigationDirection.cs @@ -58,4 +58,74 @@ public enum NavigationDirection /// PageDown, } + + public static class NavigationDirectionExtensions + { + /// + /// Checks whether a represents a tab movement. + /// + /// The direction. + /// + /// True if the direction represents a tab movement ( + /// or ); otherwise false. + /// + public static bool IsTab(this NavigationDirection direction) + { + return direction == NavigationDirection.Next || + direction == NavigationDirection.Previous; + } + + /// + /// Checks whether a represents a directional movement. + /// + /// The direction. + /// + /// True if the direction represents a directional movement (any value except + /// and ); + /// otherwise false. + /// + public static bool IsDirectional(this NavigationDirection direction) + { + return direction > NavigationDirection.Previous || + direction <= NavigationDirection.PageDown; + } + + /// + /// Converts a keypress into a . + /// + /// The key. + /// The keyboard modifiers. + /// + /// A if the keypress represents a navigation keypress. + /// + public static NavigationDirection? ToNavigationDirection( + this Key key, + InputModifiers modifiers = InputModifiers.None) + { + switch (key) + { + case Key.Tab: + return (modifiers & InputModifiers.Shift) != 0 ? + NavigationDirection.Next : NavigationDirection.Previous; + case Key.Up: + return NavigationDirection.Up; + case Key.Down: + return NavigationDirection.Down; + case Key.Left: + return NavigationDirection.Left; + case Key.Right: + return NavigationDirection.Right; + case Key.Home: + return NavigationDirection.First; + case Key.End: + return NavigationDirection.Last; + case Key.PageUp: + return NavigationDirection.PageUp; + case Key.PageDown: + return NavigationDirection.PageDown; + default: + return null; + } + } + } } diff --git a/src/Avalonia.Themes.Default/MenuItem.xaml b/src/Avalonia.Themes.Default/MenuItem.xaml index 53965db016a..8a2ed2a8026 100644 --- a/src/Avalonia.Themes.Default/MenuItem.xaml +++ b/src/Avalonia.Themes.Default/MenuItem.xaml @@ -127,11 +127,6 @@ - - @@ -139,4 +134,4 @@ - \ No newline at end of file + diff --git a/src/Avalonia.Themes.Default/Separator.xaml b/src/Avalonia.Themes.Default/Separator.xaml index 3b3a9e9749d..6312a14df54 100644 --- a/src/Avalonia.Themes.Default/Separator.xaml +++ b/src/Avalonia.Themes.Default/Separator.xaml @@ -1,6 +1,7 @@ - \ No newline at end of file + diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 9ef1e9f0d2f..3cf886ade40 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -11,6 +11,7 @@ using Xunit; using System.Collections.ObjectModel; using Avalonia.UnitTests; +using Avalonia.Input; namespace Avalonia.Controls.UnitTests { @@ -494,6 +495,77 @@ public void DataTemplate_Created_Content_Should_Be_NameScope() Assert.NotNull(NameScope.GetNameScope((TextBlock)container.Child)); } + [Fact] + public void Focuses_Next_Item_On_Key_Down() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var items = new object[] + { + new Button(), + new Button(), + }; + + var target = new ItemsControl + { + Template = GetTemplate(), + Items = items, + }; + + var root = new TestRoot { Child = target }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.Presenter.Panel.Children[0].Focus(); + + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Down, + }); + + Assert.Equal( + target.Presenter.Panel.Children[1], + FocusManager.Instance.Current); + } + } + + [Fact] + public void Does_Not_Focus_Non_Focusable_Item_On_Key_Down() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var items = new object[] + { + new Button(), + new Button { Focusable = false }, + new Button(), + }; + + var target = new ItemsControl + { + Template = GetTemplate(), + Items = items, + }; + + var root = new TestRoot { Child = target }; + + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + target.Presenter.Panel.Children[0].Focus(); + + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Down, + }); + + Assert.Equal( + target.Presenter.Panel.Children[2], + FocusManager.Instance.Current); + } + } + private class Item { public Item(string value) diff --git a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs new file mode 100644 index 00000000000..e17279013d4 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs @@ -0,0 +1,507 @@ +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 }; + + Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(item); + target.PointerLeave(item, e); + + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null); + Assert.False(e.Handled); + } + + [Fact] + public void PointerLeave_Doesnt_Deselect_Sibling() + { + var target = new DefaultMenuInteractionHandler(); + var menu = Mock.Of(); + var parentItem = Mock.Of(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu); + var item = Mock.Of(x => x.Parent == parentItem); + var sibling = Mock.Of(x => x.Parent == parentItem); + var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item }; + + Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(sibling); + target.PointerLeave(item, e); + + Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null, Times.Never); + Assert.False(e.Handled); + } + + [Fact] + public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu() + { + 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 b0ae3df8a28..70a40faed36 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -219,7 +219,7 @@ public void Passes_Navigation_Request_To_ILogicalScrollable_Parent() 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)); } diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs deleted file mode 100644 index b81b724e2ab..00000000000 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs +++ /dev/null @@ -1,799 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using Avalonia.Controls; -using Xunit; - -namespace Avalonia.Input.UnitTests -{ - public class KeyboardNavigationTests_Arrows - { - [Fact] - public void Down_Continue_Returns_Down_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_First_Control_In_Down_Sibling_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_Down_Sibling() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - (next = new Button { Name = "Button4" }), - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_First_Control_In_Down_Uncle_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Returns_Child_Of_Top_Level() - { - Button next; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button1" }), - } - }; - - var result = KeyboardNavigationHandler.GetNext(top, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Continue_Wraps() - { - Button current; - Button next; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (next = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - new Button { Name = "Button3" }, - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - (current = new Button { Name = "Button6" }), - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Cycle_Returns_Down_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Cycle_Wraps_To_First() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - (next = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Contained_Returns_Down_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Equal(next, result); - } - - [Fact] - public void Down_Contained_Stops_At_End() - { - Button current; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Null(result); - } - - [Fact] - public void Down_None_Does_Nothing() - { - Button current; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.None, - Children = - { - new Button { Name = "Button1" }, - (current = new Button { Name = "Button2" }), - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); - - Assert.Null(result); - } - - [Fact] - public void Up_Continue_Returns_Up_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - (next = new Button { Name = "Button2" }), - (current = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Last_Control_In_Up_Sibling_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (current = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Last_Child_Of_Sibling() - { - Button current; - Button next; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - (current = new Button { Name = "Button4" }), - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Last_Control_In_Up_Nephew_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button1" }, - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (current = new Button { Name = "Button4" }), - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Wraps() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - (current = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - new Button { Name = "Button3" }, - } - }, - }, - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - (next = new Button { Name = "Button6" }), - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Continue_Returns_Parent() - { - Button current; - - var top = new Decorator - { - Focusable = true, - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - Child = current = new Button - { - Name = "Button", - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(top, result); - } - - [Fact] - public void Up_Cycle_Returns_Up_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - (next = new Button { Name = "Button1" }), - (current = new Button { Name = "Button2" }), - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Cycle_Wraps_To_Last() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - (current = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - (next = new Button { Name = "Button3" }), - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Contained_Returns_Up_Control_In_Container() - { - Button current; - Button next; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - (next = new Button { Name = "Button1" }), - (current = new Button { Name = "Button2" }), - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Equal(next, result); - } - - [Fact] - public void Up_Contained_Stops_At_Beginning() - { - Button current; - - var top = new StackPanel - { - Children = - { - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - (current = new Button { Name = "Button1" }), - new Button { Name = "Button2" }, - new Button { Name = "Button3" }, - } - }, - new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - new Button { Name = "Button4" }, - new Button { Name = "Button5" }, - new Button { Name = "Button6" }, - } - }, - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Null(result); - } - - [Fact] - public void Up_Contained_Doesnt_Return_Child_Control() - { - Decorator current; - - var top = new StackPanel - { - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained, - Children = - { - (current = new Decorator - { - Focusable = true, - Child = new Button(), - }) - } - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); - - Assert.Null(result); - } - } -} diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs index a090dcd18de..ab0f5e2155f 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs @@ -140,37 +140,6 @@ public void ShiftTab_Should_Custom_Navigate_From_Outside() Assert.Same(next, result); } - [Fact] - public void Right_Should_Custom_Navigate_From_Outside() - { - Button current; - Button next; - var target = new CustomNavigatingStackPanel - { - Children = - { - new Button { Content = "Button 1" }, - new Button { Content = "Button 2" }, - (next = new Button { Content = "Button 3" }), - }, - NextControl = next, - }; - - var root = new StackPanel - { - Children = - { - (current = new Button { Content = "Outside" }), - target, - }, - [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue, - }; - - var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right); - - Assert.Same(next, result); - } - [Fact] public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next() {