Browse Source

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 <grokys@users.noreply.github.com>
pull/14621/head
Max Katz 3 years ago
committed by GitHub
parent
commit
6c1341eb5e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      samples/ControlCatalog/App.xaml
  2. 16
      samples/ControlCatalog/MainWindow.xaml
  3. 46
      samples/ControlCatalog/Pages/MenuPage.xaml
  4. 4
      samples/ControlCatalog/ViewModels/ApplicationViewModel.cs
  5. 14
      samples/ControlCatalog/ViewModels/MainWindowViewModel.cs
  6. 1
      src/Avalonia.Controls/Avalonia.Controls.csproj
  7. 16
      src/Avalonia.Controls/IMenuItem.cs
  8. 4
      src/Avalonia.Controls/MenuBase.cs
  9. 154
      src/Avalonia.Controls/MenuItem.cs
  10. 22
      src/Avalonia.Controls/MenuItemToggleType.cs
  11. 68
      src/Avalonia.Controls/NativeMenuBar.cs
  12. 75
      src/Avalonia.Controls/NativeMenuBarPresenter.cs
  13. 37
      src/Avalonia.Controls/NativeMenuItem.cs
  14. 75
      src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs
  15. 156
      src/Avalonia.Controls/RadioButton.cs
  16. 135
      src/Avalonia.Controls/RadioButtonGroupManager.cs
  17. 4
      src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml
  18. 1
      src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
  19. 43
      src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml
  20. 30
      src/Avalonia.Themes.Fluent/Controls/NativeMenuBar.xaml
  21. 24
      src/Avalonia.Themes.Fluent/IBitmapToImageConverter.cs
  22. 51
      src/Avalonia.Themes.Simple/Controls/MenuItem.xaml
  23. 29
      src/Avalonia.Themes.Simple/Controls/NativeMenuBar.xaml
  24. 1
      src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
  25. 28
      src/Avalonia.Themes.Simple/IBitmapToImageConverter.cs
  26. 15
      src/Windows/Avalonia.Win32/TrayIconImpl.cs
  27. 70
      src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs
  28. 295
      tests/Avalonia.Controls.UnitTests/MenuItemTests.cs

8
samples/ControlCatalog/App.xaml

@ -69,11 +69,11 @@
<NativeMenu>
<NativeMenuItem Header="Settings">
<NativeMenu>
<NativeMenuItem Header="Option 1" ToggleType="Radio" IsChecked="True" Command="{Binding ToggleCommand}" />
<NativeMenuItem Header="Option 2" ToggleType="Radio" IsChecked="True" Command="{Binding ToggleCommand}" />
<NativeMenuItem Header="Option 1" ToggleType="Radio" IsChecked="True" />
<NativeMenuItem Header="Option 2" ToggleType="Radio" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="Option 3" ToggleType="CheckBox" IsChecked="True" Command="{Binding ToggleCommand}" />
<NativeMenuItem Icon="/Assets/test_icon.ico" Header="Restore Defaults" Command="{Binding ToggleCommand}" />
<NativeMenuItem Header="Option 3" ToggleType="CheckBox" IsChecked="True" />
<NativeMenuItem Icon="/Assets/test_icon.ico" Header="Restore Defaults" Command="{Binding RestoreDefault}" />
<NativeMenuItem Header="Disabled option" IsEnabled="False" />
</NativeMenu>
</NativeMenuItem>

16
samples/ControlCatalog/MainWindow.xaml

@ -38,17 +38,13 @@
<NativeMenuItem Header="Options">
<NativeMenu>
<NativeMenuItem Header="Check Me (None)"
Command="{Binding ToggleMenuItemCheckedCommand}"
ToggleType="None"
IsChecked="{Binding IsMenuItemChecked}" />
ToggleType="None" />
<NativeMenuItem Header="Check Me (CheckBox)"
Command="{Binding ToggleMenuItemCheckedCommand}"
ToggleType="CheckBox"
IsChecked="{Binding IsMenuItemChecked}" />
<NativeMenuItem Header="Check Me (Radio)"
Command="{Binding ToggleMenuItemCheckedCommand}"
ToggleType="Radio"
IsChecked="{Binding IsMenuItemChecked}" />
ToggleType="CheckBox" />
<NativeMenuItem Header="Check Me (Radio 1)"
ToggleType="Radio" />
<NativeMenuItem Header="Check Me (Radio 2)"
ToggleType="Radio" />
</NativeMenu>
</NativeMenuItem>
</NativeMenu>

