Browse Source

Merge pull request #2332 from AvaloniaUI/fixes/2325-contextmenu

Fix context menu.
pull/2341/head
Steven Kirk 8 years ago
committed by GitHub
parent
commit
9e6b878bce
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 22
      samples/ControlCatalog/Pages/ContextMenuPage.xaml
  2. 2
      samples/ControlCatalog/Pages/ContextMenuPage.xaml.cs
  3. 94
      samples/ControlCatalog/Pages/MenuPage.xaml.cs
  4. 73
      samples/ControlCatalog/ViewModels/ContextMenuPageViewModel.cs
  5. 13
      samples/ControlCatalog/ViewModels/MenuItemViewModel.cs
  6. 89
      samples/ControlCatalog/ViewModels/MenuPageViewModel.cs
  7. 96
      src/Avalonia.Controls/ContextMenu.cs
  8. 151
      src/Avalonia.Controls/Menu.cs
  9. 192
      src/Avalonia.Controls/MenuBase.cs
  10. 80
      src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs
  11. 2
      src/Avalonia.Input/InputElement.cs
  12. 22
      src/Avalonia.Themes.Default/ContextMenu.xaml
  13. 71
      tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs

22
samples/ControlCatalog/Pages/ContextMenuPage.xaml

@ -10,7 +10,8 @@
HorizontalAlignment="Center"
Spacing="16">
<Border Background="{DynamicResource ThemeAccentBrush}"
Padding="48,48,48,48">
Margin="16"
Padding="48,48,48,48">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Standard _Menu Item"/>
@ -31,7 +32,24 @@
</MenuItem>
</ContextMenu>
</Border.ContextMenu>
<TextBlock Text="Right Click Here"/>
<TextBlock Text="Defined in XAML"/>
</Border>
<Border Background="{DynamicResource ThemeAccentBrush}"
Margin="16"
Padding="48,48,48,48">
<Border.ContextMenu>
<ContextMenu Items="{Binding MenuItems}">
<ContextMenu.Styles>
<Style Selector="MenuItem">
<Setter Property="Header" Value="{Binding Header}"/>
<Setter Property="Items" Value="{Binding Items}"/>
<Setter Property="Command" Value="{Binding Command}"/>
<Setter Property="CommandParameter" Value="{Binding CommandParameter}"/>
</Style>
</ContextMenu.Styles>
</ContextMenu>
</Border.ContextMenu>
<TextBlock Text="Dynamically Generated"/>
</Border>
</StackPanel>
</StackPanel>

2
samples/ControlCatalog/Pages/ContextMenuPage.xaml.cs

