Browse Source

[macOS] Add NativeDock.Menu API for adding menu items to macOS dock icon (#20634)

* Native DockMenu code

* Add Native Interop

* Update ControlCatalog sample

* Add unit tests

* Add Action<IAvnMenu> to AvaloniaNativeMenuExporter

* Add dynamic dock item demo

* Move s_dockMenu reference to App

* Use Appium tests

* Revert INativeMenuExporterResetHandler

* Properly set the button for the checkbox

* Add dock test

* I hate Appium

* Rename NativeMenu.DockMenu to NativeDock.Menu

* Make static

* Remove Dock Click Test

* Add white space back for cleaner diff

* Reduce MenuExporter back to one

* Revert UpdateIfNeeded to private

* Revert QueueReset to private too... and fix some whitespace

* Revert IAvnMenuItem/IAvnMenu back

* That's what I get not comparing it to master

* And update this too

* Add documentation
pull/20719/head
Tim Miller 1 month ago
committed by GitHub
parent
commit
ef70264236
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      native/Avalonia.Native/src/OSX/app.mm
  2. 1
      native/Avalonia.Native/src/OSX/common.h
  3. 19
      native/Avalonia.Native/src/OSX/main.mm
  4. 9
      samples/ControlCatalog/App.xaml
  5. 34
      samples/ControlCatalog/App.xaml.cs
  6. 7
      samples/IntegrationTestApp/App.axaml
  7. 25
      samples/IntegrationTestApp/App.axaml.cs
  8. 6
      samples/IntegrationTestApp/Pages/DesktopPage.axaml
  9. 10
      samples/IntegrationTestApp/Pages/DesktopPage.axaml.cs
  10. 28
      src/Avalonia.Controls/NativeDock.cs
  11. 135
      src/Avalonia.Native/AvaloniaNativeMenuExporter.cs
  12. 5
      src/Avalonia.Native/AvaloniaNativePlatform.cs
  13. 1
      src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs
  14. 1
      src/Avalonia.Native/avn.idl
  15. 41
      tests/Avalonia.IntegrationTests.Appium/NativeMenuTests.cs

12
native/Avalonia.Native/src/OSX/app.mm

@ -1,11 +1,13 @@
#include "common.h"
#include "AvnString.h"
#include "menu.h"
@interface AvnAppDelegate : NSObject<NSApplicationDelegate>
-(AvnAppDelegate* _Nonnull) initWithEvents: (IAvnApplicationEvents* _Nonnull) events;
-(void) releaseEvents;
@end
NSApplicationActivationPolicy AvnDesiredActivationPolicy = NSApplicationActivationPolicyRegular;
static NSMenu* s_dockMenu = nil;
@implementation AvnAppDelegate
ComPtr<IAvnApplicationEvents> _events;
@ -86,6 +88,11 @@ ComPtr<IAvnApplicationEvents> _events;
return _events->TryShutdown() ? NSTerminateNow : NSTerminateCancel;
}
- (NSMenu *)applicationDockMenu:(NSApplication *)sender
{
return s_dockMenu;
}
@end
@interface AvnApplication : NSApplication
@ -180,3 +187,8 @@ extern IAvnApplicationCommands* CreateApplicationCommands()
{
return new AvnApplicationCommands();
}
extern void SetDockMenu(NSMenu* menu)
{
s_dockMenu = menu;
}

1
native/Avalonia.Native/src/OSX/common.h

@ -38,6 +38,7 @@ extern void SetAppMenu(IAvnMenu *menu);
extern void SetServicesMenu (IAvnMenu* menu);
extern IAvnMenu* GetAppMenu ();
extern NSMenuItem* GetAppMenuItem ();
extern void SetDockMenu(NSMenu* menu);
extern void InitializeAvnApp(IAvnApplicationEvents* events, bool disableAppDelegate);
extern void ReleaseAvnAppEvents();

19
native/Avalonia.Native/src/OSX/main.mm