46
samples/ControlCatalog/Pages/MenuPage.xaml

@ -31,34 +31,44 @@
<Image Source="/Assets/github_icon.png"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Menu Item with _Checkbox">
<MenuItem.Icon>
<CheckBox BorderThickness="0" IsHitTestVisible="False" IsChecked="True"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Menu Item with _Checkbox" ToggleType="CheckBox" />
</MenuItem>
<MenuItem Header="_Second">
<MenuItem Header="Second _Menu Item"/>
<MenuItem IsChecked="True" Header="Second _Menu toggle item" ToggleType="CheckBox" />
<Separator/>
<MenuItem GroupName="A" Header="Radio 1 - group" ToggleType="Radio" />
<MenuItem IsChecked="True" GroupName="A" Header="Radio 2 - group" ToggleType="Radio" />
<MenuItem GroupName="A" Header="Radio 3 - group" ToggleType="Radio">
<MenuItem Header="Radio 4 - group" ToggleType="Radio" GroupName="A" />
<MenuItem Header="Radio 5 - group" ToggleType="Radio" GroupName="A" />
</MenuItem>
<Separator/>
<MenuItem Header="Radio 1" ToggleType="Radio" />
<MenuItem IsChecked="True" Header="Radio 2" ToggleType="Radio" />
<MenuItem Header="Radio 3" ToggleType="Radio">
<MenuItem Header="Radio 4" ToggleType="Radio" />
<MenuItem Header="Radio 5" ToggleType="Radio" />
</MenuItem>
</MenuItem>
</Menu>
</StackPanel>
<StackPanel>
<TextBlock Classes="h3" Margin="4 8">Dyanamically generated</TextBlock>
<Menu ItemsSource="{Binding MenuItems}">
<Menu.Styles>
<Style Selector="MenuItem" x:DataType="viewModels:MenuItemViewModel">
<Setter Property="Header" Value="{Binding Header}"/>
<Setter Property="ItemsSource" Value="{Binding Items}"/>
<Setter Property="Command" Value="{Binding Command}"/>
<Setter Property="CommandParameter" Value="{Binding CommandParameter}"/>
</Style>
</Menu.Styles>
</Menu>
<TextBlock Classes="h3" Margin="4 8">Dyanamically generated</TextBlock>
<Menu ItemsSource="{Binding MenuItems}">
<Menu.Styles>
<Style Selector="MenuItem" x:DataType="viewModels:MenuItemViewModel">
<Setter Property="Header" Value="{Binding Header}"/>
<Setter Property="ItemsSource" Value="{Binding Items}"/>
<Setter Property="Command" Value="{Binding Command}"/>
<Setter Property="CommandParameter" Value="{Binding CommandParameter}"/>
</Style>
</Menu.Styles>
</Menu>
</StackPanel>
<StackPanel>
<StackPanel>
<TextBlock Classes="h3" Margin="4 8">Mixed</TextBlock>
<Menu>
<MenuItem Header="_File">

4
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; }
}
}

14
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<WindowState>();
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;
/// <summary>

1
src/Avalonia.Controls/Avalonia.Controls.csproj

@ -21,6 +21,7 @@
<InternalsVisibleTo Include="Avalonia.LeakTests, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Headless, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Native, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Win32, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.X11, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.DesignerSupport.Remote, PublicKey=$(AvaloniaPublicKey)" />
<InternalsVisibleTo Include="Avalonia.Browser, PublicKey=$(AvaloniaPublicKey)" />

16
src/Avalonia.Controls/IMenuItem.cs