@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using ControlCatalog.ViewModels;
namespace ControlCatalog.Pages
{
@ -8,6 +9,7 @@ namespace ControlCatalog.Pages
public ContextMenuPage()
{
this.InitializeComponent();
DataContext = new ContextMenuPageViewModel();
}
private void InitializeComponent()

94
samples/ControlCatalog/Pages/MenuPage.xaml.cs

@ -4,6 +4,7 @@ using System.Threading.Tasks;
using System.Windows.Input;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using ControlCatalog.ViewModels;
using ReactiveUI;
namespace ControlCatalog.Pages
@ -13,51 +14,7 @@ namespace ControlCatalog.Pages
public MenuPage()
{
this.InitializeComponent();
var vm = new MenuPageViewModel();
vm.MenuItems = new[]
{
new MenuItemViewModel
{
Header = "_File",
Items = new[]
{
new MenuItemViewModel { Header = "_Open...", Command = vm.OpenCommand },
new MenuItemViewModel { Header = "Save", Command = vm.SaveCommand },
new MenuItemViewModel { Header = "-" },
new MenuItemViewModel
{
Header = "Recent",
Items = new[]
{
new MenuItemViewModel
{
Header = "File1.txt",
Command = vm.OpenRecentCommand,
CommandParameter = @"c:\foo\File1.txt"
},
new MenuItemViewModel
{
Header = "File2.txt",
Command = vm.OpenRecentCommand,
CommandParameter = @"c:\foo\File2.txt"
},
}
},
}
},
new MenuItemViewModel
{
Header = "_Edit",
Items = new[]
{
new MenuItemViewModel { Header = "_Copy" },
new MenuItemViewModel { Header = "_Paste" },
}
}
};
DataContext = vm;
DataContext = new MenuPageViewModel();
}
private void InitializeComponent()
@ -65,51 +22,4 @@ namespace ControlCatalog.Pages
AvaloniaXamlLoader.Load(this);
}
}
public class MenuPageViewModel
{
public MenuPageViewModel()
{
OpenCommand = ReactiveCommand.CreateFromTask(Open);
SaveCommand = ReactiveCommand.Create(Save);
OpenRecentCommand = ReactiveCommand.Create<string>(OpenRecent);
}
public IReadOnlyList<MenuItemViewModel> MenuItems { get; set; }
public ReactiveCommand<Unit, Unit> OpenCommand { get; }
public ReactiveCommand<Unit, Unit> SaveCommand { get; }
public ReactiveCommand<string, Unit> OpenRecentCommand { get; }
public async Task Open()
{
var dialog = new OpenFileDialog();
var result = await dialog.ShowAsync(App.Current.MainWindow);
if (result != null)
{
foreach (var path in result)
{
System.Diagnostics.Debug.WriteLine($"Opened: {path}");
}
}
}
public void Save()
{
System.Diagnostics.Debug.WriteLine("Save");
}
public void OpenRecent(string path)
{
System.Diagnostics.Debug.WriteLine($"Open recent: {path}");
}
}
public class MenuItemViewModel
{
public string Header { get; set; }
public ICommand Command { get; set; }
public object CommandParameter { get; set; }
public IList<MenuItemViewModel> Items { get; set; }
}
}

73
samples/ControlCatalog/ViewModels/ContextMenuPageViewModel.cs

@ -0,0 +1,73 @@
using System.Collections.Generic;
using System.Reactive;
using System.Threading.Tasks;
using Avalonia.Controls;
using ReactiveUI;
namespace ControlCatalog.ViewModels
{
public class ContextMenuPageViewModel
{
public ContextMenuPageViewModel()
{
OpenCommand = ReactiveCommand.CreateFromTask(Open);
SaveCommand = ReactiveCommand.Create(Save);
OpenRecentCommand = ReactiveCommand.Create<string>(OpenRecent);
MenuItems = new[]
{
new MenuItemViewModel { Header = "_Open...", Command = OpenCommand },
new MenuItemViewModel { Header = "Save", Command = SaveCommand },
new MenuItemViewModel { Header = "-" },
new MenuItemViewModel
{
Header = "Recent",
Items = new[]
{
new MenuItemViewModel
{
Header = "File1.txt",
Command = OpenRecentCommand,
CommandParameter = @"c:\foo\File1.txt"
},
new MenuItemViewModel
{
Header = "File2.txt",
Command = OpenRecentCommand,
CommandParameter = @"c:\foo\File2.txt"
},
}
},
};
}
public IReadOnlyList<MenuItemViewModel> MenuItems { get; set; }
public ReactiveCommand<Unit, Unit> OpenCommand { get; }
public ReactiveCommand<Unit, Unit> SaveCommand { get; }
public ReactiveCommand<string, Unit> OpenRecentCommand { get; }
public async Task Open()
{
var dialog = new OpenFileDialog();
var result = await dialog.ShowAsync(App.Current.MainWindow);
if (result != null)
{
foreach (var path in result)
{
System.Diagnostics.Debug.WriteLine($"Opened: {path}");
}
}
}
public void Save()
{
System.Diagnostics.Debug.WriteLine("Save");
}
public void OpenRecent(string path)
{
System.Diagnostics.Debug.WriteLine($"Open recent: {path}");
}
}
}

13
samples/ControlCatalog/ViewModels/MenuItemViewModel.cs

@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Windows.Input;
namespace ControlCatalog.ViewModels
{
public class MenuItemViewModel
{
public string Header { get; set; }
public ICommand Command { get; set; }
public object CommandParameter { get; set; }
public IList<MenuItemViewModel> Items { get; set; }
}
}

