using System; using System.Collections.Generic; using System.Linq; using System.Reactive.Linq; using System.Windows.Input; using Avalonia.Controls.Generators; using Avalonia.Controls.Metadata; using Avalonia.Controls.Mixins; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.VisualTree; #nullable enable namespace Avalonia.Controls { /// /// A menu item control. /// [PseudoClasses(":separator", ":icon", ":open", ":pressed", ":selected")] public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable, ICommandSource { /// /// Defines the property. /// public static readonly DirectProperty CommandProperty = Button.CommandProperty.AddOwner( menuItem => menuItem.Command, (menuItem, command) => menuItem.Command = command, enableDataValidation: true); /// /// Defines the property. /// public static readonly StyledProperty HotKeyProperty = HotKeyManager.HotKeyProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty CommandParameterProperty = Button.CommandParameterProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty IconProperty = AvaloniaProperty.Register(nameof(Icon)); /// /// Defines the property. /// public static readonly StyledProperty InputGestureProperty = AvaloniaProperty.Register(nameof(InputGesture)); /// /// Defines the property. /// public static readonly StyledProperty IsSelectedProperty = ListBoxItem.IsSelectedProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty IsSubMenuOpenProperty = AvaloniaProperty.Register(nameof(IsSubMenuOpen)); /// /// Defines the event. /// 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. /// public static readonly RoutedEvent SubmenuOpenedEvent = RoutedEvent.Register(nameof(SubmenuOpened), RoutingStrategies.Bubble); /// /// The default value for the property. /// private static readonly ITemplate DefaultPanel = new FuncTemplate(() => new StackPanel()); private ICommand? _command; private bool _commandCanExecute = true; private Popup? _popup; private KeyGesture _hotkey; private bool _isEmbeddedInMenu; /// /// Initializes static members of the class. /// static MenuItem() { SelectableMixin.Attach(IsSelectedProperty); PressedMixin.Attach(); CommandProperty.Changed.Subscribe(CommandChanged); CommandParameterProperty.Changed.Subscribe(CommandParameterChanged); FocusableProperty.OverrideDefaultValue(true); HeaderProperty.Changed.AddClassHandler((x, e) => x.HeaderChanged(e)); IconProperty.Changed.AddClassHandler((x, e) => x.IconChanged(e)); IsSelectedProperty.Changed.AddClassHandler((x, e) => x.IsSelectedChanged(e)); ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); ClickEvent.AddClassHandler((x, e) => x.OnClick(e)); SubmenuOpenedEvent.AddClassHandler((x, e) => x.OnSubmenuOpened(e)); IsSubMenuOpenProperty.Changed.AddClassHandler((x, e) => x.SubMenuOpenChanged(e)); } public MenuItem() { // HACK: This nasty but it's all WPF's fault. Grid uses an inherited attached // property to store SharedSizeGroup state, except property inheritance is done // down the logical tree. In this case, the control which is setting // Grid.IsSharedSizeScope="True" is not in the logical tree. Instead of fixing // the way Grid stores shared size state, the developers of WPF just created a // binding of the internal state of the visual parent to the menu item. We don't // have much choice but to do the same for now unless we want to refactor Grid, // which I honestly am not brave enough to do right now. Here's the same hack in // the WPF codebase: // // https://github.com/dotnet/wpf/blob/89537909bdf36bc918e88b37751add46a8980bb0/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/MenuItem.cs#L2126-L2141 // // In addition to the hack from WPF, we also make sure to return null when we have // no parent. If we don't do this, inheritance falls back to the logical tree, // causing the shared size scope in the parent MenuItem to be used, breaking // menu layout. var parentSharedSizeScope = this.GetObservable(VisualParentProperty) .SelectMany(x => { var parent = x as Control; return parent?.GetObservable(DefinitionBase.PrivateSharedSizeScopeProperty) ?? Observable.Return(null); }); this.Bind(DefinitionBase.PrivateSharedSizeScopeProperty, parentSharedSizeScope); } /// /// Occurs when a without a submenu is clicked. /// public event EventHandler Click { add { AddHandler(ClickEvent, value); } 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. /// public event EventHandler SubmenuOpened { add { AddHandler(SubmenuOpenedEvent, value); } remove { RemoveHandler(SubmenuOpenedEvent, value); } } /// /// Gets or sets the command associated with the menu item. /// public ICommand? Command { get { return _command; } set { SetAndRaise(CommandProperty, ref _command, value); } } /// /// Gets or sets an associated with this control /// public KeyGesture HotKey { get { return GetValue(HotKeyProperty); } set { SetValue(HotKeyProperty, value); } } /// /// Gets or sets the parameter to pass to the property of a /// . /// public object CommandParameter { get { return GetValue(CommandParameterProperty); } set { SetValue(CommandParameterProperty, value); } } /// /// Gets or sets the icon that appears in a . /// public object Icon { get { return GetValue(IconProperty); } set { SetValue(IconProperty, value); } } /// /// Gets or sets the input gesture that will be displayed in the menu item. /// /// /// Setting this property does not cause the input gesture to be handled by the menu item, /// it simply displays the gesture text in the menu. /// public KeyGesture InputGesture { get { return GetValue(InputGestureProperty); } set { SetValue(InputGestureProperty, value); } } /// /// Gets or sets a value indicating whether the is currently selected. /// public bool IsSelected { get { return GetValue(IsSelectedProperty); } set { SetValue(IsSelectedProperty, value); } } /// /// Gets or sets a value that indicates whether the submenu of the is /// open. /// public bool IsSubMenuOpen { get { return GetValue(IsSubMenuOpenProperty); } set { SetValue(IsSubMenuOpenProperty, value); } } /// /// Gets or sets a value that indicates whether the has a submenu. /// public bool HasSubMenu => !Classes.Contains(":empty"); /// /// Gets a value that indicates whether the is a top-level main menu item. /// public bool IsTopLevel => Parent is Menu; /// bool IMenuItem.IsPointerOverSubMenu => _popup?.IsPointerOverPopup ?? false; /// IMenuElement? IMenuItem.Parent => Parent as IMenuElement; protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute; /// 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); } protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (!_isEmbeddedInMenu) { //Normally the Menu's IMenuInteractionHandler is sending the click events for us //However when the item is not embedded into a menu we need to send them ourselves. RaiseEvent(new RoutedEventArgs(ClickEvent)); } } protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) { if (_hotkey != null) // Control attached again, set Hotkey to create a hotkey manager for this control { HotKey = _hotkey; } base.OnAttachedToLogicalTree(e); if (Command != null) { Command.CanExecuteChanged += CanExecuteChanged; } var parent = Parent; while (parent is MenuItem) { parent = parent.Parent; } _isEmbeddedInMenu = parent is IMenu; } protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) { // This will cause the hotkey manager to dispose the observer and the reference to this control if (HotKey != null) { _hotkey = HotKey; HotKey = null; } base.OnDetachedFromLogicalTree(e); if (Command != null) { Command.CanExecuteChanged -= CanExecuteChanged; } } /// /// Called when the is clicked. /// /// The click event args. protected virtual void OnClick(RoutedEventArgs e) { if (!e.Handled && Command?.CanExecute(CommandParameter) == true) { Command.Execute(CommandParameter); e.Handled = true; } } /// protected override void OnGotFocus(GotFocusEventArgs e) { base.OnGotFocus(e); e.Handled = UpdateSelectionFromEventSource(e.Source, true); } /// protected override void OnKeyDown(KeyEventArgs e) { // Don't handle here: let event bubble up to menu. } /// protected override void OnPointerEnter(PointerEventArgs e) { base.OnPointerEnter(e); var point = e.GetCurrentPoint(null); RaiseEvent(new PointerEventArgs(PointerEnterItemEvent, this, e.Pointer, this.VisualRoot, point.Position, e.Timestamp, point.Properties, e.KeyModifiers)); } /// protected override void OnPointerLeave(PointerEventArgs e) { base.OnPointerLeave(e); var point = e.GetCurrentPoint(null); RaiseEvent(new PointerEventArgs(PointerLeaveItemEvent, this, e.Pointer, this.VisualRoot, point.Position, e.Timestamp, point.Properties, e.KeyModifiers)); } /// /// Called when a submenu is opened on this MenuItem or a child MenuItem. /// /// The event args. protected virtual void OnSubmenuOpened(RoutedEventArgs e) { var menuItem = e.Source as MenuItem; if (menuItem != null && menuItem.Parent == this) { foreach (var child in ((IMenuItem)this).SubItems) { if (child != menuItem && child.IsSubMenuOpen) { child.IsSubMenuOpen = false; } } } } /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { if (_popup != null) { _popup.Opened -= PopupOpened; _popup.Closed -= PopupClosed; _popup.DependencyResolver = null; } _popup = e.NameScope.Find("PART_Popup"); if (_popup != null) { _popup.DependencyResolver = DependencyResolver.Instance; _popup.Opened += PopupOpened; _popup.Closed += PopupClosed; } } protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value) { base.UpdateDataValidation(property, value); if (property == CommandProperty) { if (value.Type == BindingValueType.BindingError) { if (_commandCanExecute) { _commandCanExecute = false; UpdateIsEffectivelyEnabled(); } } } } /// /// Closes all submenus of the menu item. /// private void CloseSubmenus() { foreach (var child in ((IMenuItem)this).SubItems) { child.IsSubMenuOpen = false; } } /// /// Called when the property changes. /// /// The event args. private static void CommandChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is MenuItem menuItem) { if (((ILogical)menuItem).IsAttachedToLogicalTree) { if (e.OldValue is ICommand oldCommand) { oldCommand.CanExecuteChanged -= menuItem.CanExecuteChanged; } if (e.NewValue is ICommand newCommand) { newCommand.CanExecuteChanged += menuItem.CanExecuteChanged; } } menuItem.CanExecuteChanged(menuItem, EventArgs.Empty); } } /// /// Called when the property changes. /// /// The event args. private static void CommandParameterChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is MenuItem menuItem) { menuItem.CanExecuteChanged(menuItem, EventArgs.Empty); } } /// /// Called when the event fires. /// /// The event sender. /// The event args. private void CanExecuteChanged(object sender, EventArgs e) { var canExecute = Command == null || Command.CanExecute(CommandParameter); if (canExecute != _commandCanExecute) { _commandCanExecute = canExecute; UpdateIsEffectivelyEnabled(); } } /// /// Called when the property changes. /// /// The property change event. private void HeaderChanged(AvaloniaPropertyChangedEventArgs e) { if (e.NewValue is string newValue && newValue == "-") { PseudoClasses.Add(":separator"); Focusable = false; } else if (e.OldValue is string oldValue && oldValue == "-") { PseudoClasses.Remove(":separator"); Focusable = true; } } /// /// Called when the property changes. /// /// The property change event. private void IconChanged(AvaloniaPropertyChangedEventArgs e) { var oldValue = e.OldValue as ILogical; var newValue = e.NewValue as ILogical; if (oldValue != null) { LogicalChildren.Remove(oldValue); PseudoClasses.Remove(":icon"); } if (newValue != null) { LogicalChildren.Add(newValue); PseudoClasses.Add(":icon"); } } /// /// Called when the property changes. /// /// The property change event. private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e) { if ((bool)e.NewValue!) { Focus(); } } /// /// Called when the property changes. /// /// The property change event. private void SubMenuOpenChanged(AvaloniaPropertyChangedEventArgs e) { var value = (bool)e.NewValue!; if (value) { RaiseEvent(new RoutedEventArgs(SubmenuOpenedEvent)); IsSelected = true; PseudoClasses.Add(":open"); } else { CloseSubmenus(); SelectedIndex = -1; PseudoClasses.Remove(":open"); } } /// /// Called when the submenu's is opened. /// /// The event sender. /// The event args. private void PopupOpened(object sender, EventArgs e) { var selected = SelectedIndex; if (selected != -1) { var container = ItemContainerGenerator.ContainerFromIndex(selected); container?.Focus(); } } /// /// Called when the submenu's is closed. /// /// The event sender. /// The event args. private void PopupClosed(object sender, EventArgs e) { SelectedItem = null; } void ICommandSource.CanExecuteChanged(object sender, EventArgs e) => this.CanExecuteChanged(sender, e); /// /// A dependency resolver which returns a . /// private class DependencyResolver : IAvaloniaDependencyResolver { /// /// Gets the default instance of . /// public static readonly DependencyResolver Instance = new DependencyResolver(); /// /// Gets a service of the specified type. /// /// The service type. /// A service of the requested type. public object GetService(Type serviceType) { if (serviceType == typeof(IAccessKeyHandler)) { return new MenuItemAccessKeyHandler(); } else { return AvaloniaLocator.Current.GetService(serviceType); } } } } }