@ -39,6 +39,22 @@ namespace Avalonia.Controls
/// </summary>
IMenuElement? Parent { get; }
/// <summary>
/// Gets toggle type of the menu item.
/// </summary>
MenuItemToggleType ToggleType { get; }
/// <summary>
/// Gets menu item group name when <see cref="ToggleType"/> is <see cref="MenuItemToggleType.Radio"/>.
/// </summary>
string? GroupName { get; }
/// <summary>
/// Gets or sets if menu item is checked when <see cref="ToggleType"/> is
/// <see cref="MenuItemToggleType.CheckBox"/> or <see cref="MenuItemToggleType.Radio"/>.
/// </summary>
bool IsChecked { get; set; }
/// <summary>
/// Raises a click event on the menu item.
/// </summary>

4
src/Avalonia.Controls/MenuBase.cs

@ -92,12 +92,12 @@ namespace Avalonia.Controls
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems => GetRealizedContainers().OfType<IMenuItem>();
IEnumerable<IMenuItem> IMenuElement.SubItems => LogicalChildren.OfType<IMenuItem>();
/// <summary>
/// Gets the interaction handler for the menu.
/// </summary>
protected IMenuInteractionHandler InteractionHandler { get; }
protected internal IMenuInteractionHandler InteractionHandler { get; }
/// <summary>
/// Occurs when a <see cref="Menu"/> is opened.