89
samples/ControlCatalog/ViewModels/MenuPageViewModel.cs

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Reactive;
using System.Threading.Tasks;
using Avalonia.Controls;
using ReactiveUI;
namespace ControlCatalog.ViewModels
{
public class MenuPageViewModel
{
public MenuPageViewModel()
{
OpenCommand = ReactiveCommand.CreateFromTask(Open);
SaveCommand = ReactiveCommand.Create(Save);
OpenRecentCommand = ReactiveCommand.Create<string>(OpenRecent);
MenuItems = new[]
{
new MenuItemViewModel
{
Header = "_File",
Items = new[]
{
new MenuItemViewModel { Header = "_Open...", Command = OpenCommand },
new MenuItemViewModel { Header = "Save", Command = SaveCommand },
new MenuItemViewModel { Header = "-" },
new MenuItemViewModel
{
Header = "Recent",
Items = new[]
{
new MenuItemViewModel
{
Header = "File1.txt",
Command = OpenRecentCommand,
CommandParameter = @"c:\foo\File1.txt"
},
new MenuItemViewModel
{
Header = "File2.txt",
Command = OpenRecentCommand,
CommandParameter = @"c:\foo\File2.txt"
},
}
},
}
},
new MenuItemViewModel
{
Header = "_Edit",
Items = new[]
{
new MenuItemViewModel { Header = "_Copy" },
new MenuItemViewModel { Header = "_Paste" },
}
}
};
}
public IReadOnlyList<MenuItemViewModel> MenuItems { get; set; }
public ReactiveCommand<Unit, Unit> OpenCommand { get; }
public ReactiveCommand<Unit, Unit> SaveCommand { get; }
public ReactiveCommand<string, Unit> OpenRecentCommand { get; }
public async Task Open()
{
var dialog = new OpenFileDialog();
var result = await dialog.ShowAsync(App.Current.MainWindow);
if (result != null)
{
foreach (var path in result)
{
System.Diagnostics.Debug.WriteLine($"Opened: {path}");
}
}
}
public void Save()
{
System.Diagnostics.Debug.WriteLine("Save");
}
public void OpenRecent(string path)
{
System.Diagnostics.Debug.WriteLine($"Open recent: {path}");
}
}
}

96
src/Avalonia.Controls/ContextMenu.cs