@ -1,6 +1,7 @@
//This file will contain actual IID structures
#define COM_GUIDS_MATERIALIZE
#include "common.h"
#include "menu.h"
static NSString* s_appTitle = @"Avalonia";
static int disableSetProcessName = 0;
@ -475,14 +476,24 @@ public:
return *ppv != nullptr ? S_OK : E_FAIL;
}
HRESULT CreateMemoryManagementHelper(IAvnNativeObjectsMemoryManagement **ppv) override {
HRESULT CreateMemoryManagementHelper(IAvnNativeObjectsMemoryManagement **ppv) override {
START_COM_CALL;
*ppv = ::CreateMemoryManagementHelper();
return S_OK;
}
virtual HRESULT SetDockMenu(IAvnMenu* dockMenu) override
{
START_COM_CALL;
@autoreleasepool
{
auto nativeMenu = dynamic_cast<AvnAppMenu*>(dockMenu);
::SetDockMenu(nativeMenu != nullptr ? nativeMenu->GetNative() : nil);
return S_OK;
}
}
};
extern "C" IAvaloniaNativeFactory* CreateAvaloniaNative()

9
samples/ControlCatalog/App.xaml

@ -59,6 +59,15 @@
<Setter Property="FontSize" Value="12" />
</Style>
</Application.Styles>
<NativeDock.Menu>
<NativeMenu>
<NativeMenuItem Header="New Window" Click="OnDockNewWindowClicked"/>
<NativeMenuItemSeparator/>
<NativeMenuItem Header="Show Main Window" Click="OnDockShowMainWindowClicked"/>
<NativeMenuItemSeparator/>
<NativeMenuItem Header="Add Dock Menu Item" Click="OnDockAddItemClicked"/>
</NativeMenu>
</NativeDock.Menu>
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/test_icon.ico" MacOSProperties.IsTemplateIcon="true" ToolTipText="Avalonia Tray Icon ToolTip">

34
samples/ControlCatalog/App.xaml.cs

@ -64,6 +64,40 @@ namespace ControlCatalog
base.OnFrameworkInitializationCompleted();
}
public void OnDockNewWindowClicked(object? sender, EventArgs e)
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime)
{
var window = new MainWindow();
window.Show();
}
}
public void OnDockShowMainWindowClicked(object? sender, EventArgs e)
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
{
desktopLifetime.MainWindow?.Activate();
}
}
private int _dockMenuItemCount;
public void OnDockAddItemClicked(object? sender, EventArgs e)
{
var dockMenu = NativeDock.GetMenu(this);
if (dockMenu is not null)
{
_dockMenuItemCount++;
var item = new NativeMenuItem($"New item {_dockMenuItemCount}");
item.Click += (_, _) =>
{
dockMenu.Items.Remove(item);
};
dockMenu.Items.Insert(0, item);
}
}
private CatalogTheme _prevTheme;
public static CatalogTheme CurrentTheme => ((App)Current!)._prevTheme;
public static void SetCatalogThemes(CatalogTheme theme)

7
samples/IntegrationTestApp/App.axaml

@ -7,6 +7,13 @@
<Application.Styles>
<FluentTheme />
</Application.Styles>
<NativeDock.Menu>
<NativeMenu>
<NativeMenuItem Header="Show Main Window"
Command="{Binding DockMenuCommand}"
CommandParameter="DockMenuShowMainWindow"/>
</NativeMenu>
</NativeDock.Menu>
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/icon.ico"

25
samples/IntegrationTestApp/App.axaml.cs

@ -1,8 +1,10 @@
using System.Linq;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.LogicalTree;
using Avalonia.Markup.Xaml;
using MiniMvvm;
@ -18,6 +20,13 @@ namespace IntegrationTestApp
{
_mainWindow!.Get<CheckBox>(name).IsChecked = true;
});
DockMenuCommand = MiniCommand.Create<string>(name =>
{
// This is for the "Show Main Window" dock menu item in the test.
// It doesn't actually show the main window, but sets the checkbox to true in the page.
var checkbox = _mainWindow!.GetLogicalDescendants().OfType<CheckBox>().FirstOrDefault(x => x.Name == name);
if (checkbox != null) checkbox.IsChecked = true;
});
DataContext = this;
}
@ -37,5 +46,21 @@ namespace IntegrationTestApp
}
public ICommand TrayIconCommand { get; }
public ICommand DockMenuCommand { get; }
public void AddDockMenuItem(string header)
{
var dockMenu = NativeDock.GetMenu(this);
if (dockMenu is not null)
{
dockMenu.Items.Insert(0, new NativeMenuItem(header));
}
}
public int GetDockMenuItemCount()
{
var dockMenu = NativeDock.GetMenu(this);
return dockMenu?.Items.Count ?? 0;
}
}
}