154
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.
/// </summary>
[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
{
/// <summary>
/// Defines the <see cref="Command"/> property.
@ -65,6 +66,24 @@ namespace Avalonia.Controls
public static readonly StyledProperty<bool> StaysOpenOnClickProperty =
AvaloniaProperty.Register<MenuItem, bool>(nameof(StaysOpenOnClick));
/// <summary>
/// Defines the <see cref="ToggleType"/> property.
/// </summary>
public static readonly StyledProperty<MenuItemToggleType> ToggleTypeProperty =
AvaloniaProperty.Register<MenuItem, MenuItemToggleType>(nameof(ToggleType));
/// <summary>
/// Defines the <see cref="IsChecked"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsCheckedProperty =
AvaloniaProperty.Register<MenuItem, bool>(nameof(IsChecked));
/// <summary>
/// Defines the <see cref="GroupName"/> property.
/// </summary>
public static readonly StyledProperty<string?> GroupNameProperty =
RadioButton.GroupNameProperty.AddOwner<MenuItem>();
/// <summary>
/// Defines the <see cref="Click"/> event.
/// </summary>
@ -116,16 +135,10 @@ namespace Avalonia.Controls
{
SelectableMixin.Attach<MenuItem>(IsSelectedProperty);
PressedMixin.Attach<MenuItem>();
CommandProperty.Changed.Subscribe(CommandChanged);
CommandParameterProperty.Changed.Subscribe(CommandParameterChanged);
FocusableProperty.OverrideDefaultValue<MenuItem>(true);
HeaderProperty.Changed.AddClassHandler<MenuItem>((x, e) => x.HeaderChanged(e));
IconProperty.Changed.AddClassHandler<MenuItem>((x, e) => x.IconChanged(e));
IsSelectedProperty.Changed.AddClassHandler<MenuItem>((x, e) => x.IsSelectedChanged(e));
ItemsPanelProperty.OverrideDefaultValue<MenuItem>(DefaultPanel);
ClickEvent.AddClassHandler<MenuItem>((x, e) => x.OnClick(e));
SubmenuOpenedEvent.AddClassHandler<MenuItem>((x, e) => x.OnSubmenuOpened(e));
IsSubMenuOpenProperty.Changed.AddClassHandler<MenuItem>((x, e) => x.SubMenuOpenChanged(e));
}
public MenuItem()
@ -279,7 +292,34 @@ namespace Avalonia.Controls
get => GetValue(StaysOpenOnClickProperty);
set => SetValue(StaysOpenOnClickProperty, value);
}
/// <inheritdoc cref="IMenuItem.ToggleType" />
public MenuItemToggleType ToggleType
{
get => GetValue(ToggleTypeProperty);
set => SetValue(ToggleTypeProperty, value);
}
/// <inheritdoc cref="IMenuItem.IsChecked"/>
public bool IsChecked
{
get => GetValue(IsCheckedProperty);
set => SetValue(IsCheckedProperty, value);
}
bool IRadioButton.IsChecked
{
get => IsChecked;
set => SetCurrentValue(IsCheckedProperty, value);
}
/// <inheritdoc cref="IMenuItem.GroupName"/>
public string? GroupName
{
get => GetValue(GroupNameProperty);
set => SetValue(GroupNameProperty, value);
}
/// <summary>
/// Gets or sets a value that indicates whether the <see cref="MenuItem"/> has a submenu.
/// </summary>
@ -315,7 +355,9 @@ namespace Avalonia.Controls
}
/// <inheritdoc/>
IEnumerable<IMenuItem> IMenuElement.SubItems => GetRealizedContainers().OfType<IMenuItem>();
IEnumerable<IMenuItem> IMenuElement.SubItems => LogicalChildren.OfType<IMenuItem>();
private IMenuInteractionHandler? MenuInteractionHandler => this.FindLogicalAncestorOfType<MenuBase>()?.InteractionHandler;
/// <summary>
/// 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);
}
}
/// <summary>
/// Called when the <see cref="GroupName"/> property changes.
/// </summary>
/// <param name="e">The property change event.</param>
private void GroupNameChanged(AvaloniaPropertyChangedEventArgs e)
{
(MenuInteractionHandler as DefaultMenuInteractionHandler)?.OnGroupOrTypeChanged(this, e.GetOldValue<string>());
}
/// <summary>
/// Called when the <see cref="ToggleType"/> property changes.
/// </summary>
/// <param name="e">The property change event.</param>
private void ToggleTypeChanged(AvaloniaPropertyChangedEventArgs e)
{
var newValue = e.GetNewValue<MenuItemToggleType>();
PseudoClasses.Set(":radio", newValue == MenuItemToggleType.Radio);
PseudoClasses.Set(":toggle", newValue == MenuItemToggleType.CheckBox);
(MenuInteractionHandler as DefaultMenuInteractionHandler)?.OnGroupOrTypeChanged(this, GroupName);
}
/// <summary>
/// Called when the <see cref="IsChecked"/> property changes.
/// </summary>
/// <param name="e">The property change event.</param>
private void IsCheckedChanged(AvaloniaPropertyChangedEventArgs e)
{
var newValue = e.GetNewValue<bool>();
PseudoClasses.Set(":checked", newValue);
if (newValue)
{
(MenuInteractionHandler as DefaultMenuInteractionHandler)?.OnCheckedChanged(this);
}
}
/// <summary>
/// Called when the <see cref="HeaderedSelectingItemsControl.Header"/> property changes.
/// </summary>
/// <param name="e">The property change event.</param>
private void HeaderChanged(AvaloniaPropertyChangedEventArgs e)
{
if (e.NewValue is string newValue && newValue == "-")
var (oldValue, newValue) = e.GetOldAndNewValue<object?>();
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
/// <param name="e">The property change event.</param>
private void IconChanged(AvaloniaPropertyChangedEventArgs e)
{
var oldValue = e.OldValue as ILogical;
var newValue = e.NewValue as ILogical;
var (oldValue, newValue) = e.GetOldAndNewValue<object?>();
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");
}
}

22
src/Avalonia.Controls/MenuItemToggleType.cs

@ -0,0 +1,22 @@
namespace Avalonia.Controls;
/// <summary>
/// Defines how a <see cref="MenuItem"/> or <see cref="NativeMenuItem"/> reacts to clicks.
/// </summary>
public enum MenuItemToggleType
{
/// <summary>
/// Normal menu item.
/// </summary>
None,
/// <summary>
/// Toggleable menu item with a checkbox.
/// </summary>
CheckBox,
/// <summary>
/// Menu item representing single option of radio group.
/// </summary>
Radio
}

