From 6c1341eb5ecd230543dd576bf228c9d4a0b3e71e Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 14 Feb 2024 02:56:27 -0800 Subject: [PATCH] Introduce MenuItem.ToggleType (#11441) * Init work with Toggle and RadioMenuItem * Add tests, fix couple of bugs, simplify IGroupRadioButton * Add more tests, specify ToggleType * Fix TrayIcon sync * Remove unused ToggleMenuItemCheckedCommand * Revert unused MenuItem.OnClick * Rename IGroupRadioButton to IRadioButton * Revert DevTools changes * Remove NativeMenuBar styles, unify Win32.TrayIcon and NativeMenuBar items generation --------- Co-authored-by: Steven Kirk --- samples/ControlCatalog/App.xaml | 8 +- samples/ControlCatalog/MainWindow.xaml | 16 +- samples/ControlCatalog/Pages/MenuPage.xaml | 46 +-- .../ViewModels/ApplicationViewModel.cs | 4 +- .../ViewModels/MainWindowViewModel.cs | 14 - .../Avalonia.Controls.csproj | 1 + src/Avalonia.Controls/IMenuItem.cs | 16 + src/Avalonia.Controls/MenuBase.cs | 4 +- src/Avalonia.Controls/MenuItem.cs | 154 ++++++++- src/Avalonia.Controls/MenuItemToggleType.cs | 22 ++ src/Avalonia.Controls/NativeMenuBar.cs | 68 +++- .../NativeMenuBarPresenter.cs | 75 +++++ src/Avalonia.Controls/NativeMenuItem.cs | 37 ++- .../Platform/DefaultMenuInteractionHandler.cs | 75 ++++- src/Avalonia.Controls/RadioButton.cs | 156 ++------- .../RadioButtonGroupManager.cs | 135 ++++++++ .../Controls/CheckBox.xaml | 4 +- .../Controls/FluentControls.xaml | 1 - .../Controls/MenuItem.xaml | 43 ++- .../Controls/NativeMenuBar.xaml | 30 -- .../IBitmapToImageConverter.cs | 24 -- .../Controls/MenuItem.xaml | 51 ++- .../Controls/NativeMenuBar.xaml | 29 -- .../Controls/SimpleControls.xaml | 1 - .../IBitmapToImageConverter.cs | 28 -- src/Windows/Avalonia.Win32/TrayIconImpl.cs | 15 +- .../Win32NativeToManagedMenuExporter.cs | 70 +---- .../MenuItemTests.cs | 295 ++++++++++++++++++ 28 files changed, 1015 insertions(+), 407 deletions(-) create mode 100644 src/Avalonia.Controls/MenuItemToggleType.cs create mode 100644 src/Avalonia.Controls/NativeMenuBarPresenter.cs create mode 100644 src/Avalonia.Controls/RadioButtonGroupManager.cs delete mode 100644 src/Avalonia.Themes.Fluent/Controls/NativeMenuBar.xaml delete mode 100644 src/Avalonia.Themes.Fluent/IBitmapToImageConverter.cs delete mode 100644 src/Avalonia.Themes.Simple/Controls/NativeMenuBar.xaml delete mode 100644 src/Avalonia.Themes.Simple/IBitmapToImageConverter.cs diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 02b1242471..519fb3ec7a 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -69,11 +69,11 @@ - - + + - - + + diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml index 442c1d37b0..adcfc285cd 100644 --- a/samples/ControlCatalog/MainWindow.xaml +++ b/samples/ControlCatalog/MainWindow.xaml @@ -38,17 +38,13 @@ + ToggleType="None" /> - + ToggleType="CheckBox" /> + + diff --git a/samples/ControlCatalog/Pages/MenuPage.xaml b/samples/ControlCatalog/Pages/MenuPage.xaml index 40476f91f7..bbcc759ca7 100644 --- a/samples/ControlCatalog/Pages/MenuPage.xaml +++ b/samples/ControlCatalog/Pages/MenuPage.xaml @@ -31,34 +31,44 @@ - - - - - + + + + + + + + + + + + + + + + - Dyanamically generated - - - - - + Dyanamically generated + + + + + - - + Mixed diff --git a/samples/ControlCatalog/ViewModels/ApplicationViewModel.cs b/samples/ControlCatalog/ViewModels/ApplicationViewModel.cs index 868c72df5c..509feca5e2 100644 --- a/samples/ControlCatalog/ViewModels/ApplicationViewModel.cs +++ b/samples/ControlCatalog/ViewModels/ApplicationViewModel.cs @@ -16,11 +16,11 @@ namespace ControlCatalog.ViewModels } }); - ToggleCommand = MiniCommand.Create(() => { }); + RestoreDefault = MiniCommand.Create(() => { }); } public MiniCommand ExitCommand { get; } - public MiniCommand ToggleCommand { get; } + public MiniCommand RestoreDefault { get; } } } diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs index 8c6f0a2bd6..9b9e336806 100644 --- a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs +++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs @@ -13,7 +13,6 @@ namespace ControlCatalog.ViewModels { class MainWindowViewModel : ViewModelBase { - private bool _isMenuItemChecked = true; private WindowState _windowState; private WindowState[] _windowStates = Array.Empty(); private ExtendClientAreaChromeHints _chromeHints = ExtendClientAreaChromeHints.PreferSystemChrome; @@ -41,11 +40,6 @@ namespace ControlCatalog.ViewModels (App.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); }); - ToggleMenuItemCheckedCommand = MiniCommand.Create(() => - { - IsMenuItemChecked = !IsMenuItemChecked; - }); - WindowState = WindowState.Normal; WindowStates = new WindowState[] @@ -120,12 +114,6 @@ namespace ControlCatalog.ViewModels set { this.RaiseAndSetIfChanged(ref _windowStates, value); } } - public bool IsMenuItemChecked - { - get { return _isMenuItemChecked; } - set { this.RaiseAndSetIfChanged(ref _isMenuItemChecked, value); } - } - public bool IsSystemBarVisible { get { return _isSystemBarVisible; } @@ -148,8 +136,6 @@ namespace ControlCatalog.ViewModels public MiniCommand ExitCommand { get; } - public MiniCommand ToggleMenuItemCheckedCommand { get; } - private DateTime? _validatedDateExample; /// diff --git a/src/Avalonia.Controls/Avalonia.Controls.csproj b/src/Avalonia.Controls/Avalonia.Controls.csproj index 0dda861448..f8f14cca6f 100644 --- a/src/Avalonia.Controls/Avalonia.Controls.csproj +++ b/src/Avalonia.Controls/Avalonia.Controls.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Avalonia.Controls/IMenuItem.cs b/src/Avalonia.Controls/IMenuItem.cs index 3a9c5373a6..49ac0bac1b 100644 --- a/src/Avalonia.Controls/IMenuItem.cs +++ b/src/Avalonia.Controls/IMenuItem.cs @@ -39,6 +39,22 @@ namespace Avalonia.Controls /// IMenuElement? Parent { get; } + /// + /// Gets toggle type of the menu item. + /// + MenuItemToggleType ToggleType { get; } + + /// + /// Gets menu item group name when is . + /// + string? GroupName { get; } + + /// + /// Gets or sets if menu item is checked when is + /// or . + /// + bool IsChecked { get; set; } + /// /// Raises a click event on the menu item. /// diff --git a/src/Avalonia.Controls/MenuBase.cs b/src/Avalonia.Controls/MenuBase.cs index 2a34eb3f4a..2c0792c028 100644 --- a/src/Avalonia.Controls/MenuBase.cs +++ b/src/Avalonia.Controls/MenuBase.cs @@ -92,12 +92,12 @@ namespace Avalonia.Controls } /// - IEnumerable IMenuElement.SubItems => GetRealizedContainers().OfType(); + IEnumerable IMenuElement.SubItems => LogicalChildren.OfType(); /// /// Gets the interaction handler for the menu. /// - protected IMenuInteractionHandler InteractionHandler { get; } + protected internal IMenuInteractionHandler InteractionHandler { get; } /// /// Occurs when a is opened. diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index c9fdb80dda..a81ca13e81 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -6,6 +6,7 @@ using System.Windows.Input; 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; @@ -20,8 +21,8 @@ namespace Avalonia.Controls /// A menu item control. /// [TemplatePart("PART_Popup", typeof(Popup))] - [PseudoClasses(":separator", ":icon", ":open", ":pressed", ":selected")] - public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable, ICommandSource, IClickableControl + [PseudoClasses(":separator", ":radio", ":toggle", ":checked", ":icon", ":open", ":pressed", ":selected")] + public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable, ICommandSource, IClickableControl, IRadioButton { /// /// Defines the property. @@ -65,6 +66,24 @@ namespace Avalonia.Controls 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. /// @@ -116,16 +135,10 @@ namespace Avalonia.Controls { 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() @@ -279,7 +292,34 @@ namespace Avalonia.Controls 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. /// @@ -315,7 +355,9 @@ namespace Avalonia.Controls } /// - IEnumerable IMenuElement.SubItems => GetRealizedContainers().OfType(); + IEnumerable IMenuElement.SubItems => LogicalChildren.OfType(); + + private IMenuInteractionHandler? MenuInteractionHandler => this.FindLogicalAncestorOfType()?.InteractionHandler; /// /// Opens the submenu. @@ -601,18 +643,97 @@ namespace Avalonia.Controls } } + 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); + } + } + /// + /// 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) { - if (e.NewValue is string newValue && newValue == "-") + var (oldValue, newValue) = e.GetOldAndNewValue(); + if (Equals(newValue, "-")) { PseudoClasses.Add(":separator"); Focusable = false; } - else if (e.OldValue is string oldValue && oldValue == "-") + else if (Equals(oldValue, "-")) { PseudoClasses.Remove(":separator"); Focusable = true; @@ -625,18 +746,17 @@ namespace Avalonia.Controls /// The property change event. private void IconChanged(AvaloniaPropertyChangedEventArgs e) { - var oldValue = e.OldValue as ILogical; - var newValue = e.NewValue as ILogical; + var (oldValue, newValue) = e.GetOldAndNewValue(); - if (oldValue != null) + if (oldValue is ILogical oldLogical) { - LogicalChildren.Remove(oldValue); + LogicalChildren.Remove(oldLogical); PseudoClasses.Remove(":icon"); } - if (newValue != null) + if (newValue is ILogical newLogical) { - LogicalChildren.Add(newValue); + LogicalChildren.Add(newLogical); PseudoClasses.Add(":icon"); } } diff --git a/src/Avalonia.Controls/MenuItemToggleType.cs b/src/Avalonia.Controls/MenuItemToggleType.cs new file mode 100644 index 0000000000..b0ea48792d --- /dev/null +++ b/src/Avalonia.Controls/MenuItemToggleType.cs @@ -0,0 +1,22 @@ +namespace Avalonia.Controls; + +/// +/// Defines how a or reacts to clicks. +/// +public enum MenuItemToggleType +{ + /// + /// Normal menu item. + /// + None, + + /// + /// Toggleable menu item with a checkbox. + /// + CheckBox, + + /// + /// Menu item representing single option of radio group. + /// + Radio +} diff --git a/src/Avalonia.Controls/NativeMenuBar.cs b/src/Avalonia.Controls/NativeMenuBar.cs index 3953de8165..118c2291cc 100644 --- a/src/Avalonia.Controls/NativeMenuBar.cs +++ b/src/Avalonia.Controls/NativeMenuBar.cs @@ -1,17 +1,26 @@ using System; -using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; using Avalonia.Interactivity; +using Avalonia.Metadata; using Avalonia.Reactive; +using Avalonia.VisualTree; namespace Avalonia.Controls { + [TemplatePart("PART_NativeMenuPresenter", typeof(MenuBase))] public class NativeMenuBar : TemplatedControl { + [Unstable("To be removed in 12.0, NativeMenuBar now has a default template")] // TODO12 public static readonly AttachedProperty EnableMenuItemClickForwardingProperty = - AvaloniaProperty.RegisterAttached( + AvaloniaProperty.RegisterAttached( "EnableMenuItemClickForwarding"); + private MenuBase? _menu; + private IDisposable? _subscriptions; + static NativeMenuBar() { EnableMenuItemClickForwardingProperty.Changed.Subscribe(args => @@ -22,8 +31,53 @@ namespace Avalonia.Controls else item.Click -= OnMenuItemClick; }); + + // TODO12 Ideally we should make NativeMenuBar inherit MenuBase directly, but it would be a breaking change for 11.x. + // Changing default template while keeping old StyleKeyOverride => Menu isn't a breaking change. + TemplateProperty.OverrideDefaultValue(new FuncControlTemplate((_, ns) => new NativeMenuBarPresenter + { + Name = "PART_NativeMenuPresenter", + [~BackgroundProperty] = new TemplateBinding(BackgroundProperty), + [~BorderBrushProperty] = new TemplateBinding(BorderBrushProperty) + }.RegisterInNameScope(ns))); } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + _menu = e.NameScope.Find("PART_NativeMenuPresenter") + ?? this.FindDescendantOfType() + ?? throw new InvalidOperationException("NativeMenuBar requires a MenuBase#PART_NativeMenuPresenter template part."); + + if (VisualRoot is TopLevel topLevel) + { + SubscribeToToplevel(topLevel, _menu); + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (_menu is null) + return; + + if (e.Root is TopLevel topLevel) + { + SubscribeToToplevel(topLevel, _menu); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + _subscriptions?.Dispose(); + _subscriptions = null; + } + + [Unstable("To be removed in 12.0, NativeMenuBar now has a default template.")] // TODO12 public static void SetEnableMenuItemClickForwarding(MenuItem menuItem, bool enable) { menuItem.SetValue(EnableMenuItemClickForwardingProperty, enable); @@ -33,5 +87,15 @@ namespace Avalonia.Controls { (((MenuItem)sender!).DataContext as INativeMenuItemExporterEventsImplBridge)?.RaiseClicked(); } + + private void SubscribeToToplevel(TopLevel topLevel, MenuBase menu) + { + _subscriptions?.Dispose(); + _subscriptions = new CompositeDisposable( + menu.Bind(IsVisibleProperty, topLevel.GetBindingObservable(NativeMenu.IsNativeMenuExportedProperty) + .Select(v => !v.GetValueOrDefault())), + menu.Bind(ItemsControl.ItemsSourceProperty, topLevel.GetBindingObservable(NativeMenu.MenuProperty) + .Select(v => v.GetValueOrDefault()?.Items))); + } } } diff --git a/src/Avalonia.Controls/NativeMenuBarPresenter.cs b/src/Avalonia.Controls/NativeMenuBarPresenter.cs new file mode 100644 index 0000000000..be93f9dc85 --- /dev/null +++ b/src/Avalonia.Controls/NativeMenuBarPresenter.cs @@ -0,0 +1,75 @@ +using System; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Interactivity; +using Avalonia.Reactive; + +namespace Avalonia.Controls; + +internal class NativeMenuBarPresenter : Menu +{ + protected override Type StyleKeyOverride => typeof(Menu); + + internal static Control? CreateContainerForNativeItem(object? item, int index, object? recycleKey) + { + if (item is NativeMenuItemSeparator) + { + return new Separator(); + } + else if (item is NativeMenuItem nativeItem) + { + var newItem = new NativeMenuItemPresenter + { + ItemsSource = nativeItem.Menu?.Items, + [!HeaderedSelectingItemsControl.HeaderProperty] = + nativeItem.GetObservable(NativeMenuItem.HeaderProperty).ToBinding(), + [!MenuItem.IconProperty] = nativeItem.GetObservable(NativeMenuItem.IconProperty) + .Select(i => i is { } bitmap ? new Image { Source = bitmap } : null).ToBinding(), + [!MenuItem.IsEnabledProperty] = nativeItem.GetObservable(NativeMenuItem.IsEnabledProperty).ToBinding(), + [!MenuItem.CommandProperty] = nativeItem.GetObservable(NativeMenuItem.CommandProperty).ToBinding(), + [!MenuItem.CommandParameterProperty] = + nativeItem.GetObservable(NativeMenuItem.CommandParameterProperty).ToBinding(), + [!MenuItem.InputGestureProperty] = nativeItem.GetObservable(NativeMenuItem.GestureProperty).ToBinding(), + [!MenuItem.ToggleTypeProperty] = nativeItem.GetObservable(NativeMenuItem.ToggleTypeProperty) + // TODO12 remove NativeMenuItemToggleType + .Select(v => (MenuItemToggleType)v).ToBinding() + }; + + BindingOperations.Apply(newItem, MenuItem.IsCheckedProperty, InstancedBinding.TwoWay( + nativeItem.GetObservable(NativeMenuItem.IsCheckedProperty).Select(v => (object)v), + new AnonymousObserver(v => nativeItem.SetValue(NativeMenuItem.IsCheckedProperty, v)))); + + newItem.Click += MenuItemOnClick; + + return newItem; + } + + return null; + + static void MenuItemOnClick(object? sender, RoutedEventArgs e) + { + if (((MenuItem)sender!).DataContext is NativeMenuItem item + && item.HasClickHandlers && item is INativeMenuItemExporterEventsImplBridge bridge) + { + bridge.RaiseClicked(); + } + } + } + + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return CreateContainerForNativeItem(item, index, recycleKey) + ?? base.CreateContainerForItemOverride(item, index, recycleKey); + } + + private class NativeMenuItemPresenter : MenuItem + { + protected override Type StyleKeyOverride => typeof(MenuItem); + + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return CreateContainerForNativeItem(item, index, recycleKey) + ?? base.CreateContainerForItemOverride(item, index, recycleKey); + } + } +} diff --git a/src/Avalonia.Controls/NativeMenuItem.cs b/src/Avalonia.Controls/NativeMenuItem.cs index 47fc369859..72f7102768 100644 --- a/src/Avalonia.Controls/NativeMenuItem.cs +++ b/src/Avalonia.Controls/NativeMenuItem.cs @@ -54,18 +54,22 @@ namespace Avalonia.Controls return value; } + /// public static readonly StyledProperty IconProperty = AvaloniaProperty.Register(nameof(Icon)); + /// public Bitmap? Icon { get => GetValue(IconProperty); set => SetValue(IconProperty, value); } + /// public static readonly StyledProperty HeaderProperty = AvaloniaProperty.Register(nameof(Header)); + /// public string? Header { get => GetValue(HeaderProperty); @@ -89,45 +93,51 @@ namespace Avalonia.Controls set => SetValue(ToolTipProperty, value); } + /// public static readonly StyledProperty GestureProperty = AvaloniaProperty.Register(nameof(Gesture)); + /// public KeyGesture? Gesture { get => GetValue(GestureProperty); set => SetValue(GestureProperty, value); } + /// public static readonly StyledProperty IsCheckedProperty = - AvaloniaProperty.Register(nameof(IsChecked)); + MenuItem.IsCheckedProperty.AddOwner(); + /// public bool IsChecked { get => GetValue(IsCheckedProperty); set => SetValue(IsCheckedProperty, value); } + /// public static readonly StyledProperty ToggleTypeProperty = AvaloniaProperty.Register(nameof(ToggleType)); + /// public NativeMenuItemToggleType ToggleType { get => GetValue(ToggleTypeProperty); set => SetValue(ToggleTypeProperty, value); } + /// public static readonly StyledProperty CommandProperty = - Button.CommandProperty.AddOwner(new(enableDataValidation: true)); + MenuItem.CommandProperty.AddOwner(new(enableDataValidation: true)); - /// - /// Defines the property. - /// + /// public static readonly StyledProperty CommandParameterProperty = - Button.CommandParameterProperty.AddOwner(); + MenuItem.CommandParameterProperty.AddOwner(); public static readonly StyledProperty IsEnabledProperty = AvaloniaProperty.Register(nameof(IsEnabled), true); + /// public bool IsEnabled { get => GetValue(IsEnabledProperty); @@ -141,16 +151,14 @@ namespace Avalonia.Controls public bool HasClickHandlers => Click != null; + /// public ICommand? Command { get => GetValue(CommandProperty); set => SetValue(CommandProperty, value); } - /// - /// Gets or sets the parameter to pass to the property of a - /// . - /// + /// public object? CommandParameter { get => GetValue(CommandParameterProperty); @@ -192,11 +200,12 @@ namespace Avalonia.Controls } } } - + + // TODO12: remove this enum and use MenuItemToggleType only public enum NativeMenuItemToggleType { - None, - CheckBox, - Radio + None = MenuItemToggleType.None, + CheckBox = MenuItemToggleType.CheckBox, + Radio = MenuItemToggleType.Radio } } diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 2369c1f983..2097cd2d1a 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -22,6 +22,7 @@ namespace Avalonia.Controls.Platform private readonly bool _isContextMenu; private IDisposable? _inputManagerSubscription; private IRenderRoot? _root; + private RadioButtonGroupManager? _groupManager; public DefaultMenuInteractionHandler(bool isContextMenu) : this(isContextMenu, Input.InputManager.Instance, DefaultDelayRun) @@ -42,7 +43,7 @@ namespace Avalonia.Controls.Platform public void Attach(MenuBase menu) => AttachCore(menu); public void Detach(MenuBase menu) => DetachCore(menu); - + protected Action DelayRun { get; } protected IInputManager? InputManager { get; } @@ -297,6 +298,12 @@ namespace Avalonia.Controls.Platform _root = Menu.VisualRoot; + if (_root is not null) + { + _groupManager = RadioButtonGroupManager.GetOrCreateForRoot(_root); + AddMenuItemToRadioGroup(_groupManager, menu); + } + if (_root is InputElement inputRoot) { inputRoot.AddHandler(InputElement.PointerPressedEvent, RootPointerPressed, RoutingStrategies.Tunnel); @@ -331,6 +338,12 @@ namespace Avalonia.Controls.Platform Menu.RemoveHandler(MenuItem.PointerExitedItemEvent, PointerExited); Menu.RemoveHandler(InputElement.PointerMovedEvent, PointerMoved); + if (_root is not null && _groupManager is { } oldManager) + { + _groupManager = null; + RemoveMenuItemFromRadioGroup(oldManager, menu); + } + if (_root is InputElement inputRoot) { inputRoot.RemoveHandler(InputElement.PointerPressedEvent, RootPointerPressed); @@ -352,6 +365,19 @@ namespace Avalonia.Controls.Platform internal void Click(IMenuItem item) { + if (!item.HasSubMenu) + { + if (item.ToggleType == MenuItemToggleType.CheckBox) + { + var newValue = !item.IsChecked; + item.IsChecked = newValue; + } + else if (item.ToggleType == MenuItemToggleType.Radio && !item.IsChecked) + { + item.IsChecked = true; + } + } + item.RaiseClick(); if (!item.StaysOpenOnClick) @@ -553,6 +579,26 @@ namespace Avalonia.Controls.Platform } } + internal void OnCheckedChanged(IMenuItem item) + { + if (item is IRadioButton radioButton) + { + _groupManager?.OnCheckedChanged(radioButton); + } + } + + internal void OnGroupOrTypeChanged(IRadioButton button, string? oldGroupName) + { + if (!string.IsNullOrEmpty(oldGroupName)) + { + _groupManager?.Remove(button, oldGroupName); + } + if (!string.IsNullOrEmpty(button.GroupName)) + { + _groupManager?.Add(button); + } + } + internal static IMenuItem? GetMenuItemCore(StyledElement? item) { while (true) @@ -574,5 +620,32 @@ namespace Avalonia.Controls.Platform { DispatcherTimer.RunOnce(action, timeSpan); } + + private static void AddMenuItemToRadioGroup(RadioButtonGroupManager manager, IMenuElement element) + { + // Instead add menu item to the group on attached/detached + ensure checked stated on attached. + if (element is IRadioButton button) + { + manager.Add(button); + } + + foreach (var subItem in element.SubItems) + { + AddMenuItemToRadioGroup(manager, subItem); + } + } + + private static void RemoveMenuItemFromRadioGroup(RadioButtonGroupManager manager, IMenuElement element) + { + if (element is IRadioButton button) + { + manager.Remove(button, button.GroupName); + } + + foreach (var subItem in element.SubItems) + { + RemoveMenuItemFromRadioGroup(manager, subItem); + } + } } } diff --git a/src/Avalonia.Controls/RadioButton.cs b/src/Avalonia.Controls/RadioButton.cs index f48ae72312..1ce36f14b5 100644 --- a/src/Avalonia.Controls/RadioButton.cs +++ b/src/Avalonia.Controls/RadioButton.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Runtime.CompilerServices; using Avalonia.Automation.Peers; using Avalonia.Controls.Automation.Peers; using Avalonia.Controls.Primitives; @@ -14,101 +14,33 @@ namespace Avalonia.Controls /// /// Represents a button that allows a user to select a single option from a group of options. /// - public class RadioButton : ToggleButton + public class RadioButton : ToggleButton, IRadioButton { - private class RadioButtonGroupManager - { - public static readonly RadioButtonGroupManager Default = new RadioButtonGroupManager(); - static readonly ConditionalWeakTable s_registeredVisualRoots - = new ConditionalWeakTable(); - - readonly Dictionary>> s_registeredGroups - = new Dictionary>>(); - - public static RadioButtonGroupManager GetOrCreateForRoot(IRenderRoot? root) - { - if (root == null) - return Default; - return s_registeredVisualRoots.GetValue(root, key => new RadioButtonGroupManager()); - } - - public void Add(RadioButton radioButton) - { - lock (s_registeredGroups) - { - string groupName = radioButton.GroupName!; - if (!s_registeredGroups.TryGetValue(groupName, out var group)) - { - group = new List>(); - s_registeredGroups.Add(groupName, group); - } - group.Add(new WeakReference(radioButton)); - } - } - - public void Remove(RadioButton radioButton, string oldGroupName) - { - lock (s_registeredGroups) - { - if (!string.IsNullOrEmpty(oldGroupName) && s_registeredGroups.TryGetValue(oldGroupName, out var group)) - { - int i = 0; - while (i < group.Count) - { - if (!group[i].TryGetTarget(out var button) || button == radioButton) - { - group.RemoveAt(i); - continue; - } - i++; - } - if (group.Count == 0) - { - s_registeredGroups.Remove(oldGroupName); - } - } - } - } - - public void SetChecked(RadioButton radioButton) - { - lock (s_registeredGroups) - { - string groupName = radioButton.GroupName!; - if (s_registeredGroups.TryGetValue(groupName, out var group)) - { - int i = 0; - while (i < group.Count) - { - if (!group[i].TryGetTarget(out var current)) - { - group.RemoveAt(i); - continue; - } - if (current != radioButton && current.IsChecked.GetValueOrDefault()) - current.SetCurrentValue(IsCheckedProperty, false); - i++; - } - if (group.Count == 0) - { - s_registeredGroups.Remove(groupName); - } - } - } - } - } - + /// + /// Identifies the GroupName dependency property. + /// public static readonly StyledProperty GroupNameProperty = AvaloniaProperty.Register(nameof(GroupName)); private RadioButtonGroupManager? _groupManager; + /// + /// Gets or sets the name that specifies which RadioButton controls are mutually exclusive. + /// public string? GroupName { get => GetValue(GroupNameProperty); set => SetValue(GroupNameProperty, value); } + bool IRadioButton.IsChecked + { + get => IsChecked.GetValueOrDefault(); + set => SetCurrentValue(IsCheckedProperty, value); + } + + MenuItemToggleType IRadioButton.ToggleType => MenuItemToggleType.Radio; + protected override void Toggle() { if (!IsChecked.GetValueOrDefault()) @@ -119,14 +51,8 @@ namespace Avalonia.Controls protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { - if (!string.IsNullOrEmpty(GroupName)) - { - _groupManager?.Remove(this, GroupName); - - _groupManager = RadioButtonGroupManager.GetOrCreateForRoot(e.Root); - - _groupManager.Add(this); - } + _groupManager?.Remove(this, GroupName); + EnsureRadioGroupManager(e.Root); base.OnAttachedToVisualTree(e); } @@ -134,10 +60,8 @@ namespace Avalonia.Controls { base.OnDetachedFromVisualTree(e); - if (!string.IsNullOrEmpty(GroupName)) - { - _groupManager?.Remove(this, GroupName); - } + _groupManager?.Remove(this, GroupName); + _groupManager = null; } protected override AutomationPeer OnCreateAutomationPeer() @@ -168,42 +92,24 @@ namespace Avalonia.Controls } if (!string.IsNullOrEmpty(newGroupName)) { - if (_groupManager == null) - { - _groupManager = RadioButtonGroupManager.GetOrCreateForRoot(this.GetVisualRoot()); - } - _groupManager.Add(this); + EnsureRadioGroupManager(); } } private new void IsCheckedChanged(bool? value) { - var groupName = GroupName; - if (string.IsNullOrEmpty(groupName)) - { - var parent = this.GetVisualParent(); - - if (value.GetValueOrDefault() && parent != null) - { - var siblings = parent - .GetVisualChildren() - .OfType() - .Where(x => x != this && string.IsNullOrEmpty(x.GroupName)); - - foreach (var sibling in siblings) - { - if (sibling.IsChecked.GetValueOrDefault()) - sibling.SetCurrentValue(IsCheckedProperty, false); - } - } - } - else + if (value.GetValueOrDefault()) { - if (value.GetValueOrDefault() && _groupManager != null) - { - _groupManager.SetChecked(this); - } + EnsureRadioGroupManager(); + _groupManager.OnCheckedChanged(this); } } + + [MemberNotNull(nameof(_groupManager))] + private void EnsureRadioGroupManager(IRenderRoot? root = null) + { + _groupManager = RadioButtonGroupManager.GetOrCreateForRoot(root ?? this.GetVisualRoot()); + _groupManager.Add(this); + } } } diff --git a/src/Avalonia.Controls/RadioButtonGroupManager.cs b/src/Avalonia.Controls/RadioButtonGroupManager.cs new file mode 100644 index 0000000000..80a942a2f0 --- /dev/null +++ b/src/Avalonia.Controls/RadioButtonGroupManager.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Avalonia.Collections.Pooled; +using Avalonia.Controls.Primitives; +using Avalonia.LogicalTree; +using Avalonia.Rendering; + +namespace Avalonia.Controls; + +internal interface IRadioButton : ILogical +{ + string? GroupName { get; } + MenuItemToggleType ToggleType { get; } + bool IsChecked { get; set; } +} + +internal class RadioButtonGroupManager +{ + private static readonly RadioButtonGroupManager s_default = new(); + private static readonly ConditionalWeakTable s_registeredVisualRoots = new(); + + private readonly Dictionary>> _registeredGroups = new(); + private bool _ignoreCheckedChanges; + + public static RadioButtonGroupManager GetOrCreateForRoot(IRenderRoot? root) + { + if (root == null) + return s_default; + return s_registeredVisualRoots.GetValue(root, key => new RadioButtonGroupManager()); + } + + public void Add(IRadioButton radioButton) + { + var groupName = radioButton.GroupName; + if (groupName is not null && radioButton.ToggleType == MenuItemToggleType.Radio) + { + if (!_registeredGroups.TryGetValue(groupName, out var group)) + { + group = new List>(); + _registeredGroups.Add(groupName, group); + } + + group.Add(new WeakReference(radioButton)); + } + } + + public void Remove(IRadioButton radioButton, string? oldGroupName) + { + if (!string.IsNullOrEmpty(oldGroupName) && _registeredGroups.TryGetValue(oldGroupName, out var group)) + { + int i = 0; + while (i < group.Count) + { + if (!group[i].TryGetTarget(out var button) || button == radioButton) + { + group.RemoveAt(i); + continue; + } + + i++; + } + + if (group.Count == 0) + { + _registeredGroups.Remove(oldGroupName); + } + } + } + + public void OnCheckedChanged(IRadioButton radioButton) + { + if (_ignoreCheckedChanges || radioButton.ToggleType != MenuItemToggleType.Radio) + { + return; + } + + _ignoreCheckedChanges = true; + try + { + var groupName = radioButton.GroupName; + if (!string.IsNullOrEmpty(groupName)) + { + if (_registeredGroups.TryGetValue(groupName, out var group)) + { + var i = 0; + while (i < group.Count) + { + if (!group[i].TryGetTarget(out var current)) + { + group.RemoveAt(i); + continue; + } + + if (current != radioButton && current.IsChecked) + current.IsChecked = false; + i++; + } + + if (group.Count == 0) + { + _registeredGroups.Remove(groupName); + } + + var parent = radioButton.LogicalParent as IRadioButton; + while (parent is not null && parent.GroupName == groupName) + { + parent.IsChecked = true; + parent = parent.LogicalParent as IRadioButton; + } + } + } + else + { + if (radioButton.LogicalParent is { } parent) + { + foreach (var sibling in parent.LogicalChildren) + { + if (sibling != radioButton + && sibling is IRadioButton { ToggleType: MenuItemToggleType.Radio } button + && string.IsNullOrEmpty(button.GroupName) + && button.IsChecked) + { + button.IsChecked = false; + } + } + } + } + } + finally + { + _ignoreCheckedChanges = false; + } + } +} diff --git a/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml b/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml index 85a6489e06..3bebc293d3 100644 --- a/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml @@ -12,6 +12,8 @@ + M5.5 10.586 1.707 6.793A1 1 0 0 0 .293 8.207l4.5 4.5a1 1 0 0 0 1.414 0l11-11A1 1 0 0 0 15.793.293L5.5 10.586Z + @@ -144,7 +146,7 @@ diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml index 2b712e5e92..90279214e5 100644 --- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml @@ -32,7 +32,6 @@ - diff --git a/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml b/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml index dff1e5961e..b887198262 100644 --- a/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml @@ -71,6 +71,8 @@ CornerRadius="{TemplateBinding CornerRadius}"> + @@ -80,10 +82,17 @@ SharedSizeGroup="MenuItemChevron" /> - + + + Grid.Column="2"/> + + + + + + - - - - - - - - - - - - - diff --git a/src/Avalonia.Themes.Fluent/IBitmapToImageConverter.cs b/src/Avalonia.Themes.Fluent/IBitmapToImageConverter.cs deleted file mode 100644 index 06b27669c2..0000000000 --- a/src/Avalonia.Themes.Fluent/IBitmapToImageConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Globalization; -using Avalonia.Controls; -using Avalonia.Data.Converters; -using Avalonia.Media.Imaging; - -namespace Avalonia.Themes.Fluent -{ - internal class IBitmapToImageConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value != null && value is Bitmap bm) - return new Image { Source = bm }; - - return null; - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} diff --git a/src/Avalonia.Themes.Simple/Controls/MenuItem.xaml b/src/Avalonia.Themes.Simple/Controls/MenuItem.xaml index 063c0d3d68..1c0fc15150 100644 --- a/src/Avalonia.Themes.Simple/Controls/MenuItem.xaml +++ b/src/Avalonia.Themes.Simple/Controls/MenuItem.xaml @@ -26,19 +26,20 @@ SharedSizeGroup="MenuItemIGT" /> - - + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/NativeMenuBar.xaml b/src/Avalonia.Themes.Simple/Controls/NativeMenuBar.xaml deleted file mode 100644 index 9139f96a2a..0000000000 --- a/src/Avalonia.Themes.Simple/Controls/NativeMenuBar.xaml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml index 0366442e6d..a13f579567 100644 --- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml +++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml @@ -59,7 +59,6 @@ - diff --git a/src/Avalonia.Themes.Simple/IBitmapToImageConverter.cs b/src/Avalonia.Themes.Simple/IBitmapToImageConverter.cs deleted file mode 100644 index 4bb8e2e286..0000000000 --- a/src/Avalonia.Themes.Simple/IBitmapToImageConverter.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Data.Converters; -using Avalonia.Media.Imaging; - -namespace Avalonia.Themes.Simple -{ - internal class IBitmapToImageConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value != null && value is Bitmap bm) - return new Image { Source=bm }; - - return null; - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} diff --git a/src/Windows/Avalonia.Win32/TrayIconImpl.cs b/src/Windows/Avalonia.Win32/TrayIconImpl.cs index 9d2b274a2b..a33e463a2c 100644 --- a/src/Windows/Avalonia.Win32/TrayIconImpl.cs +++ b/src/Windows/Avalonia.Win32/TrayIconImpl.cs @@ -144,8 +144,8 @@ namespace Avalonia.Win32 private void OnRightClicked() { - var menuItems = _exporter.GetMenu(); - if (null == menuItems || menuItems.Count == 0) + var menu = _exporter.GetNativeMenu(); + if (menu == null || menu.Items.Count == 0) { return; } @@ -156,10 +156,7 @@ namespace Avalonia.Win32 SizeToContent = SizeToContent.WidthAndHeight, Background = null, TransparencyLevelHint = new[] { WindowTransparencyLevel.Transparent }, - Content = new TrayIconMenuFlyoutPresenter() - { - ItemsSource = menuItems - } + Content = new TrayIconMenuFlyoutPresenter() { ItemsSource = menu.Items } }; GetCursorPos(out POINT pt); @@ -192,6 +189,12 @@ namespace Avalonia.Win32 host.Close(); } } + + protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return NativeMenuBarPresenter.CreateContainerForNativeItem(item, index, recycleKey) + ?? base.CreateContainerForItemOverride(item, index, recycleKey); + } } private class TrayPopupRoot : Window diff --git a/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs b/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs index ab4ec00b75..f66b0215f4 100644 --- a/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs +++ b/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs @@ -1,66 +1,16 @@ -using Avalonia.Reactive; -using Avalonia.Collections; -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Controls.Platform; -namespace Avalonia.Win32 -{ - internal class Win32NativeToManagedMenuExporter : INativeMenuExporter - { - private NativeMenu? _nativeMenu; - - public void SetNativeMenu(NativeMenu? nativeMenu) - { - _nativeMenu = nativeMenu; - } - - private static AvaloniaList Populate(NativeMenu nativeMenu) - { - var result = new AvaloniaList(); - - foreach (var menuItem in nativeMenu.Items) - { - if (menuItem is NativeMenuItemSeparator) - { - result.Add(new MenuItem { Header = "-" }); - } - else if (menuItem is NativeMenuItem item) - { - var newItem = new MenuItem - { - [!MenuItem.HeaderProperty] = item.GetObservable(NativeMenuItem.HeaderProperty).ToBinding(), - [!MenuItem.IconProperty] = item.GetObservable(NativeMenuItem.IconProperty) - .Select(i => i is {} bitmap ? new Image { Source = bitmap } : null).ToBinding(), - [!MenuItem.IsEnabledProperty] = item.GetObservable(NativeMenuItem.IsEnabledProperty).ToBinding(), - [!MenuItem.CommandProperty] = item.GetObservable(NativeMenuItem.CommandProperty).ToBinding(), - [!MenuItem.CommandParameterProperty] = item.GetObservable(NativeMenuItem.CommandParameterProperty).ToBinding(), - [!MenuItem.InputGestureProperty] = item.GetObservable(NativeMenuItem.GestureProperty).ToBinding() - }; - - if (item.Menu != null) - { - newItem.ItemsSource = Populate(item.Menu); - } - else if (item.HasClickHandlers && item is INativeMenuItemExporterEventsImplBridge bridge) - { - newItem.Click += (_, _) => bridge.RaiseClicked(); - } +namespace Avalonia.Win32; - result.Add(newItem); - } - } - - return result; - } - - public AvaloniaList? GetMenu() - { - if (_nativeMenu != null) - { - return Populate(_nativeMenu); - } +internal class Win32NativeToManagedMenuExporter : INativeMenuExporter +{ + private NativeMenu? _nativeMenu; - return null; - } + public void SetNativeMenu(NativeMenu? nativeMenu) + { + _nativeMenu = nativeMenu; } + + internal NativeMenu? GetNativeMenu() => _nativeMenu; } diff --git a/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs b/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs index 92a565490b..820f647667 100644 --- a/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs +++ b/tests/Avalonia.Controls.UnitTests/MenuItemTests.cs @@ -473,6 +473,301 @@ namespace Avalonia.Controls.UnitTests Assert.Same(items[0].Children, children[0].ItemsSource); } + [Fact] + public void Radio_MenuItem_In_Same_Group_Is_Unchecked() + { + using var app = Application(); + + MenuItem menuItem1, menuItem2, menuItem3; + + var menu = new Menu + { + Items = + { + (menuItem1 = new MenuItem + { + GroupName = "A", IsChecked = false, ToggleType = MenuItemToggleType.Radio + }), + (menuItem2 = new MenuItem + { + GroupName = "A", IsChecked = true, ToggleType = MenuItemToggleType.Radio + }), + (menuItem3 = new MenuItem + { + GroupName = "A", IsChecked = false, ToggleType = MenuItemToggleType.Radio + }) + } + }; + + var window = new Window { Content = menu }; + window.Show(); + + Assert.False(menuItem1.IsChecked); + Assert.True(menuItem2.IsChecked); + Assert.False(menuItem3.IsChecked); + + menuItem3.IsChecked = true; + + Assert.False(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.True(menuItem3.IsChecked); + } + + [Fact] + public void Radio_Menu_Group_Can_Be_Changed_In_Runtime() + { + using var app = Application(); + + MenuItem menuItem1, menuItem2, menuItem3; + + var menu = new Menu + { + Items = + { + (menuItem1 = new MenuItem + { + GroupName = "A", IsChecked = false, ToggleType = MenuItemToggleType.Radio + }), + (menuItem2 = new MenuItem + { + GroupName = "A", IsChecked = true, ToggleType = MenuItemToggleType.Radio + }), + (menuItem3 = new MenuItem + { + GroupName = null, IsChecked = false, ToggleType = MenuItemToggleType.Radio + }) + } + }; + + var window = new Window { Content = menu }; + window.Show(); + + Assert.False(menuItem1.IsChecked); + Assert.True(menuItem2.IsChecked); + Assert.False(menuItem3.IsChecked); + + menuItem3.GroupName = "A"; + menuItem3.IsChecked = true; + + Assert.False(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.True(menuItem3.IsChecked); + + menuItem3.GroupName = null; + menuItem1.IsChecked = true; + + Assert.True(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.True(menuItem3.IsChecked); + } + + [Fact] + public void Radio_MenuItem_In_Same_Group_But_Submenu_Is_Unchecked() + { + using var app = Application(); + + MenuItem menuItem1, menuItem2, menuItem3, menuItem4; + + var menu = new Menu + { + Items = + { + (menuItem1 = new MenuItem + { + GroupName = "A", IsChecked = false, ToggleType = MenuItemToggleType.Radio + }), + (menuItem2 = new MenuItem + { + GroupName = "A", IsChecked = false, ToggleType = MenuItemToggleType.Radio + }), + (menuItem3 = new MenuItem + { + GroupName = "A", + IsChecked = true, + ToggleType = MenuItemToggleType.Radio, + Items = + { + (menuItem4 = new MenuItem + { + GroupName = "A", + IsChecked = true, + ToggleType = MenuItemToggleType.Radio + }) + } + }), + } + }; + + var window = new Window { Content = menu }; + window.Show(); + + Assert.False(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.True(menuItem3.IsChecked); + Assert.True(menuItem4.IsChecked); + + menuItem2.IsChecked = true; + + Assert.False(menuItem1.IsChecked); + Assert.True(menuItem2.IsChecked); + Assert.False(menuItem3.IsChecked); + Assert.False(menuItem4.IsChecked); + } + + [Fact] + public void Radio_MenuItem_In_Same_Group_But_Submenu_Is_Checked() + { + using var app = Application(); + + MenuItem menuItem1, menuItem2, menuItem3, menuItem4; + + var menu = new Menu + { + Items = + { + (menuItem1 = new MenuItem + { + GroupName = "A", IsChecked = false, ToggleType = MenuItemToggleType.Radio + }), + (menuItem2 = new MenuItem + { + GroupName = "A", IsChecked = true, ToggleType = MenuItemToggleType.Radio + }), + (menuItem3 = new MenuItem + { + GroupName = "A", + IsChecked = false, + ToggleType = MenuItemToggleType.Radio, + Items = + { + (menuItem4 = new MenuItem + { + GroupName = "A", + IsChecked = false, + ToggleType = MenuItemToggleType.Radio + }) + } + }), + } + }; + + var window = new Window { Content = menu }; + window.Show(); + + Assert.False(menuItem1.IsChecked); + Assert.True(menuItem2.IsChecked); + Assert.False(menuItem3.IsChecked); + Assert.False(menuItem4.IsChecked); + + menuItem4.IsChecked = true; + + Assert.False(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.True(menuItem3.IsChecked); + Assert.True(menuItem4.IsChecked); + } + + [Fact] + public void Radio_MenuItem_Empty_GroupName_Not_Influence_Other_Groups() + { + using var app = Application(); + + MenuItem menuItem1, menuItem2, menuItem3, menuItem4; + + var menu = new Menu + { + Items = + { + (menuItem1 = new MenuItem + { + GroupName = "A", IsChecked = true, ToggleType = MenuItemToggleType.Radio + }), + (menuItem2 = new MenuItem + { + GroupName = "A", IsChecked = false, ToggleType = MenuItemToggleType.Radio + }), + (menuItem3 = new MenuItem + { + GroupName = null, IsChecked = false, ToggleType = MenuItemToggleType.Radio + }), + (menuItem4 = new MenuItem + { + GroupName = null, IsChecked = true, ToggleType = MenuItemToggleType.Radio + }) + } + }; + + var window = new Window { Content = menu }; + window.Show(); + + Assert.True(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.False(menuItem3.IsChecked); + Assert.True(menuItem4.IsChecked); + + menuItem3.IsChecked = true; + + Assert.True(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.True(menuItem3.IsChecked); + Assert.False(menuItem4.IsChecked); + } + + [Fact] + public void Radio_Menus_With_Empty_Group_On_Different_Levels_Can_Be_Checked_Simultaneously() + { + using var app = Application(); + + MenuItem menuItem1, menuItem2, menuItem3, menuItem4; + + var menu = new Menu + { + Items = + { + (menuItem1 = new MenuItem + { + GroupName = null, IsChecked = true, ToggleType = MenuItemToggleType.Radio + }), + (menuItem2 = new MenuItem + { + GroupName = null, + IsChecked = false, + ToggleType = MenuItemToggleType.Radio, + Items = + { + (menuItem3 = new MenuItem + { + GroupName = null, + IsChecked = false, + ToggleType = MenuItemToggleType.Radio + }), + (menuItem4 = new MenuItem + { + GroupName = null, + IsChecked = false, + ToggleType = MenuItemToggleType.Radio + }), + } + }) + } + }; + + var window = new Window { Content = menu }; + window.Show(); + + Assert.True(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.False(menuItem3.IsChecked); + Assert.False(menuItem4.IsChecked); + + menuItem3.IsChecked = true; + + Assert.True(menuItem1.IsChecked); + Assert.False(menuItem2.IsChecked); + Assert.True(menuItem3.IsChecked); + Assert.False(menuItem4.IsChecked); + } + private IDisposable Application() { var screen = new PixelRect(new PixelPoint(), new PixelSize(100, 100));