@ -1,34 +1,31 @@
using System;
using System.Reactive.Linq;
using System.Linq;
using System.ComponentModel;
using System.Linq;
using System.Reactive.Linq;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Platform;
using System.Collections.Generic;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.LogicalTree;
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls
{
public class ContextMenu : SelectingItemsControl, IMenu
/// <summary>
/// A control context menu.
/// </summary>
public class ContextMenu : MenuBase
{
private readonly IMenuInteractionHandler _interaction;
private bool _isOpen;
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Vertical });
private Popup _popup;
/// <summary>
/// Defines the <see cref="IsOpen"/> property.
/// </summary>
public static readonly DirectProperty<ContextMenu, bool> IsOpenProperty =
AvaloniaProperty.RegisterDirect<ContextMenu, bool>(nameof(IsOpen), o => o.IsOpen);
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenu"/> class.
/// </summary>
public ContextMenu()
: this(new DefaultMenuInteractionHandler(true))
{
_interaction = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ??
new DefaultMenuInteractionHandler();
}
/// <summary>
@ -36,10 +33,8 @@ namespace Avalonia.Controls
/// </summary>
/// <param name="interactionHandler">The menu interaction handler.</param>
public ContextMenu(IMenuInteractionHandler interactionHandler)
: base(interactionHandler)
{
Contract.Requires<ArgumentNullException>(interactionHandler != null);
_interaction = interactionHandler;
}
/// <summary>
@ -47,44 +42,10 @@ namespace Avalonia.Controls
/// </summary>
static ContextMenu()
{
ItemsPanelProperty.OverrideDefaultValue(typeof(ContextMenu), DefaultPanel);
ContextMenuProperty.Changed.Subscribe(ContextMenuChanged);
}
/// <summary>
/// Gets a value indicating whether the popup is open
/// </summary>
public bool IsOpen => _isOpen;
/// <inheritdoc/>
IMenuInteractionHandler IMenu.InteractionHandler => _interaction;
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{
get
{
var index = SelectedIndex;
return (index != -1) ?
(IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null;
}
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary>
/// Occurs when the value of the
/// <see cref="P:Avalonia.Controls.ContextMenu.IsOpen" />
@ -121,7 +82,7 @@ namespace Avalonia.Controls
/// <summary>
/// Opens the menu.
/// </summary>
public void Open() => Open(null);
public override void Open() => Open(null);
/// <summary>
/// Opens a context menu on the specified control.
@ -139,21 +100,20 @@ namespace Avalonia.Controls
ObeyScreenEdges = true
};
_popup.Opened += PopupOpened;
_popup.Closed += PopupClosed;
_interaction.Attach(this);
}
((ISetLogicalParent)_popup).SetParent(control);
_popup.Child = this;
_popup.IsOpen = true;
SetAndRaise(IsOpenProperty, ref _isOpen, true);
IsOpen = true;
}
/// <summary>
/// Closes the menu.
/// </summary>
public void Close()
public override void Close()
{
if (_popup != null && _popup.IsVisible)
{
@ -161,8 +121,17 @@ namespace Avalonia.Controls
}
SelectedIndex = -1;
IsOpen = false;
}
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new MenuItemContainerGenerator(this);
}
SetAndRaise(IsOpenProperty, ref _isOpen, false);
private void PopupOpened(object sender, EventArgs e)
{
Focus();
}
private void PopupClosed(object sender, EventArgs e)
@ -176,7 +145,7 @@ namespace Avalonia.Controls
i.IsSubMenuOpen = false;
}
contextMenu._isOpen = false;
contextMenu.IsOpen = false;
contextMenu.SelectedIndex = -1;
}
}
@ -186,7 +155,7 @@ namespace Avalonia.Controls
var control = (Control)sender;
var contextMenu = control.ContextMenu;
if (control.ContextMenu._isOpen)
if (control.ContextMenu.IsOpen)
{
if (contextMenu.CancelClosing())
return;
@ -218,10 +187,5 @@ namespace Avalonia.Controls
ContextMenuOpening?.Invoke(this, eventArgs);
return eventArgs.Cancel;
}
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap)
{
throw new NotImplementedException();
}
}
}

151
src/Avalonia.Controls/Menu.cs