6
samples/IntegrationTestApp/Pages/DesktopPage.axaml

@ -10,5 +10,11 @@
<Button Name="ToggleTrayIconVisible"
Content="Toggle TrayIcon Visible"
Click="ToggleTrayIconVisible_Click"/>
<Separator Margin="0,8"/>
<CheckBox x:FieldModifier="public" Name="DockMenuShowMainWindow">Dock Menu Show Main Window Clicked</CheckBox>
<Button Name="AddDockMenuItem"
Content="Add Dock Menu Item"
Click="AddDockMenuItem_Click"/>
<TextBlock Name="DockMenuItemCount" Text="0"/>
</StackPanel>
</UserControl>

10
samples/IntegrationTestApp/Pages/DesktopPage.axaml.cs

@ -6,6 +6,8 @@ namespace IntegrationTestApp.Pages;
public partial class DesktopPage : UserControl
{
private int _dockMenuItemCount;
public DesktopPage()
{
InitializeComponent();
@ -16,4 +18,12 @@ public partial class DesktopPage : UserControl
var icon = TrayIcon.GetIcons(Application.Current!)!.FirstOrDefault()!;
icon.IsVisible = !icon.IsVisible;
}
private void AddDockMenuItem_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var app = (App)Application.Current!;
_dockMenuItemCount++;
app.AddDockMenuItem($"Dynamic Item {_dockMenuItemCount}");
DockMenuItemCount.Text = app.GetDockMenuItemCount().ToString();
}
}

28
src/Avalonia.Controls/NativeDock.cs

@ -0,0 +1,28 @@
namespace Avalonia.Controls
{
/// <summary>
/// Allows native menu support on platforms where a <see cref="NativeMenu"/> can be attached to the dock.
/// </summary>
public static class NativeDock
{
/// <summary>
/// Defines the Menu attached property.
/// </summary>
public static readonly AttachedProperty<NativeMenu?> MenuProperty =
AvaloniaProperty.RegisterAttached<AvaloniaObject, NativeMenu?>("Menu", typeof(NativeDock));
/// <summary>
/// Sets the value of the attached <see cref="MenuProperty"/>.
/// </summary>
/// <param name="o">The control to set the menu for.</param>
/// <param name="menu">The menu to set.</param>
public static void SetMenu(AvaloniaObject o, NativeMenu? menu) => o.SetValue(MenuProperty, menu);
/// <summary>
/// Gets the value of the attached <see cref="MenuProperty"/>.
/// </summary>
/// <param name="o">The control to get the menu for.</param>
/// <returns>The menu of the control.</returns>
public static NativeMenu? GetMenu(AvaloniaObject o) => o.GetValue(MenuProperty);
}
}

135
src/Avalonia.Native/AvaloniaNativeMenuExporter.cs

