using System; using System.Collections.Generic; using System.Linq; using System.Windows.Input; using Avalonia.Automation; using Avalonia.Automation.Peers; using Avalonia.Controls.Metadata; using Avalonia.Controls.Mixins; using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.Reactive; namespace Avalonia.Controls { /// /// A menu item control. /// [TemplatePart("PART_Popup", typeof(Popup))] [PseudoClasses(":separator", ":radio", ":toggle", ":checked", ":icon", ":open", ":pressed", ":selected")] public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable, ICommandSource, IClickableControl, IRadioButton { private EventHandler? _canExecuteChangeHandler = default; private EventHandler CanExecuteChangedHandler => _canExecuteChangeHandler ??= new(CanExecuteChanged); /// /// Defines the property. /// public static readonly StyledProperty CommandProperty = Button.CommandProperty.AddOwner(new(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 IsSubMenuOpenProperty = AvaloniaProperty.Register(nameof(IsSubMenuOpen)); /// /// Defines the property. /// public static readonly StyledProperty StaysOpenOnClickProperty = AvaloniaProperty.Register(nameof(StaysOpenOnClick)); /// /// Defines the property. /// public static readonly StyledProperty ToggleTypeProperty = AvaloniaProperty.Register(nameof(ToggleType)); /// /// Defines the property. /// public static readonly StyledProperty IsCheckedProperty = AvaloniaProperty.Register(nameof(IsChecked)); /// /// Defines the property. /// public static readonly StyledProperty GroupNameProperty = RadioButton.GroupNameProperty.AddOwner(); /// /// Defines the event. /// public static readonly RoutedEvent ClickEvent = RoutedEvent.Register( nameof(Click), RoutingStrategies.Bubble); /// /// Defines the event. /// public static readonly RoutedEvent PointerEnteredItemEvent = RoutedEvent.Register( nameof(PointerEnteredItem), RoutingStrategies.Bubble); /// /// Defines the event. /// public static readonly RoutedEvent PointerExitedItemEvent = RoutedEvent.Register( nameof(PointerExitedItem), 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 FuncTemplate DefaultPanel = new(() => new StackPanel()); private bool _commandCanExecute = true; private bool _commandBindingError; private Popup? _popup; private KeyGesture? _hotkey; private bool _isEmbeddedInMenu; /// /// Initializes static members of the class. /// static MenuItem() { SelectableMixin.Attach(IsSelectedProperty); PressedMixin.Attach(); FocusableProperty.OverrideDefaultValue(true); ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); ClickEvent.AddClassHandler((x, e) => x.OnClick(e)); SubmenuOpenedEvent.AddClassHandler((x, e) => x.OnSubmenuOpened(e)); AutomationProperties.IsOffscreenBehaviorProperty.OverrideDefaultValue(IsOffscreenBehavior.FromClip); AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler(OnAccessKeyPressed); } 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) .Select(x => { var parent = x as Control; return parent?.GetObservable(DefinitionBase.PrivateSharedSizeScopeProperty) ?? Observable.Return(null); }) .Switch(); 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? PointerEnteredItem { add => AddHandler(PointerEnteredItemEvent, value); remove => RemoveHandler(PointerEnteredItemEvent, value); } /// /// Raised when the pointer leaves a menu item. /// /// /// A bubbling version of the event for menu items. /// public event EventHandler? PointerExitedItem { add => AddHandler(PointerExitedItemEvent, value); remove => RemoveHandler(PointerExitedItemEvent, 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 => GetValue(CommandProperty); set => SetValue(CommandProperty, value); } /// /// Gets or sets an associated with this control /// public KeyGesture? HotKey { get => GetValue(HotKeyProperty); set => SetValue(HotKeyProperty, value); } /// /// Gets or sets the parameter to pass to the property of a /// . /// public object? CommandParameter { get => GetValue(CommandParameterProperty); set => SetValue(CommandParameterProperty, value); } /// /// Gets or sets the icon that appears in a . /// public object? Icon { get => 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 => GetValue(InputGestureProperty); set => SetValue(InputGestureProperty, value); } /// /// Gets or sets a value indicating whether the is currently selected. /// public bool IsSelected { get => GetValue(IsSelectedProperty); set => SetValue(IsSelectedProperty, value); } /// /// Gets or sets a value that indicates whether the submenu of the is /// open. /// public bool IsSubMenuOpen { get => GetValue(IsSubMenuOpenProperty); set => SetValue(IsSubMenuOpenProperty, value); } /// /// Gets or sets a value that indicates the submenu that this is /// within should not close when this item is clicked. /// public bool StaysOpenOnClick { get => GetValue(StaysOpenOnClickProperty); set => SetValue(StaysOpenOnClickProperty, value); } /// public MenuItemToggleType ToggleType { get => GetValue(ToggleTypeProperty); set => SetValue(ToggleTypeProperty, value); } /// public bool IsChecked { get => GetValue(IsCheckedProperty); set => SetValue(IsCheckedProperty, value); } bool IRadioButton.IsChecked { get => IsChecked; set => SetCurrentValue(IsCheckedProperty, value); } /// public string? GroupName { get => GetValue(GroupNameProperty); set => SetValue(GroupNameProperty, 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.IsEnabled && (HasSubMenu || _commandCanExecute); /// bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap); /// IMenuItem? IMenuElement.SelectedItem { get { var index = SelectedIndex; return (index != -1) ? (IMenuItem?)ContainerFromIndex(index) : null; } set => SelectedIndex = value is Control c ? IndexFromContainer(c) : -1; } /// IEnumerable IMenuElement.SubItems => LogicalChildren.OfType(); private IMenuInteractionHandler? MenuInteractionHandler => this.FindLogicalAncestorOfType()?.InteractionHandler; /// /// Opens the submenu. /// /// /// This has the same effect as setting to true. /// public void Open() => SetCurrentValue(IsSubMenuOpenProperty, true); /// /// Closes the submenu. /// /// /// This has the same effect as setting to false. /// public void Close() => SetCurrentValue(IsSubMenuOpenProperty, false); /// void IMenuItem.RaiseClick() => RaiseEvent(new RoutedEventArgs(ClickEvent)); protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) { return new MenuItem(); } protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) { if (item is MenuItem or Separator) { recycleKey = null; return false; } recycleKey = DefaultRecycleKey; return true; } 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 { SetCurrentValue(HotKeyProperty, _hotkey); } base.OnAttachedToLogicalTree(e); (var command, var parameter) = (Command, CommandParameter); if (command is not null) { command.CanExecuteChanged += CanExecuteChangedHandler; } TryUpdateCanExecute(command, parameter); var parent = Parent; while (parent is MenuItem) { parent = parent.Parent; } _isEmbeddedInMenu = parent?.FindLogicalAncestorOfType(true) != null; } /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); TryUpdateCanExecute(); } 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; SetCurrentValue(HotKeyProperty, null); } base.OnDetachedFromLogicalTree(e); if (Command != null) { Command.CanExecuteChanged -= CanExecuteChangedHandler; } } /// /// Invoked when an unhandled reaches an element in its /// route that is derived from this class. Implement this method to add class handling /// for this event. /// /// Data about the event. protected virtual void OnClick(RoutedEventArgs e) { (var command, var parameter) = (Command, CommandParameter); if (!e.Handled && command is not null && command.CanExecute(parameter) == true) { command.Execute(parameter); 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 OnPointerEntered(PointerEventArgs e) { base.OnPointerEntered(e); RaiseEvent(new RoutedEventArgs(PointerEnteredItemEvent)); } /// protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); RaiseEvent(new RoutedEventArgs(PointerExitedItemEvent)); } /// /// Invoked when an unhandled reaches an element in its /// route that is derived from this class. Implement this method to add class handling /// for this event. /// /// Data about the event. 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 AutomationPeer OnCreateAutomationPeer() { return new MenuItemAutomationPeer(this); } protected override void UpdateDataValidation( AvaloniaProperty property, BindingValueType state, Exception? error) { base.UpdateDataValidation(property, state, error); if (property == CommandProperty) { _commandBindingError = state == BindingValueType.BindingError; if (_commandBindingError && _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) { var newCommand = e.NewValue as ICommand; if (e.Sender is MenuItem menuItem) { if (((ILogical)menuItem).IsAttachedToLogicalTree) { if (e.OldValue is ICommand oldCommand) { oldCommand.CanExecuteChanged -= menuItem.CanExecuteChangedHandler; } if (newCommand is not null) { newCommand.CanExecuteChanged += menuItem.CanExecuteChangedHandler; } } menuItem.TryUpdateCanExecute(newCommand, menuItem.CommandParameter); } } private static void OnAccessKeyPressed(MenuItem sender, AccessKeyPressedEventArgs e) { if (e is not { Handled: false, Target: null }) return; e.Target = sender; e.Handled = true; } /// /// Called when the property changes. /// /// The event args. private static void CommandParameterChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is MenuItem menuItem) { (var command, var parameter) = (menuItem.Command, e.NewValue); menuItem.TryUpdateCanExecute(command, parameter); } } /// /// Called when the event fires. /// /// The event sender. /// The event args. private void CanExecuteChanged(object? sender, EventArgs e) { TryUpdateCanExecute(); } /// /// Tries to evaluate CanExecute value of a Command if menu is opened /// private void TryUpdateCanExecute() { TryUpdateCanExecute(Command, CommandParameter); } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] private void TryUpdateCanExecute(ICommand? command, object? parameter) { if (command == null) { _commandCanExecute = !_commandBindingError; UpdateIsEffectivelyEnabled(); return; } //Perf optimization - only raise CanExecute event if the menu is open if (!((ILogical)this).IsAttachedToLogicalTree || Parent is MenuItem { IsSubMenuOpen: false }) { return; } var canExecute = command.CanExecute(parameter); if (canExecute != _commandCanExecute) { _commandCanExecute = canExecute; UpdateIsEffectivelyEnabled(); } } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == HeaderProperty) { HeaderChanged(change); } else if (change.Property == IconProperty) { IconChanged(change); } else if (change.Property == IsSelectedProperty) { IsSelectedChanged(change); } else if (change.Property == IsSubMenuOpenProperty) { SubMenuOpenChanged(change); } else if (change.Property == CommandProperty) { CommandChanged(change); } else if (change.Property == CommandParameterProperty) { CommandParameterChanged(change); } else if (change.Property == IsCheckedProperty) { IsCheckedChanged(change); } else if (change.Property == ToggleTypeProperty) { ToggleTypeChanged(change); } else if (change.Property == GroupNameProperty) { GroupNameChanged(change); } else if (change.Property == ItemCountProperty) { // A menu item with no sub-menu is effectively disabled if its command binding // failed: this means that the effectively enabled state depends on whether the // number of items in the menu is 0 or not. var (o, n) = change.GetOldAndNewValue(); if (o == 0 || n == 0) UpdateIsEffectivelyEnabled(); } } /// /// Called when the property changes. /// /// The property change event. private void GroupNameChanged(AvaloniaPropertyChangedEventArgs e) { (MenuInteractionHandler as DefaultMenuInteractionHandler)?.OnGroupOrTypeChanged(this, e.GetOldValue()); } /// /// Called when the property changes. /// /// The property change event. private void ToggleTypeChanged(AvaloniaPropertyChangedEventArgs e) { var newValue = e.GetNewValue(); PseudoClasses.Set(":radio", newValue == MenuItemToggleType.Radio); PseudoClasses.Set(":toggle", newValue == MenuItemToggleType.CheckBox); (MenuInteractionHandler as DefaultMenuInteractionHandler)?.OnGroupOrTypeChanged(this, GroupName); } /// /// Called when the property changes. /// /// The property change event. private void IsCheckedChanged(AvaloniaPropertyChangedEventArgs e) { var newValue = e.GetNewValue(); PseudoClasses.Set(":checked", newValue); if (newValue) { (MenuInteractionHandler as DefaultMenuInteractionHandler)?.OnCheckedChanged(this); } } /// /// Called when the property changes. /// /// The property change event. private void HeaderChanged(AvaloniaPropertyChangedEventArgs e) { var (oldValue, newValue) = e.GetOldAndNewValue(); if (Equals(newValue, "-")) { PseudoClasses.Add(":separator"); Focusable = false; } else if (Equals(oldValue, "-")) { PseudoClasses.Remove(":separator"); Focusable = true; } } /// /// Called when the property changes. /// /// The property change event. private void IconChanged(AvaloniaPropertyChangedEventArgs e) { var (oldValue, newValue) = e.GetOldAndNewValue(); if (oldValue is { }) { if (oldValue is ILogical oldLogical) { LogicalChildren.Remove(oldLogical); } PseudoClasses.Remove(":icon"); } if (newValue is { }) { if (newValue is ILogical newLogical) { LogicalChildren.Add(newLogical); } PseudoClasses.Add(":icon"); } } /// /// Called when the property changes. /// /// The property change event. private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e) { var parentMenu = Parent as Menu; if ((bool)e.NewValue! && (parentMenu is null || parentMenu.IsOpen)) { Focus(); } } /// /// Called when the property changes. /// /// The property change event. private void SubMenuOpenChanged(AvaloniaPropertyChangedEventArgs e) { var value = (bool)e.NewValue!; if (value) { foreach (var item in ItemsView.OfType()) { item.TryUpdateCanExecute(); } RaiseEvent(new RoutedEventArgs(SubmenuOpenedEvent)); SetCurrentValue(IsSelectedProperty, 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) { // If we're using overlay popups, there's a chance we need to do a layout pass before // the child items are added to the visual tree. If we don't do this here, then // selection breaks. if (Presenter?.IsAttachedToVisualTree == false) UpdateLayout(); var selected = SelectedIndex; if (selected != -1) { var container = 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) => CanExecuteChangedHandler(sender, e); void IClickableControl.RaiseClick() { if (IsEffectivelyEnabled) { RaiseEvent(new RoutedEventArgs(ClickEvent)); } } /// /// 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); } } } } }