@ -1,56 +1,26 @@
// 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.Controls.Generators;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
namespace Avalonia.Controls
{
/// <summary>
/// A top-level menu control.
/// </summary>
public class Menu : SelectingItemsControl, IFocusScope, IMainMenu, IMenu
public class Menu : MenuBase, IMainMenu
{
/// <summary>
/// Defines the <see cref="IsOpen"/> property.
/// </summary>
public static readonly DirectProperty<Menu, bool> IsOpenProperty =
AvaloniaProperty.RegisterDirect<Menu, bool>(
nameof(IsOpen),
o => o.IsOpen);
/// <summary>
/// Defines the <see cref="MenuOpened"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuOpenedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuOpened), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="MenuClosed"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuClosedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuClosed), RoutingStrategies.Bubble);
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
private readonly IMenuInteractionHandler _interaction;
private bool _isOpen;
/// <summary>
/// Initializes a new instance of the <see cref="Menu"/> class.
/// </summary>
public Menu()
{
_interaction = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ??
new DefaultMenuInteractionHandler();
}
/// <summary>
@ -58,82 +28,17 @@ namespace Avalonia.Controls
/// </summary>
/// <param name="interactionHandler">The menu interaction handler.</param>
public Menu(IMenuInteractionHandler interactionHandler)
: base(interactionHandler)
{
Contract.Requires<ArgumentNullException>(interactionHandler != null);
_interaction = interactionHandler;
}
/// <summary>
/// Initializes static members of the <see cref="Menu"/> class.
/// </summary>
static Menu()
{
ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel);
MenuItem.SubmenuOpenedEvent.AddClassHandler<Menu>(x => x.OnSubmenuOpened);
}
/// <summary>
/// Gets a value indicating whether the menu is open.
/// </summary>
public bool IsOpen
{
get { return _isOpen; }
private set { SetAndRaise(IsOpenProperty, ref _isOpen, value); }
}
/// <inheritdoc/>
IMenuInteractionHandler IMenu.InteractionHandler => _interaction;
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{
get
{
var index = SelectedIndex;
return (index != -1) ?
(IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null;
}
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary>
/// Occurs when a <see cref="Menu"/> is opened.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuOpened
{
add { AddHandler(MenuOpenedEvent, value); }
remove { RemoveHandler(MenuOpenedEvent, value); }
}
/// <summary>
/// Occurs when a <see cref="Menu"/> is closed.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuClosed
{
add { AddHandler(MenuClosedEvent, value); }
remove { RemoveHandler(MenuClosedEvent, value); }
}
/// <summary>
/// Closes the menu.
/// </summary>
public void Close()
public override void Close()
{
if (IsOpen)
{
@ -153,10 +58,8 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Opens the menu in response to the Alt/F10 key.
/// </summary>
public void Open()
/// <inheritdoc/>
public override void Open()
{
if (!IsOpen)
{
@ -170,15 +73,6 @@ namespace Avalonia.Controls
}
}
/// <inheritdoc/>
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
}
/// <inheritdoc/>
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
@ -190,41 +84,6 @@ namespace Avalonia.Controls
{
inputRoot.AccessKeyHandler.MainMenu = this;
}
_interaction.Attach(this);
}
/// <inheritdoc/>
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_interaction.Detach(this);
}
/// <inheritdoc/>
protected override void OnKeyDown(KeyEventArgs e)
{
// Don't handle here: let the interaction handler handle it.
}
/// <summary>
/// Called when a submenu opens somewhere in the menu.
/// </summary>
/// <param name="e">The event args.</param>
protected virtual void OnSubmenuOpened(RoutedEventArgs e)
{
if (e.Source is MenuItem menuItem && menuItem.Parent == this)
{
foreach (var child in this.GetLogicalChildren().OfType<MenuItem>())
{
if (child != menuItem && child.IsSubMenuOpen)
{
child.IsSubMenuOpen = false;
}
}
}
IsOpen = true;
}
}
}

192
src/Avalonia.Controls/MenuBase.cs