@ -1,4 +1,4 @@
using System;
using System;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls.Platform;
@ -6,24 +6,29 @@ using Avalonia.Dialogs;
using Avalonia.Input;
using Avalonia.Native.Interop;
using Avalonia.Native.Interop.Impl;
using Avalonia.Reactive;
using Avalonia.Threading;
namespace Avalonia.Native
{
internal class AvaloniaNativeMenuExporter : ITopLevelNativeMenuExporter
{
internal enum MenuTarget { Application, Window, TrayIcon, Dock }
private readonly IAvaloniaNativeFactory _factory;
private readonly MenuTarget _target;
private bool _resetQueued = true;
private bool _exported;
private readonly IAvnWindow? _nativeWindow;
private NativeMenu? _menu;
private __MicroComIAvnMenuProxy? _nativeMenu;
private readonly IAvnTrayIcon? _trayIcon;
private readonly IAvnApplicationCommands _applicationCommands;
private readonly IAvnApplicationCommands? _applicationCommands;
public AvaloniaNativeMenuExporter(IAvnWindow nativeWindow, IAvaloniaNativeFactory factory)
{
_factory = factory;
_target = MenuTarget.Window;
_nativeWindow = nativeWindow;
_applicationCommands = _factory.CreateApplicationCommands();
@ -33,6 +38,7 @@ namespace Avalonia.Native
public AvaloniaNativeMenuExporter(IAvaloniaNativeFactory factory)
{
_factory = factory;
_target = MenuTarget.Application;
_applicationCommands = _factory.CreateApplicationCommands();
DoLayoutReset();
@ -41,20 +47,65 @@ namespace Avalonia.Native
public AvaloniaNativeMenuExporter(IAvnTrayIcon trayIcon, IAvaloniaNativeFactory factory)
{
_factory = factory;
_target = MenuTarget.TrayIcon;
_trayIcon = trayIcon;
_applicationCommands = _factory.CreateApplicationCommands();
DoLayoutReset();
}
internal AvaloniaNativeMenuExporter(IAvaloniaNativeFactory factory, MenuTarget target)
{
_factory = factory;
_target = target;
_resetQueued = false;
var macOpts = AvaloniaLocator.Current.GetService<MacOSPlatformOptions>() ?? new MacOSPlatformOptions();
if (macOpts.DisableNativeMenus)
{
return;
}
NativeDock.MenuProperty.Changed.Subscribe(args =>
{
if (args.Sender is Application)
{
SetNativeMenu(args.NewValue.GetValueOrDefault());
}
});
var app = Application.Current;
if (app is not null)
{
var dockMenu = NativeDock.GetMenu(app);
if (dockMenu is not null)
{
SetNativeMenu(dockMenu);
}
}
}
public bool IsNativeMenuExported => _exported;
public event EventHandler OnIsNativeMenuExportedChanged { add { } remove { } }
public void SetNativeMenu(NativeMenu? menu)
{
_menu = menu ?? new NativeMenu();
DoLayoutReset(true);
if (_target == MenuTarget.Dock)
{
_menu = menu;
if (_menu is not null)
{
DoLayoutReset(true);
}
}
else
{
_menu = menu ?? new NativeMenu();
DoLayoutReset(true);
}
}
internal void UpdateIfNeeded()
@ -70,7 +121,7 @@ namespace Avalonia.Native
var result = new NativeMenu();
var aboutItem = new NativeMenuItem("About Avalonia");
aboutItem.Click += async (_, _) =>
{
var dialog = new AboutAvaloniaDialog();
@ -78,14 +129,14 @@ namespace Avalonia.Native
if (Application.Current is
{ ApplicationLifetime: IClassicDesktopStyleApplicationLifetime { MainWindow: { IsVisible: true } mainWindow } })
{
await dialog.ShowDialog(mainWindow);
await dialog.ShowDialog(mainWindow);
}
else
{
dialog.Show();
}
};
result.Add(aboutItem);
return result;
@ -109,7 +160,7 @@ namespace Avalonia.Native
hideItem.Click += (_, _) =>
{
_applicationCommands.HideApp();
_applicationCommands?.HideApp();
};
appMenu.Add(hideItem);
@ -120,14 +171,14 @@ namespace Avalonia.Native
};
hideOthersItem.Click += (_, _) =>
{
_applicationCommands.HideOthers();
_applicationCommands?.HideOthers();
};
appMenu.Add(hideOthersItem);
var showAllItem = new NativeMenuItem("Show All");
showAllItem.Click += (_, _) =>
{
_applicationCommands.ShowAll();
_applicationCommands?.ShowAll();
};
appMenu.Add(showAllItem);
@ -165,9 +216,9 @@ namespace Avalonia.Native
{
_resetQueued = false;
if (_nativeWindow is null)
switch (_target)
{
if (_trayIcon is null)
case MenuTarget.Application:
{
var app = Application.Current;
var appMenu = app is null ? null : NativeMenu.GetMenu(app);
@ -181,17 +232,34 @@ namespace Avalonia.Native
}
SetMenu(appMenu);
break;
}
else if (_menu != null)
case MenuTarget.Window:
{
SetMenu(_trayIcon, _menu);
if (_menu != null)
{
SetMenu(_nativeWindow, _menu);
}
break;
}
}
else
{
if (_menu != null)
case MenuTarget.TrayIcon:
{
if (_menu != null)
{
SetMenu(_trayIcon, _menu);
}
break;
}
case MenuTarget.Dock:
{
SetMenu(_nativeWindow, _menu);
if (_menu != null)
{
SetDockMenu(_menu);
}
break;
}
}
@ -253,7 +321,7 @@ namespace Avalonia.Native
}
}
private void SetMenu(IAvnWindow avnWindow, NativeMenu menu)
private void SetMenu(IAvnWindow? avnWindow, NativeMenu menu)
{
var setMenu = false;
@ -270,11 +338,11 @@ namespace Avalonia.Native
if(setMenu)
{
avnWindow.SetMainMenu(_nativeMenu);
avnWindow?.SetMainMenu(_nativeMenu);
}
}
private void SetMenu(IAvnTrayIcon trayIcon, NativeMenu menu)
private void SetMenu(IAvnTrayIcon? trayIcon, NativeMenu menu)
{
var setMenu = false;
@ -291,7 +359,28 @@ namespace Avalonia.Native
if(setMenu)
{
trayIcon.SetMenu(_nativeMenu);
trayIcon?.SetMenu(_nativeMenu);
}
}
private void SetDockMenu(NativeMenu menu)
{
var setMenu = false;
if (_nativeMenu is null)
{
_nativeMenu = __MicroComIAvnMenuProxy.Create(_factory);
_nativeMenu.Initialize(this, menu, "");
setMenu = true;
}
_nativeMenu.Update(_factory, menu);
if (setMenu)
{
_factory.SetDockMenu(_nativeMenu);
}
}
}