68
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<bool> EnableMenuItemClickForwardingProperty =
AvaloniaProperty.RegisterAttached<NativeMenuBar, MenuItem, Boolean>(
AvaloniaProperty.RegisterAttached<NativeMenuBar, MenuItem, bool>(
"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<NativeMenuBar>(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<MenuBase>("PART_NativeMenuPresenter")
?? this.FindDescendantOfType<MenuBase>()
?? 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<bool>())),
menu.Bind(ItemsControl.ItemsSourceProperty, topLevel.GetBindingObservable(NativeMenu.MenuProperty)
.Select(v => v.GetValueOrDefault<NativeMenu>()?.Items)));
}
}
}

75
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<object?>(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);
}
}
}

37
src/Avalonia.Controls/NativeMenuItem.cs

@ -54,18 +54,22 @@ namespace Avalonia.Controls
return value;
}
/// <inheritdoc cref="MenuItem.IconProperty"/>
public static readonly StyledProperty<Bitmap?> IconProperty =
AvaloniaProperty.Register<NativeMenuItem, Bitmap?>(nameof(Icon));
/// <inheritdoc cref="MenuItem.Icon"/>
public Bitmap? Icon
{
get => GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
/// <inheritdoc cref="MenuItem.HeaderProperty"/>
public static readonly StyledProperty<string?> HeaderProperty =
AvaloniaProperty.Register<NativeMenuItem, string?>(nameof(Header));
/// <inheritdoc cref="MenuItem.Header"/>
public string? Header
{
get => GetValue(HeaderProperty);
@ -89,45 +93,51 @@ namespace Avalonia.Controls
set => SetValue(ToolTipProperty, value);
}
/// <inheritdoc cref="MenuItem.InputGestureProperty"/>
public static readonly StyledProperty<KeyGesture?> GestureProperty =
AvaloniaProperty.Register<NativeMenuItem, KeyGesture?>(nameof(Gesture));
/// <inheritdoc cref="MenuItem.InputGesture"/>
public KeyGesture? Gesture
{
get => GetValue(GestureProperty);
set => SetValue(GestureProperty, value);
}
/// <inheritdoc cref="MenuItem.IsCheckedProperty"/>
public static readonly StyledProperty<bool> IsCheckedProperty =
AvaloniaProperty.Register<NativeMenuItem, bool>(nameof(IsChecked));
MenuItem.IsCheckedProperty.AddOwner<NativeMenuItem>();
/// <inheritdoc cref="MenuItem.IsChecked"/>
public bool IsChecked
{
get => GetValue(IsCheckedProperty);
set => SetValue(IsCheckedProperty, value);
}
/// <inheritdoc cref="MenuItem.ToggleTypeProperty"/>
public static readonly StyledProperty<NativeMenuItemToggleType> ToggleTypeProperty =
AvaloniaProperty.Register<NativeMenuItem, NativeMenuItemToggleType>(nameof(ToggleType));
/// <inheritdoc cref="MenuItem.ToggleType"/>
public NativeMenuItemToggleType ToggleType
{
get => GetValue(ToggleTypeProperty);
set => SetValue(ToggleTypeProperty, value);
}
/// <inheritdoc cref="MenuItem.CommandProperty"/>
public static readonly StyledProperty<ICommand?> CommandProperty =
Button.CommandProperty.AddOwner<NativeMenuItem>(new(enableDataValidation: true));
MenuItem.CommandProperty.AddOwner<NativeMenuItem>(new(enableDataValidation: true));
/// <summary>
/// Defines the <see cref="CommandParameter"/> property.
/// </summary>
/// <inheritdoc cref="MenuItem.CommandParameterProperty"/>
public static readonly StyledProperty<object?> CommandParameterProperty =
Button.CommandParameterProperty.AddOwner<NativeMenuItem>();
MenuItem.CommandParameterProperty.AddOwner<NativeMenuItem>();
public static readonly StyledProperty<bool> IsEnabledProperty =
AvaloniaProperty.Register<NativeMenuItem, bool>(nameof(IsEnabled), true);
/// <inheritdoc cref="MenuItem.IsEnabled"/>
public bool IsEnabled
{
get => GetValue(IsEnabledProperty);
@ -141,16 +151,14 @@ namespace Avalonia.Controls
public bool HasClickHandlers => Click != null;
/// <inheritdoc cref="MenuItem.Command"/>
public ICommand? Command
{
get => GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
/// <summary>
/// Gets or sets the parameter to pass to the <see cref="Command"/> property of a
/// <see cref="NativeMenuItem"/>.
/// </summary>
/// <inheritdoc cref="MenuItem.CommandParameter"/>
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
}
}

75
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<Action, TimeSpan> 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);
}
}
}
}