@ -0,0 +1,192 @@
// 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.Controls.Generators;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
namespace Avalonia.Controls
{
/// <summary>
/// Base class for menu controls.
/// </summary>
public abstract class MenuBase : SelectingItemsControl, IFocusScope, IMenu
{
/// <summary>
/// Defines the <see cref="IsOpen"/> property.
/// </summary>
public static readonly DirectProperty<Menu, bool> IsOpenProperty =
AvaloniaProperty.RegisterDirect<Menu, bool>(
nameof(IsOpen),
o => o.IsOpen);
/// <summary>
/// Defines the <see cref="MenuOpened"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuOpenedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuOpened), RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="MenuClosed"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MenuClosedEvent =
RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuClosed), RoutingStrategies.Bubble);
private bool _isOpen;
/// <summary>
/// Initializes a new instance of the <see cref="MenuBase"/> class.
/// </summary>
public MenuBase()
{
InteractionHandler = new DefaultMenuInteractionHandler(false);
}
/// <summary>
/// Initializes a new instance of the <see cref="MenuBase"/> class.
/// </summary>
/// <param name="interactionHandler">The menu interaction handler.</param>
public MenuBase(IMenuInteractionHandler interactionHandler)
{
Contract.Requires<ArgumentNullException>(interactionHandler != null);
InteractionHandler = interactionHandler;
}
/// <summary>
/// Initializes static members of the <see cref="MenuBase"/> class.
/// </summary>
static MenuBase()
{
MenuItem.SubmenuOpenedEvent.AddClassHandler<MenuBase>(x => x.OnSubmenuOpened);
}
/// <summary>
/// Gets a value indicating whether the menu is open.
/// </summary>
public bool IsOpen
{
get { return _isOpen; }
protected set { SetAndRaise(IsOpenProperty, ref _isOpen, value); }
}
/// <inheritdoc/>
IMenuInteractionHandler IMenu.InteractionHandler => InteractionHandler;
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
{
get
{
var index = SelectedIndex;
return (index != -1) ?
(IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
null;
}
set
{
SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
}
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems
{
get
{
return ItemContainerGenerator.Containers
.Select(x => x.ContainerControl)
.OfType<IMenuItem>();
}
}
/// <summary>
/// Gets the interaction handler for the menu.
/// </summary>
protected IMenuInteractionHandler InteractionHandler { get; }
/// <summary>
/// Occurs when a <see cref="Menu"/> is opened.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuOpened
{
add { AddHandler(MenuOpenedEvent, value); }
remove { RemoveHandler(MenuOpenedEvent, value); }
}
/// <summary>
/// Occurs when a <see cref="Menu"/> is closed.
/// </summary>
public event EventHandler<RoutedEventArgs> MenuClosed
{
add { AddHandler(MenuClosedEvent, value); }
remove { RemoveHandler(MenuClosedEvent, value); }
}
/// <summary>
/// Closes the menu.
/// </summary>
public abstract void Close();
/// <summary>
/// Opens the menu.
/// </summary>
public abstract void Open();
/// <inheritdoc/>
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
/// <inheritdoc/>
protected override IItemContainerGenerator CreateItemContainerGenerator()
{
return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
}
/// <inheritdoc/>
protected override void OnKeyDown(KeyEventArgs e)
{
// Don't handle here: let the interaction handler handle it.
}
/// <inheritdoc/>
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
InteractionHandler.Attach(this);
}
/// <inheritdoc/>
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
InteractionHandler.Detach(this);
}
/// <summary>
/// Called when a submenu opens somewhere in the menu.
/// </summary>
/// <param name="e">The event args.</param>
protected virtual void OnSubmenuOpened(RoutedEventArgs e)
{
if (e.Source is MenuItem menuItem && menuItem.Parent == this)
{
foreach (var child in this.GetLogicalChildren().OfType<MenuItem>())
{
if (child != menuItem && child.IsSubMenuOpen)
{
child.IsSubMenuOpen = false;
}
}
}
IsOpen = true;
}
}
}

80
src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs

@ -13,18 +13,21 @@ namespace Avalonia.Controls.Platform
/// </summary>
public class DefaultMenuInteractionHandler : IMenuInteractionHandler
{
private readonly bool _isContextMenu;
private IDisposable _inputManagerSubscription;
private IRenderRoot _root;
public DefaultMenuInteractionHandler()
: this(Input.InputManager.Instance, DefaultDelayRun)
public DefaultMenuInteractionHandler(bool isContextMenu)
: this(isContextMenu, Input.InputManager.Instance, DefaultDelayRun)
{
}
public DefaultMenuInteractionHandler(
bool isContextMenu,
IInputManager inputManager,
Action<Action, TimeSpan> delayRun)
{
_isContextMenu = isContextMenu;
InputManager = inputManager;
DelayRun = delayRun;
}
@ -59,7 +62,7 @@ namespace Avalonia.Controls.Platform
window.Deactivated += WindowDeactivated;
}
_inputManagerSubscription = InputManager.Process.Subscribe(RawInput);
_inputManagerSubscription = InputManager?.Process.Subscribe(RawInput);
}
public virtual void Detach(IMenu menu)
@ -125,23 +128,16 @@ namespace Avalonia.Controls.Platform
protected internal virtual void KeyDown(object sender, KeyEventArgs e)
{
var item = GetMenuItem(e.Source as IControl);
if (item != null)
{
KeyDown(item, e);
}
KeyDown(GetMenuItem(e.Source as IControl), e);
}
protected internal virtual void KeyDown(IMenuItem item, KeyEventArgs e)
{
Contract.Requires<ArgumentNullException>(item != null);
switch (e.Key)
{
case Key.Up:
case Key.Down:
if (item.IsTopLevel)
if (item?.IsTopLevel == true)
{
if (item.HasSubMenu && !item.IsSubMenuOpen)
{
@ -156,7 +152,7 @@ namespace Avalonia.Controls.Platform
break;
case Key.Left:
if (item.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen)
if (item?.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen)
{
parent.Close();
parent.Focus();
@ -169,7 +165,7 @@ namespace Avalonia.Controls.Platform
break;
case Key.Right:
if (!item.IsTopLevel && item.HasSubMenu)
if (item != null && !item.IsTopLevel && item.HasSubMenu)
{
Open(item, true);
e.Handled = true;
@ -181,47 +177,65 @@ namespace Avalonia.Controls.Platform
break;
case Key.Enter:
if (!item.HasSubMenu)
if (item != null)
{
Click(item);
}
else
{
Open(item, true);
}
if (!item.HasSubMenu)
{
Click(item);
}
else
{
Open(item, true);
}
e.Handled = true;
e.Handled = true;
}
break;
case Key.Escape:
if (item.Parent != null)
if (item?.Parent != null)
{
item.Parent.Close();
item.Parent.Focus();
e.Handled = true;
}
else
{
Menu.Close();
}
e.Handled = true;
break;
default:
var direction = e.Key.ToNavigationDirection();
if (direction.HasValue && item.Parent?.MoveSelection(direction.Value, true) == true)
if (direction.HasValue)
{
// If the the parent is an IMenu which successfully moved its selection,
// and the current menu is open then close the current menu and open the
// new menu.
if (item.IsSubMenuOpen && item.Parent is IMenu)
if (item == null && _isContextMenu)
{
item.Close();
Open(item.Parent.SelectedItem, true);
if (Menu.MoveSelection(direction.Value, true) == true)
{
e.Handled = true;
}
}
else if (item.Parent?.MoveSelection(direction.Value, true) == true)
{
// If the the parent is an IMenu which successfully moved its selection,
// and the current menu is open then close the current menu and open the
// new menu.
if (item.IsSubMenuOpen && item.Parent is IMenu)
{
item.Close();
Open(item.Parent.SelectedItem, true);
}
e.Handled = true;
}
e.Handled = true;
}
break;
}
if (!e.Handled && item.Parent is IMenuItem parentItem)
if (!e.Handled && item?.Parent is IMenuItem parentItem)
{
KeyDown(parentItem, e);
}

2
src/Avalonia.Input/InputElement.cs

@ -375,7 +375,7 @@ namespace Avalonia.Input
/// </summary>
public void Focus()
{
FocusManager.Instance.Focus(this);
FocusManager.Instance?.Focus(this);
}
/// <inheritdoc/>

22
src/Avalonia.Themes.Default/ContextMenu.xaml

@ -10,12 +10,22 @@
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ItemsPresenter Name="PART_ItemsPresenter"
Items="{TemplateBinding Items}"
ItemsPanel="{TemplateBinding ItemsPanel}"
ItemTemplate="{TemplateBinding ItemTemplate}"
KeyboardNavigation.TabNavigation="Continue"/>
<ScrollViewer>
<Panel>
<ItemsPresenter Name="PART_ItemsPresenter"
Items="{TemplateBinding Items}"
ItemsPanel="{TemplateBinding ItemsPanel}"
ItemTemplate="{TemplateBinding ItemTemplate}"
KeyboardNavigation.TabNavigation="Continue"/>
<Rectangle Name="iconSeparator"
Fill="{DynamicResource ThemeControlMidBrush}"
HorizontalAlignment="Left"
IsHitTestVisible="False"
Margin="29,2,0,2"
Width="1"/>
</Panel>
</ScrollViewer>
</Border>
</ControlTemplate>
</Setter>
</Style>
</Style>

71
tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs

@ -13,7 +13,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Up_Opens_MenuItem_With_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Up, Source = item };
@ -27,7 +27,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Down_Opens_MenuItem_With_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Down, Source = item };
@ -41,7 +41,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Right_Selects_Next_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Right, true) == true);
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Right, Source = item };
@ -55,7 +55,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Left_Selects_Previous_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Left, true) == true);
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Left, Source = item };
@ -69,7 +69,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Enter_On_Item_With_No_SubMenu_Causes_Click()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Enter, Source = item };
@ -84,7 +84,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Enter, Source = item };
@ -99,7 +99,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Escape_Closes_Parent_Menu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
var e = new KeyEventArgs { Key = Key.Escape, Source = item };
@ -113,7 +113,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerEnter_Opens_Item_When_Old_Item_Is_Open()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = new Mock<IMenu>();
var item = Mock.Of<IMenuItem>(x =>
x.IsSubMenuOpen == true &&
@ -141,7 +141,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerLeave_Deselects_Item_When_Menu_Not_Open()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = new Mock<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu.Object);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
@ -156,7 +156,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerLeave_Doesnt_Deselect_Item_When_Menu_Open()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = new Mock<IMenu>();
var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu.Object);
var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
@ -175,7 +175,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Up_Selects_Previous_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Up, Source = item };
@ -189,7 +189,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Down_Selects_Next_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Down, Source = item };
@ -203,7 +203,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Left_Closes_Parent_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var parentItem = Mock.Of<IMenuItem>(x => x.HasSubMenu == true && x.IsSubMenuOpen == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Left, Source = item };
@ -218,7 +218,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Right_With_SubMenu_Items_Opens_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Right, Source = item };
@ -233,7 +233,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Right_On_TopLevel_Child_Navigates_TopLevel_Selection()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = new Mock<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x =>
x.IsSubMenuOpen == true &&
@ -263,7 +263,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Enter_On_Item_With_No_SubMenu_Causes_Click()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@ -279,7 +279,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
var e = new KeyEventArgs { Key = Key.Enter, Source = item };
@ -294,7 +294,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void Escape_Closes_Parent_MenuItem()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
var e = new KeyEventArgs { Key = Key.Escape, Source = item };
@ -309,7 +309,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerEnter_Selects_Item()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@ -325,7 +325,7 @@ namespace Avalonia.Controls.UnitTests.Platform
public void PointerEnter_Opens_Submenu_After_Delay()
{
var timer = new TestTimer();
var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
var target = new DefaultMenuInteractionHandler(false, null, timer.RunOnce);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
@ -344,7 +344,7 @@ namespace Avalonia.Controls.UnitTests.Platform
public void PointerEnter_Closes_Sibling_Submenu_After_Delay()
{
var timer = new TestTimer();
var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
var target = new DefaultMenuInteractionHandler(false, null, timer.RunOnce);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@ -365,7 +365,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerLeave_Deselects_Item()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@ -381,7 +381,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerLeave_Doesnt_Deselect_Sibling()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@ -398,7 +398,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true && x.IsPointerOverSubMenu == true);
@ -413,7 +413,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerReleased_On_Item_With_No_SubMenu_Causes_Click()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@ -430,7 +430,7 @@ namespace Avalonia.Controls.UnitTests.Platform
public void Selection_Is_Correct_When_Pointer_Temporarily_Exits_Item_To_Select_SubItem()
{
var timer = new TestTimer();
var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
var target = new DefaultMenuInteractionHandler(false, null, timer.RunOnce);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
@ -467,7 +467,7 @@ namespace Avalonia.Controls.UnitTests.Platform
[Fact]
public void PointerPressed_On_Item_With_SubMenu_Causes_Opens_Submenu()
{
var target = new DefaultMenuInteractionHandler();
var target = new DefaultMenuInteractionHandler(false);
var menu = Mock.Of<IMenu>();
var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
@ -481,6 +481,23 @@ namespace Avalonia.Controls.UnitTests.Platform
}
}
public class ContextMenu
{
[Fact]
public void Down_Selects_Selects_First_MenuItem_When_No_Selection()
{
var target = new DefaultMenuInteractionHandler(true);
var contextMenu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Down, true) == true);
var e = new KeyEventArgs { Key = Key.Down, Source = contextMenu };
target.Attach(contextMenu);
target.KeyDown(contextMenu, e);
Mock.Get(contextMenu).Verify(x => x.MoveSelection(NavigationDirection.Down, true));
Assert.True(e.Handled);
}
}
private class TestTimer
{
private Action _action;

Loading…
Cancel
Save