5
src/Avalonia.Native/AvaloniaNativePlatform.cs

@ -66,6 +66,11 @@ namespace Avalonia.Native
var exporter = new AvaloniaNativeMenuExporter(_factory);
}
public void SetupApplicationDockMenuExporter()
{
_ = new AvaloniaNativeMenuExporter(_factory, AvaloniaNativeMenuExporter.MenuTarget.Dock);
}
public void SetupApplicationName()
{
if (!string.IsNullOrWhiteSpace(Application.Current!.Name))

1
src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs

@ -22,6 +22,7 @@ namespace Avalonia
{
platform.SetupApplicationName();
platform.SetupApplicationMenuExporter();
platform.SetupApplicationDockMenuExporter();
});
});

1
src/Avalonia.Native/avn.idl

@ -724,6 +724,7 @@ interface IAvaloniaNativeFactory : IUnknown
HRESULT CreatePlatformRenderTimer(IAvnPlatformRenderTimer** ppv);
HRESULT ImportMTLSharedEvent([intptr]void* idMtlSharedEvent, IAvnMTLSharedEvent** ppv);
HRESULT CreateMemoryManagementHelper(IAvnNativeObjectsMemoryManagement** ppv);
HRESULT SetDockMenu(IAvnMenu* menu);
}
[uuid(233e094f-9b9f-44a3-9a6e-6948bbdd9fb1)]

41
tests/Avalonia.IntegrationTests.Appium/NativeMenuTests.cs

@ -1,4 +1,6 @@
using System.Threading;
using System.Diagnostics;
using System.IO;
using System.Threading;
using Xunit;
namespace Avalonia.IntegrationTests.Appium
@ -18,9 +20,9 @@ namespace Avalonia.IntegrationTests.Appium
var buttonTab = tabs.FindElementByName("Button");
var menuBar = Session.FindElementByXPath("/XCUIElementTypeApplication/XCUIElementTypeMenuBar");
var viewMenu = menuBar.FindElementByName("View");
Assert.False(buttonTab.Selected);
viewMenu.Click();
var buttonMenu = viewMenu.FindElementByName("Button");
buttonMenu.Click();
@ -86,4 +88,37 @@ namespace Avalonia.IntegrationTests.Appium
Assert.Contains(toolTipCandidates, x => x.Text == "Tip:Button");
}
}
[Collection("Default")]
public class DockMenuTests : TestBase
{
private const string DockAppName = "IntegrationTestApp";
public DockMenuTests(DefaultAppFixture fixture)
: base(fixture, "DesktopPage")
{
}
[PlatformFact(TestPlatforms.MacOS)]
public void MacOS_DockMenu_Can_Add_Items_Dynamically()
{
var countText = Session.FindElementByAccessibilityId("DockMenuItemCount");
Assert.Equal("0", countText.Text);
var addButton = Session.FindElementByAccessibilityId("AddDockMenuItem");
addButton.Click();
Thread.Sleep(500);
countText = Session.FindElementByAccessibilityId("DockMenuItemCount");
Assert.Equal("2", countText.Text);
addButton.Click();
Thread.Sleep(500);
countText = Session.FindElementByAccessibilityId("DockMenuItemCount");
Assert.Equal("3", countText.Text);
}
}
}

Loading…
Cancel
Save