156
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
/// <summary>
/// Represents a button that allows a user to select a single option from a group of options.
/// </summary>
public class RadioButton : ToggleButton
public class RadioButton : ToggleButton, IRadioButton
{
private class RadioButtonGroupManager
{
public static readonly RadioButtonGroupManager Default = new RadioButtonGroupManager();
static readonly ConditionalWeakTable<IRenderRoot, RadioButtonGroupManager> s_registeredVisualRoots
= new ConditionalWeakTable<IRenderRoot, RadioButtonGroupManager>();
readonly Dictionary<string, List<WeakReference<RadioButton>>> s_registeredGroups
= new Dictionary<string, List<WeakReference<RadioButton>>>();
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<WeakReference<RadioButton>>();
s_registeredGroups.Add(groupName, group);
}
group.Add(new WeakReference<RadioButton>(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);
}
}
}
}
}
/// <summary>
/// Identifies the GroupName dependency property.
/// </summary>
public static readonly StyledProperty<string?> GroupNameProperty =
AvaloniaProperty.Register<RadioButton, string?>(nameof(GroupName));
private RadioButtonGroupManager? _groupManager;
/// <summary>
/// Gets or sets the name that specifies which RadioButton controls are mutually exclusive.
/// </summary>
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<RadioButton>()
.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);
}
}
}

135
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<IRenderRoot, RadioButtonGroupManager> s_registeredVisualRoots = new();
private readonly Dictionary<string, List<WeakReference<IRadioButton>>> _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<WeakReference<IRadioButton>>();
_registeredGroups.Add(groupName, group);
}
group.Add(new WeakReference<IRadioButton>(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;
}
}
}

4
src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml

@ -12,6 +12,8 @@
</Border>
</Design.PreviewWith>
<StreamGeometry x:Key="CheckMarkPathData">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</StreamGeometry>
<ControlTheme x:Key="{x:Type CheckBox}" TargetType="CheckBox">
<Setter Property="Padding" Value="8,0,0,0" />
<Setter Property="HorizontalAlignment" Value="Left" />
@ -144,7 +146,7 @@
<Style Selector="^ /template/ Path#CheckGlyph">
<Setter Property="Fill" Value="{DynamicResource CheckBoxCheckGlyphForegroundChecked}" />
<Setter Property="Data" Value="M1507 31L438 1101L-119 543L-29 453L438 919L1417 -59L1507 31Z" />
<Setter Property="Data" Value="{StaticResource CheckMarkPathData}" />
<Setter Property="Width" Value="9" />
<Setter Property="Opacity" Value="1" />
</Style>

1
src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml

@ -32,7 +32,6 @@
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/Menu.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/MenuItem.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/NativeMenuBar.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml" />

43
src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml

@ -71,6 +71,8 @@
CornerRadius="{TemplateBinding CornerRadius}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"
SharedSizeGroup="ToggleIcon" />
<ColumnDefinition Width="Auto"
SharedSizeGroup="MenuItemIcon" />
<ColumnDefinition Width="*" />
@ -80,10 +82,17 @@
SharedSizeGroup="MenuItemChevron" />
</Grid.ColumnDefinitions>
<ContentControl x:Name="PART_IconPresenter"
<ContentControl Grid.Column="0"
x:Name="PART_ToggleIconPresenter"
IsVisible="False"
Theme="{StaticResource FluentMenuItemIconTheme}"
Content="{TemplateBinding Icon}"
Margin="{DynamicResource MenuIconPresenterMargin}" />
<ContentControl Grid.Column="1"
x:Name="PART_IconPresenter"
IsVisible="False"
Theme="{StaticResource FluentMenuItemIconTheme}"
Content="{TemplateBinding Icon}"
Margin="{DynamicResource MenuIconPresenterMargin}" />
<ContentPresenter Name="PART_HeaderPresenter"
@ -92,10 +101,9 @@
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
RecognizesAccessKey="True"
Grid.Column="1"/>
Grid.Column="2"/>
<TextBlock x:Name="PART_InputGestureText"
Grid.Column="2"
Classes="CaptionTextBlockStyle"
Grid.Column="3"
Margin="{DynamicResource MenuInputGestureTextMargin}"
Text="{TemplateBinding InputGesture, Converter={StaticResource KeyGestureConverter}}"
HorizontalAlignment="Right"
@ -200,11 +208,36 @@
</ControlTemplate>
</Setter>
</Style>
<Style Selector="^ /template/ Image#PART_ToggleIconImage">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:checked:toggle /template/ ContentControl#PART_ToggleIconPresenter">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Content">
<Template>
<Path Fill="{Binding $parent[MenuItem].Foreground}"
Width="16"
Data="{StaticResource CheckMarkPathData}" />
</Template>
</Setter>
</Style>
<Style Selector="^:checked:radio /template/ ContentControl#PART_ToggleIconPresenter">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Content">
<Template>
<Ellipse Fill="{Binding $parent[MenuItem].Foreground}"
Margin="4" Width="8" Height="8" />
</Template>
</Setter>
</Style>
</ControlTheme>
<ControlTheme x:Key="HorizontalMenuItem" TargetType="MenuItem" BasedOn="{StaticResource FluentTopLevelMenuItem}">
<Setter Property="Padding" Value="{DynamicResource HorizontalMenuFlyoutItemThemePaddingNarrow}" />
<Setter Property="Margin" Value="{DynamicResource HorizontalMenuFlyoutItemMargin}" />
</ControlTheme>
<ControlTheme x:Key="FluentMenuItemIconTheme"
TargetType="ContentControl">
<Setter Property="Width"

30
src/Avalonia.Themes.Fluent/Controls/NativeMenuBar.xaml

@ -1,30 +0,0 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Avalonia.Themes.Fluent"
x:ClassModifier="internal">
<local:IBitmapToImageConverter x:Key="AvaloniaThemesFluentNativeMenuBarIBitmapToImageConverter"/>
<ControlTheme x:Key="{x:Type NativeMenuBar}" TargetType="NativeMenuBar">
<Setter Property="Template">
<ControlTemplate>
<Menu
IsVisible="{Binding !$parent[TopLevel].(NativeMenu.IsNativeMenuExported)}"
ItemsSource="{Binding $parent[TopLevel].(NativeMenu.Menu).Items}">
<Menu.Styles>
<Style Selector="MenuItem" x:DataType="NativeMenuItem">
<Setter Property="Header" Value="{Binding Header}"/>
<Setter Property="ToolTip.Tip" Value="{Binding ToolTip}"/>
<Setter Property="IsEnabled" Value="{Binding IsEnabled}"/>
<Setter Property="InputGesture" Value="{Binding Gesture}"/>
<Setter Property="ItemsSource" Value="{Binding Menu.Items}"/>
<Setter Property="Command" Value="{Binding Command}"/>
<Setter Property="CommandParameter" Value="{Binding CommandParameter}"/>
<Setter Property="(NativeMenuBar.EnableMenuItemClickForwarding)" Value="True"/>
<!--NativeMenuItem is IBitmap and MenuItem is Image-->
<Setter Property="Icon" Value="{Binding Icon , Converter={StaticResource AvaloniaThemesFluentNativeMenuBarIBitmapToImageConverter}}"/>
</Style>
</Menu.Styles>
</Menu>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

24
src/Avalonia.Themes.Fluent/IBitmapToImageConverter.cs

@ -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();
}
}
}

51
src/Avalonia.Themes.Simple/Controls/MenuItem.xaml

@ -26,19 +26,20 @@
SharedSizeGroup="MenuItemIGT" />
<ColumnDefinition Width="20" />
</Grid.ColumnDefinitions>
<ContentPresenter Name="icon"
Width="16"
Height="16"
Margin="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}" />
<Path Name="check"
Margin="3"
VerticalAlignment="Center"
Data="F1M10,1.2L4.7,9.1 4.5,9.1 0,5.2 1.3,3.5 4.3,6.1 8.3,0 10,1.2z"
Fill="{TemplateBinding Foreground}"
IsVisible="False" />
<ContentControl Grid.Column="0"
x:Name="PART_ToggleIconPresenter"
IsVisible="False"
Margin="3"
Width="16"
Height="16" />
<ContentControl Grid.Column="0"
Name="PART_IconPresenter"
Width="16"
Height="16"
Margin="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}" />
<ContentPresenter Name="PART_HeaderPresenter"
Grid.Column="2"
Margin="{TemplateBinding Padding}"
@ -103,5 +104,29 @@
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="{DynamicResource ThemeDisabledOpacity}" />
</Style>
<Style Selector="^:toggle /template/ Viewbox#PART_ToggleIconPresenter, ^:radio /template/ Viewbox#PART_ToggleIconPresenter">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:toggle /template/ ContentPresenter#PART_IconPresenter, ^:radio /template/ ContentPresenter#PART_IconPresenter">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="^:checked:toggle /template/ ContentControl#PART_ToggleIconPresenter">
<Setter Property="Content">
<Template>
<Path VerticalAlignment="Center"
Data="F1M10,1.2L4.7,9.1 4.5,9.1 0,5.2 1.3,3.5 4.3,6.1 8.3,0 10,1.2z"
Fill="{Binding $parent[MenuItem].Foreground}" />
</Template>
</Setter>
</Style>
<Style Selector="^:checked:radio /template/ ContentControl#PART_ToggleIconPresenter">
<Setter Property="Content">
<Template>
<Ellipse Fill="{Binding $parent[MenuItem].Foreground}"
Margin="4" Width="8" Height="8" />
</Template>
</Setter>
</Style>
</ControlTheme>
</ResourceDictionary>

29
src/Avalonia.Themes.Simple/Controls/NativeMenuBar.xaml

@ -1,29 +0,0 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:default="using:Avalonia.Themes.Simple"
x:ClassModifier="internal">
<default:IBitmapToImageConverter x:Key="AvaloniaThemesSimpleNativeMenuBarIBitmapToImageConverter" />
<ControlTheme x:Key="{x:Type NativeMenuBar}"
TargetType="NativeMenuBar">
<Setter Property="Template">
<ControlTemplate>
<Menu IsVisible="{Binding !$parent[TopLevel].(NativeMenu.IsNativeMenuExported)}"
ItemsSource="{Binding $parent[TopLevel].(NativeMenu.Menu).Items}">
<Menu.Styles>
<Style Selector="MenuItem" x:DataType="NativeMenuItem">
<Setter Property="Header" Value="{Binding Header}"/>
<Setter Property="IsEnabled" Value="{Binding IsEnabled}"/>
<Setter Property="InputGesture" Value="{Binding Gesture}"/>
<Setter Property="ItemsSource" Value="{Binding Menu.Items}"/>
<Setter Property="Command" Value="{Binding Command}"/>
<Setter Property="CommandParameter" Value="{Binding CommandParameter}"/>
<Setter Property="(NativeMenuBar.EnableMenuItemClickForwarding)" Value="True"/>
<!--NativeMenuItem is IBitmap and MenuItem is Image-->
<Setter Property="Icon" Value="{Binding Icon , Converter={StaticResource AvaloniaThemesSimpleNativeMenuBarIBitmapToImageConverter}}"/>
</Style>
</Menu.Styles>
</Menu>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

1
src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml

@ -59,7 +59,6 @@
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/CalendarDatePicker.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/Slider.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/NotificationCard.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/NativeMenuBar.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/NumericUpDown.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/DateTimePickerShared.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/DatePicker.xaml" />

28
src/Avalonia.Themes.Simple/IBitmapToImageConverter.cs

@ -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();
}
}
}

15
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

70
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<MenuItem> Populate(NativeMenu nativeMenu)
{
var result = new AvaloniaList<MenuItem>();
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<MenuItem>? 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;
}

295
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));

Loading…
Cancel
Save