diff --git a/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj b/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj index dba3ee6d31d..85fcf20034f 100644 --- a/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj +++ b/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 37E2330F21583241000CB7E2 /* KeyTransform.mm in Sources */ = {isa = PBXBuildFile; fileRef = 37E2330E21583241000CB7E2 /* KeyTransform.mm */; }; 520624B322973F4100C4DCEF /* menu.mm in Sources */ = {isa = PBXBuildFile; fileRef = 520624B222973F4100C4DCEF /* menu.mm */; }; 522D5959258159C1006F7F7A /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 522D5958258159C1006F7F7A /* Carbon.framework */; }; + 523484CA26EA688F00EA0C2C /* trayicon.mm in Sources */ = {isa = PBXBuildFile; fileRef = 523484C926EA688F00EA0C2C /* trayicon.mm */; }; 5B21A982216530F500CEE36E /* cursor.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5B21A981216530F500CEE36E /* cursor.mm */; }; 5B8BD94F215BFEA6005ED2A7 /* clipboard.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5B8BD94E215BFEA6005ED2A7 /* clipboard.mm */; }; AB00E4F72147CA920032A60A /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = AB00E4F62147CA920032A60A /* main.mm */; }; @@ -51,6 +52,8 @@ 37E2330E21583241000CB7E2 /* KeyTransform.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = KeyTransform.mm; sourceTree = ""; }; 520624B222973F4100C4DCEF /* menu.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = menu.mm; sourceTree = ""; }; 522D5958258159C1006F7F7A /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; + 523484C926EA688F00EA0C2C /* trayicon.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = trayicon.mm; sourceTree = ""; }; + 523484CB26EA68AA00EA0C2C /* trayicon.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = trayicon.h; sourceTree = ""; }; 5B21A981216530F500CEE36E /* cursor.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = cursor.mm; sourceTree = ""; }; 5B8BD94E215BFEA6005ED2A7 /* clipboard.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = clipboard.mm; sourceTree = ""; }; 5BF943652167AD1D009CAE35 /* cursor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = cursor.h; sourceTree = ""; }; @@ -114,6 +117,8 @@ AB00E4F62147CA920032A60A /* main.mm */, 37155CE3233C00EB0034DCE9 /* menu.h */, 520624B222973F4100C4DCEF /* menu.mm */, + 523484C926EA688F00EA0C2C /* trayicon.mm */, + 523484CB26EA68AA00EA0C2C /* trayicon.h */, 1A3E5EA723E9E83B00EDE661 /* rendertarget.mm */, 37A517B22159597E00FBA241 /* Screens.mm */, 37C09D8721580FE4006A6758 /* SystemDialogs.mm */, @@ -204,6 +209,7 @@ 1A1852DC23E05814008F0DED /* deadlock.mm in Sources */, 5B21A982216530F500CEE36E /* cursor.mm in Sources */, 37DDA9B0219330F8002E132B /* AvnString.mm in Sources */, + 523484CA26EA688F00EA0C2C /* trayicon.mm in Sources */, AB8F7D6B21482D7F0057DBA5 /* platformthreading.mm in Sources */, 1A3E5EA823E9E83B00EDE661 /* rendertarget.mm in Sources */, 1A3E5EAE23E9FB1300EDE661 /* cgl.mm in Sources */, diff --git a/native/Avalonia.Native/src/OSX/common.h b/native/Avalonia.Native/src/OSX/common.h index c082003ccfd..8896fbe88b3 100644 --- a/native/Avalonia.Native/src/OSX/common.h +++ b/native/Avalonia.Native/src/OSX/common.h @@ -22,6 +22,7 @@ extern AvnDragDropEffects ConvertDragDropEffects(NSDragOperation nsop); extern IAvnCursorFactory* CreateCursorFactory(); extern IAvnGlDisplay* GetGlDisplay(); extern IAvnMenu* CreateAppMenu(IAvnMenuEvents* events); +extern IAvnTrayIcon* CreateTrayIcon(); extern IAvnMenuItem* CreateAppMenuItem(); extern IAvnMenuItem* CreateAppMenuItemSeparator(); extern IAvnNativeControlHost* CreateNativeControlHost(NSView* parent); diff --git a/native/Avalonia.Native/src/OSX/main.mm b/native/Avalonia.Native/src/OSX/main.mm index 3e152a61255..eeaaecfdbde 100644 --- a/native/Avalonia.Native/src/OSX/main.mm +++ b/native/Avalonia.Native/src/OSX/main.mm @@ -303,6 +303,17 @@ virtual HRESULT ObtainGlDisplay(IAvnGlDisplay** ppv) override } } + virtual HRESULT CreateTrayIcon (IAvnTrayIcon** ppv) override + { + START_COM_CALL; + + @autoreleasepool + { + *ppv = ::CreateTrayIcon(); + return S_OK; + } + } + virtual HRESULT CreateMenu (IAvnMenuEvents* cb, IAvnMenu** ppv) override { START_COM_CALL; diff --git a/native/Avalonia.Native/src/OSX/trayicon.h b/native/Avalonia.Native/src/OSX/trayicon.h new file mode 100644 index 00000000000..f94f9a871b1 --- /dev/null +++ b/native/Avalonia.Native/src/OSX/trayicon.h @@ -0,0 +1,33 @@ +// +// trayicon.h +// Avalonia.Native.OSX +// +// Created by Dan Walmsley on 09/09/2021. +// Copyright © 2021 Avalonia. All rights reserved. +// + +#ifndef trayicon_h +#define trayicon_h + +#include "common.h" + +class AvnTrayIcon : public ComSingleObject +{ +private: + NSStatusItem* _native; + +public: + FORWARD_IUNKNOWN() + + AvnTrayIcon(); + + ~AvnTrayIcon (); + + virtual HRESULT SetIcon (void* data, size_t length) override; + + virtual HRESULT SetMenu (IAvnMenu* menu) override; + + virtual HRESULT SetIsVisible (bool isVisible) override; +}; + +#endif /* trayicon_h */ diff --git a/native/Avalonia.Native/src/OSX/trayicon.mm b/native/Avalonia.Native/src/OSX/trayicon.mm new file mode 100644 index 00000000000..151990cfb12 --- /dev/null +++ b/native/Avalonia.Native/src/OSX/trayicon.mm @@ -0,0 +1,85 @@ +#include "common.h" +#include "trayicon.h" +#include "menu.h" + +extern IAvnTrayIcon* CreateTrayIcon() +{ + @autoreleasepool + { + return new AvnTrayIcon(); + } +} + +AvnTrayIcon::AvnTrayIcon() +{ + _native = [[NSStatusBar systemStatusBar] statusItemWithLength: NSSquareStatusItemLength]; + +} + +AvnTrayIcon::~AvnTrayIcon() +{ + if(_native != nullptr) + { + [[_native statusBar] removeStatusItem:_native]; + _native = nullptr; + } +} + +HRESULT AvnTrayIcon::SetIcon (void* data, size_t length) +{ + START_COM_CALL; + + @autoreleasepool + { + if(data != nullptr) + { + NSData *imageData = [NSData dataWithBytes:data length:length]; + NSImage *image = [[NSImage alloc] initWithData:imageData]; + + NSSize originalSize = [image size]; + + NSSize size; + size.height = [[NSFont menuFontOfSize:0] pointSize] * 1.333333; + + auto scaleFactor = size.height / originalSize.height; + size.width = originalSize.width * scaleFactor; + + [image setSize: size]; + [_native setImage:image]; + } + else + { + [_native setImage:nullptr]; + } + return S_OK; + } +} + +HRESULT AvnTrayIcon::SetMenu (IAvnMenu* menu) +{ + START_COM_CALL; + + @autoreleasepool + { + auto appMenu = dynamic_cast(menu); + + if(appMenu != nullptr) + { + [_native setMenu:appMenu->GetNative()]; + } + } + + return S_OK; +} + +HRESULT AvnTrayIcon::SetIsVisible(bool isVisible) +{ + START_COM_CALL; + + @autoreleasepool + { + [_native setVisible:isVisible]; + } + + return S_OK; +} diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 6aad44c0d50..6e57686e007 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -1,5 +1,8 @@ - + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs index f3ec7b48aaa..36b6fc2dcdb 100644 --- a/samples/ControlCatalog/App.xaml.cs +++ b/samples/ControlCatalog/App.xaml.cs @@ -1,14 +1,21 @@ using System; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml.Styling; using Avalonia.Styling; +using ControlCatalog.ViewModels; namespace ControlCatalog { public class App : Application { + public App() + { + DataContext = new ApplicationViewModel(); + } + private static readonly StyleInclude DataGridFluent = new StyleInclude(new Uri("avares://ControlCatalog/Styles")) { Source = new Uri("avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml") @@ -97,7 +104,9 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { desktopLifetime.MainWindow = new MainWindow(); + } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewLifetime) singleViewLifetime.MainView = new MainView(); diff --git a/samples/ControlCatalog/MainWindow.xaml.cs b/samples/ControlCatalog/MainWindow.xaml.cs index 2446c0e1c99..a9900471c5b 100644 --- a/samples/ControlCatalog/MainWindow.xaml.cs +++ b/samples/ControlCatalog/MainWindow.xaml.cs @@ -35,6 +35,8 @@ public MainWindow() var mainMenu = this.FindControl("MainMenu"); mainMenu.AttachedToVisualTree += MenuAttached; + + ExtendClientAreaChromeHints = Avalonia.Platform.ExtendClientAreaChromeHints.OSXThickTitleBar; } public static string MenuQuitHeader => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "Quit Avalonia" : "E_xit"; diff --git a/samples/ControlCatalog/ViewModels/ApplicationViewModel.cs b/samples/ControlCatalog/ViewModels/ApplicationViewModel.cs new file mode 100644 index 00000000000..7eea7b06576 --- /dev/null +++ b/samples/ControlCatalog/ViewModels/ApplicationViewModel.cs @@ -0,0 +1,26 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using MiniMvvm; + +namespace ControlCatalog.ViewModels +{ + public class ApplicationViewModel : ViewModelBase + { + public ApplicationViewModel() + { + ExitCommand = MiniCommand.Create(() => + { + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) + { + lifetime.Shutdown(); + } + }); + + ToggleCommand = MiniCommand.Create(() => { }); + } + + public MiniCommand ExitCommand { get; } + + public MiniCommand ToggleCommand { get; } + } +} diff --git a/src/Avalonia.Base/Logging/LogArea.cs b/src/Avalonia.Base/Logging/LogArea.cs index 2ad220dddd7..c049f9e7638 100644 --- a/src/Avalonia.Base/Logging/LogArea.cs +++ b/src/Avalonia.Base/Logging/LogArea.cs @@ -39,5 +39,10 @@ public static class LogArea /// The log event comes from Win32Platform. /// public const string Win32Platform = nameof(Win32Platform); + + /// + /// The log event comes from X11Platform. + /// + public const string X11Platform = nameof(X11Platform); } } diff --git a/src/Avalonia.Controls/ApiCompatBaseline.txt b/src/Avalonia.Controls/ApiCompatBaseline.txt index acd4c796c6d..1c449ff678a 100644 --- a/src/Avalonia.Controls/ApiCompatBaseline.txt +++ b/src/Avalonia.Controls/ApiCompatBaseline.txt @@ -8,6 +8,7 @@ MembersMustExist : Member 'public System.Action Avalonia.Controls MembersMustExist : Member 'public void Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.Resized.set(System.Action)' does not exist in the implementation but it does exist in the contract. MembersMustExist : Member 'public void Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetCursor(Avalonia.Platform.IPlatformHandle)' does not exist in the implementation but it does exist in the contract. MembersMustExist : Member 'public Avalonia.AvaloniaProperty Avalonia.AvaloniaProperty Avalonia.Controls.Notifications.NotificationCard.CloseOnClickProperty' does not exist in the implementation but it does exist in the contract. +InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Controls.Platform.ITopLevelNativeMenuExporter.SetNativeMenu(Avalonia.Controls.NativeMenu)' is present in the contract but not in the implementation. EnumValuesMustMatch : Enum value 'Avalonia.Platform.ExtendClientAreaChromeHints Avalonia.Platform.ExtendClientAreaChromeHints.Default' is (System.Int32)2 in the implementation but (System.Int32)1 in the contract. InterfacesShouldHaveSameMembers : Interface member 'public System.Nullable Avalonia.Platform.ITopLevelImpl.FrameSize' is present in the implementation but not in the contract. InterfacesShouldHaveSameMembers : Interface member 'public System.Nullable Avalonia.Platform.ITopLevelImpl.FrameSize.get()' is present in the implementation but not in the contract. @@ -26,4 +27,5 @@ InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platfor InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IWindowImpl.Resize(Avalonia.Size)' is present in the contract but not in the implementation. MembersMustExist : Member 'public void Avalonia.Platform.IWindowImpl.Resize(Avalonia.Size)' does not exist in the implementation but it does exist in the contract. InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IWindowImpl.Resize(Avalonia.Size, Avalonia.Platform.PlatformResizeReason)' is present in the implementation but not in the contract. -Total Issues: 27 +InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.ITrayIconImpl Avalonia.Platform.IWindowingPlatform.CreateTrayIcon()' is present in the implementation but not in the contract. +Total Issues: 58 diff --git a/src/Avalonia.Controls/NativeMenu.Export.cs b/src/Avalonia.Controls/NativeMenu.Export.cs index 0349df842b2..6bfe5ebc82b 100644 --- a/src/Avalonia.Controls/NativeMenu.Export.cs +++ b/src/Avalonia.Controls/NativeMenu.Export.cs @@ -52,15 +52,10 @@ static void SetIsNativeMenuExported(TopLevel tl, bool value) } public static readonly AttachedProperty MenuProperty - = AvaloniaProperty.RegisterAttached("Menu"/*, validate: - (o, v) => - { - if(!(o is Application || o is TopLevel)) - throw new InvalidOperationException("NativeMenu.Menu property isn't valid on "+o.GetType()); - return v; - }*/); + = AvaloniaProperty.RegisterAttached("Menu"); public static void SetMenu(AvaloniaObject o, NativeMenu menu) => o.SetValue(MenuProperty, menu); + public static NativeMenu GetMenu(AvaloniaObject o) => o.GetValue(MenuProperty); static NativeMenu() @@ -79,6 +74,10 @@ static NativeMenu() { GetInfo(tl).Exporter?.SetNativeMenu(args.NewValue.GetValueOrDefault()); } + else if(args.Sender is INativeMenuExporterProvider provider) + { + provider.NativeMenuExporter?.SetNativeMenu(args.NewValue.GetValueOrDefault()); + } }); } } diff --git a/src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs b/src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs index 3ac5f28956b..9b779054f32 100644 --- a/src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs +++ b/src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs @@ -1,14 +1,25 @@ using System; -using System.Collections.Generic; using Avalonia.Platform; +#nullable enable + namespace Avalonia.Controls.Platform { - public interface ITopLevelNativeMenuExporter + public interface INativeMenuExporter + { + void SetNativeMenu(NativeMenu? menu); + } + + public interface ITopLevelNativeMenuExporter : INativeMenuExporter { bool IsNativeMenuExported { get; } + event EventHandler OnIsNativeMenuExportedChanged; - void SetNativeMenu(NativeMenu menu); + } + + public interface INativeMenuExporterProvider + { + INativeMenuExporter? NativeMenuExporter { get; } } public interface ITopLevelImplWithNativeMenuExporter : ITopLevelImpl diff --git a/src/Avalonia.Controls/Platform/ITrayIconImpl.cs b/src/Avalonia.Controls/Platform/ITrayIconImpl.cs new file mode 100644 index 00000000000..9768d149f0f --- /dev/null +++ b/src/Avalonia.Controls/Platform/ITrayIconImpl.cs @@ -0,0 +1,35 @@ +using System; +using Avalonia.Controls.Platform; + +#nullable enable + +namespace Avalonia.Platform +{ + public interface ITrayIconImpl : IDisposable + { + /// + /// Sets the icon of this tray icon. + /// + void SetIcon(IWindowIconImpl? icon); + + /// + /// Sets the icon of this tray icon. + /// + void SetToolTipText(string? text); + + /// + /// Sets if the tray icon is visible or not. + /// + void SetIsVisible(bool visible); + + /// + /// Gets the MenuExporter to allow native menus to be exported to the TrayIcon. + /// + INativeMenuExporter? MenuExporter { get; } + + /// + /// Gets or Sets the Action that is called when the TrayIcon is clicked. + /// + Action? OnClicked { get; set; } + } +} diff --git a/src/Avalonia.Controls/Platform/IWindowingPlatform.cs b/src/Avalonia.Controls/Platform/IWindowingPlatform.cs index be8939e19ae..21882b12712 100644 --- a/src/Avalonia.Controls/Platform/IWindowingPlatform.cs +++ b/src/Avalonia.Controls/Platform/IWindowingPlatform.cs @@ -1,8 +1,13 @@ +#nullable enable + namespace Avalonia.Platform { public interface IWindowingPlatform { IWindowImpl CreateWindow(); + IWindowImpl CreateEmbeddableWindow(); + + ITrayIconImpl? CreateTrayIcon(); } } diff --git a/src/Avalonia.Controls/Platform/PlatformManager.cs b/src/Avalonia.Controls/Platform/PlatformManager.cs index 19d034b4e25..e39f0b1e994 100644 --- a/src/Avalonia.Controls/Platform/PlatformManager.cs +++ b/src/Avalonia.Controls/Platform/PlatformManager.cs @@ -1,8 +1,9 @@ using System; using System.Reactive.Disposables; -using Avalonia.Media; using Avalonia.Platform; +#nullable enable + namespace Avalonia.Controls.Platform { public static partial class PlatformManager @@ -22,6 +23,19 @@ public static void SetDesignerScalingFactor(double factor) { } + public static ITrayIconImpl? CreateTrayIcon() + { + var platform = AvaloniaLocator.Current.GetService(); + + if (platform == null) + { + throw new Exception("Could not CreateWindow(): IWindowingPlatform is not registered."); + } + + return s_designerMode ? null : platform.CreateTrayIcon(); + } + + public static IWindowImpl CreateWindow() { var platform = AvaloniaLocator.Current.GetService(); diff --git a/src/Avalonia.Controls/TrayIcon.cs b/src/Avalonia.Controls/TrayIcon.cs new file mode 100644 index 00000000000..6bfddfa877a --- /dev/null +++ b/src/Avalonia.Controls/TrayIcon.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Collections; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Platform; +using Avalonia.Platform; + +#nullable enable + +namespace Avalonia.Controls +{ + public sealed class TrayIcons : AvaloniaList + { + } + + public class TrayIcon : AvaloniaObject, INativeMenuExporterProvider, IDisposable + { + private readonly ITrayIconImpl? _impl; + + private TrayIcon(ITrayIconImpl? impl) + { + if (impl != null) + { + _impl = impl; + + _impl.SetIsVisible(IsVisible); + + _impl.OnClicked = () => Clicked?.Invoke(this, EventArgs.Empty); + } + } + + public TrayIcon() : this(PlatformManager.CreateTrayIcon()) + { + } + + static TrayIcon() + { + IconsProperty.Changed.Subscribe(args => + { + if (args.Sender is Application) + { + if (args.OldValue.Value != null) + { + RemoveIcons(args.OldValue.Value); + } + + if (args.NewValue.Value != null) + { + args.NewValue.Value.CollectionChanged += Icons_CollectionChanged; + } + } + }); + + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) + { + lifetime.Exit += Lifetime_Exit; + } + } + + /// + /// Raised when the TrayIcon is clicked. + /// Note, this is only supported on Win32 and some Linux DEs, + /// on OSX this event is not raised. + /// + public event EventHandler? Clicked; + + /// + /// Defines the attached property. + /// + public static readonly AttachedProperty IconsProperty + = AvaloniaProperty.RegisterAttached("Icons"); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MenuProperty + = AvaloniaProperty.Register(nameof(Menu)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IconProperty = + Window.IconProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ToolTipTextProperty = + AvaloniaProperty.Register(nameof(ToolTipText)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsVisibleProperty = + Visual.IsVisibleProperty.AddOwner(); + + public static void SetIcons(AvaloniaObject o, TrayIcons trayIcons) => o.SetValue(IconsProperty, trayIcons); + + public static TrayIcons GetIcons(AvaloniaObject o) => o.GetValue(IconsProperty); + + /// + /// Gets or sets the Menu of the TrayIcon. + /// + public NativeMenu? Menu + { + get => GetValue(MenuProperty); + set => SetValue(MenuProperty, value); + } + + /// + /// Gets or sets the icon of the TrayIcon. + /// + public WindowIcon Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + /// + /// Gets or sets the tooltip text of the TrayIcon. + /// + public string? ToolTipText + { + get => GetValue(ToolTipTextProperty); + set => SetValue(ToolTipTextProperty, value); + } + + /// + /// Gets or sets the visibility of the TrayIcon. + /// + public bool IsVisible + { + get => GetValue(IsVisibleProperty); + set => SetValue(IsVisibleProperty, value); + } + + public INativeMenuExporter? NativeMenuExporter => _impl?.MenuExporter; + + private static void Lifetime_Exit(object sender, ControlledApplicationLifetimeExitEventArgs e) + { + var trayIcons = GetIcons(Application.Current); + + RemoveIcons(trayIcons); + } + + private static void Icons_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + RemoveIcons(e.OldItems.Cast()); + } + + private static void RemoveIcons(IEnumerable icons) + { + foreach (var icon in icons) + { + icon.Dispose(); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IconProperty) + { + _impl?.SetIcon(Icon.PlatformImpl); + } + else if (change.Property == IsVisibleProperty) + { + _impl?.SetIsVisible(change.NewValue.GetValueOrDefault()); + } + else if (change.Property == ToolTipTextProperty) + { + _impl?.SetToolTipText(change.NewValue.GetValueOrDefault()); + } + else if (change.Property == MenuProperty) + { + _impl?.MenuExporter?.SetNativeMenu(change.NewValue.GetValueOrDefault()); + } + } + + /// + /// Disposes the tray icon (removing it from the tray area). + /// + public void Dispose() => _impl?.Dispose(); + } +} diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs index 67b832318a5..ada63f5326e 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowingPlatform.cs @@ -16,7 +16,9 @@ class PreviewerWindowingPlatform : IWindowingPlatform, IPlatformSettings private static DetachableTransportConnection s_lastWindowTransport; private static PreviewerWindowImpl s_lastWindow; public static List PreFlightMessages = new List(); - + + public ITrayIconImpl CreateTrayIcon() => null; + public IWindowImpl CreateWindow() => new WindowStub(); public IWindowImpl CreateEmbeddableWindow() diff --git a/src/Avalonia.FreeDesktop/DBusHelper.cs b/src/Avalonia.FreeDesktop/DBusHelper.cs index 7996a94dd03..4e23711ed4a 100644 --- a/src/Avalonia.FreeDesktop/DBusHelper.cs +++ b/src/Avalonia.FreeDesktop/DBusHelper.cs @@ -51,8 +51,11 @@ public void Initialized() public static Connection TryInitialize(string dbusAddress = null) { - if (Connection != null) - return Connection; + return Connection ?? TryGetConnection(dbusAddress); + } + + public static Connection TryGetConnection(string dbusAddress = null) + { var oldContext = SynchronizationContext.Current; try { diff --git a/src/Avalonia.FreeDesktop/DBusMenuExporter.cs b/src/Avalonia.FreeDesktop/DBusMenuExporter.cs index b5e35db9691..9e426688d88 100644 --- a/src/Avalonia.FreeDesktop/DBusMenuExporter.cs +++ b/src/Avalonia.FreeDesktop/DBusMenuExporter.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.Platform; using Avalonia.FreeDesktop.DBusMenu; using Avalonia.Input; +using Avalonia.Platform; using Avalonia.Threading; using Tmds.DBus; #pragma warning disable 1998 @@ -16,51 +17,78 @@ namespace Avalonia.FreeDesktop { public class DBusMenuExporter { - public static ITopLevelNativeMenuExporter TryCreate(IntPtr xid) + public static ITopLevelNativeMenuExporter TryCreateTopLevelNativeMenu(IntPtr xid) { if (DBusHelper.Connection == null) return null; return new DBusMenuExporterImpl(DBusHelper.Connection, xid); } + + public static INativeMenuExporter TryCreateDetachedNativeMenu(ObjectPath path, Connection currentConection) + { + return new DBusMenuExporterImpl(currentConection, path); + } + + public static ObjectPath GenerateDBusMenuObjPath => "/net/avaloniaui/dbusmenu/" + + Guid.NewGuid().ToString("N"); - class DBusMenuExporterImpl : ITopLevelNativeMenuExporter, IDBusMenu, IDisposable + private class DBusMenuExporterImpl : ITopLevelNativeMenuExporter, IDBusMenu, IDisposable { private readonly Connection _dbus; private readonly uint _xid; - private IRegistrar _registar; + private IRegistrar _registrar; private bool _disposed; private uint _revision = 1; private NativeMenu _menu; - private Dictionary _idsToItems = new Dictionary(); - private Dictionary _itemsToIds = new Dictionary(); + private readonly Dictionary _idsToItems = new Dictionary(); + private readonly Dictionary _itemsToIds = new Dictionary(); private readonly HashSet _menus = new HashSet(); private bool _resetQueued; private int _nextId = 1; + private bool _appMenu = true; + public DBusMenuExporterImpl(Connection dbus, IntPtr xid) { _dbus = dbus; _xid = (uint)xid.ToInt32(); - ObjectPath = new ObjectPath("/net/avaloniaui/dbusmenu/" - + Guid.NewGuid().ToString().Replace("-", "")); + ObjectPath = GenerateDBusMenuObjPath; SetNativeMenu(new NativeMenu()); Init(); } + public DBusMenuExporterImpl(Connection dbus, ObjectPath path) + { + _dbus = dbus; + _appMenu = false; + ObjectPath = path; + SetNativeMenu(new NativeMenu()); + Init(); + } + async void Init() { try { - await _dbus.RegisterObjectAsync(this); - _registar = DBusHelper.Connection.CreateProxy( - "com.canonical.AppMenu.Registrar", - "/com/canonical/AppMenu/Registrar"); - if (!_disposed) - await _registar.RegisterWindowAsync(_xid, ObjectPath); + if (_appMenu) + { + await _dbus.RegisterObjectAsync(this); + _registrar = DBusHelper.Connection.CreateProxy( + "com.canonical.AppMenu.Registrar", + "/com/canonical/AppMenu/Registrar"); + if (!_disposed) + await _registrar.RegisterWindowAsync(_xid, ObjectPath); + } + else + { + await _dbus.RegisterObjectAsync(this); + } } catch (Exception e) { - Console.Error.WriteLine(e); + Logging.Logger.TryGet(Logging.LogEventLevel.Error, Logging.LogArea.X11Platform) + ?.Log(this, e.Message); + // It's not really important if this code succeeds, // and it's not important to know if it succeeds // since even if we register the window it's not guaranteed that @@ -75,7 +103,7 @@ public void Dispose() _disposed = true; _dbus.UnregisterObject(this); // Fire and forget - _registar?.UnregisterWindowAsync(_xid); + _registrar?.UnregisterWindowAsync(_xid); } @@ -248,17 +276,24 @@ object GetProperty((NativeMenuItemBase item, NativeMenu menu) i, string name) if (item.ToggleType != NativeMenuItemToggleType.None) return item.IsChecked ? 1 : 0; } - + if (name == "icon-data") { if (item.Icon != null) { - var ms = new MemoryStream(); - item.Icon.Save(ms); - return ms.ToArray(); + var loader = AvaloniaLocator.Current.GetService(); + + if (loader != null) + { + var icon = loader.LoadIcon(item.Icon.PlatformImpl.Item); + + using var ms = new MemoryStream(); + icon.Save(ms); + return ms.ToArray(); + } } } - + if (name == "children-display") return menu != null ? "submenu" : null; } diff --git a/src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs b/src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs index fca2a1336f8..0ca2733cde8 100644 --- a/src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs +++ b/src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs @@ -51,6 +51,8 @@ class HeadlessWindowingPlatform : IWindowingPlatform public IWindowImpl CreateEmbeddableWindow() => throw new PlatformNotSupportedException(); public IPopupImpl CreatePopup() => new HeadlessWindowImpl(true); + + public ITrayIconImpl CreateTrayIcon() => null; } internal static void Initialize() diff --git a/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs b/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs index 89efa6af0c6..4431e108edb 100644 --- a/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs +++ b/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs @@ -9,14 +9,15 @@ namespace Avalonia.Native { - class AvaloniaNativeMenuExporter : ITopLevelNativeMenuExporter + internal class AvaloniaNativeMenuExporter : ITopLevelNativeMenuExporter { - private IAvaloniaNativeFactory _factory; + private readonly IAvaloniaNativeFactory _factory; private bool _resetQueued = true; - private bool _exported = false; - private IAvnWindow _nativeWindow; + private bool _exported; + private readonly IAvnWindow _nativeWindow; private NativeMenu _menu; private __MicroComIAvnMenuProxy _nativeMenu; + private readonly IAvnTrayIcon _trayIcon; public AvaloniaNativeMenuExporter(IAvnWindow nativeWindow, IAvaloniaNativeFactory factory) { @@ -33,13 +34,21 @@ public AvaloniaNativeMenuExporter(IAvaloniaNativeFactory factory) DoLayoutReset(); } + public AvaloniaNativeMenuExporter(IAvnTrayIcon trayIcon, IAvaloniaNativeFactory factory) + { + _factory = factory; + _trayIcon = trayIcon; + + DoLayoutReset(); + } + public bool IsNativeMenuExported => _exported; public event EventHandler OnIsNativeMenuExportedChanged; public void SetNativeMenu(NativeMenu menu) { - _menu = menu == null ? new NativeMenu() : menu; + _menu = menu ?? new NativeMenu(); DoLayoutReset(true); } @@ -82,15 +91,22 @@ private void DoLayoutReset(bool forceUpdate = false) if (_nativeWindow is null) { - var appMenu = NativeMenu.GetMenu(Application.Current); + if (_trayIcon is null) + { + var appMenu = NativeMenu.GetMenu(Application.Current); - if (appMenu == null) + if (appMenu == null) + { + appMenu = CreateDefaultAppMenu(); + NativeMenu.SetMenu(Application.Current, appMenu); + } + + SetMenu(appMenu); + } + else if (_menu != null) { - appMenu = CreateDefaultAppMenu(); - NativeMenu.SetMenu(Application.Current, appMenu); + SetMenu(_trayIcon, _menu); } - - SetMenu(appMenu); } else { @@ -118,7 +134,7 @@ private void SetMenu(NativeMenu menu) var appMenuHolder = menuItem?.Parent; - if (menu.Parent is null) + if (menuItem is null) { menuItem = new NativeMenuItem(); } @@ -136,7 +152,7 @@ private void SetMenu(NativeMenu menu) if (_nativeMenu is null) { - _nativeMenu = (__MicroComIAvnMenuProxy)__MicroComIAvnMenuProxy.Create(_factory); + _nativeMenu = __MicroComIAvnMenuProxy.Create(_factory); _nativeMenu.Initialize(this, appMenuHolder, ""); @@ -171,5 +187,26 @@ private void SetMenu(IAvnWindow avnWindow, NativeMenu menu) avnWindow.SetMainMenu(_nativeMenu); } } + + private void SetMenu(IAvnTrayIcon trayIcon, 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) + { + trayIcon.SetMenu(_nativeMenu); + } + } } } diff --git a/src/Avalonia.Native/AvaloniaNativePlatform.cs b/src/Avalonia.Native/AvaloniaNativePlatform.cs index a7d05e416f2..eaf4d0e2e4b 100644 --- a/src/Avalonia.Native/AvaloniaNativePlatform.cs +++ b/src/Avalonia.Native/AvaloniaNativePlatform.cs @@ -134,6 +134,11 @@ void DoInitialize(AvaloniaNativePlatformOptions options) } } + public ITrayIconImpl CreateTrayIcon () + { + return new TrayIconImpl(_factory); + } + public IWindowImpl CreateWindow() { return new WindowImpl(_factory, _options, _platformGl); diff --git a/src/Avalonia.Native/TrayIconImpl.cs b/src/Avalonia.Native/TrayIconImpl.cs new file mode 100644 index 00000000000..abcc61d9506 --- /dev/null +++ b/src/Avalonia.Native/TrayIconImpl.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using Avalonia.Controls.Platform; +using Avalonia.Native.Interop; +using Avalonia.Platform; + +#nullable enable + +namespace Avalonia.Native +{ + internal class TrayIconImpl : ITrayIconImpl + { + private readonly IAvnTrayIcon _native; + + public TrayIconImpl(IAvaloniaNativeFactory factory) + { + _native = factory.CreateTrayIcon(); + + MenuExporter = new AvaloniaNativeMenuExporter(_native, factory); + } + + public Action? OnClicked { get; set; } + + public void Dispose() + { + _native.Dispose(); + } + + public unsafe void SetIcon(IWindowIconImpl? icon) + { + if (icon is null) + { + _native.SetIcon(null, IntPtr.Zero); + } + else + { + using (var ms = new MemoryStream()) + { + icon.Save(ms); + + var imageData = ms.ToArray(); + + fixed (void* ptr = imageData) + { + _native.SetIcon(ptr, new IntPtr(imageData.Length)); + } + } + } + } + + public void SetToolTipText(string? text) + { + // NOP + } + + public void SetIsVisible(bool visible) + { + _native.SetIsVisible(visible.AsComBool()); + } + + public INativeMenuExporter? MenuExporter { get; } + } +} diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 70d85dacdda..00c54750a4a 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -427,6 +427,7 @@ interface IAvaloniaNativeFactory : IUnknown HRESULT CreateMenu(IAvnMenuEvents* cb, IAvnMenu** ppv); HRESULT CreateMenuItem(IAvnMenuItem** ppv); HRESULT CreateMenuItemSeparator(IAvnMenuItem** ppv); + HRESULT CreateTrayIcon(IAvnTrayIcon** ppv); } [uuid(233e094f-9b9f-44a3-9a6e-6948bbdd9fb1)] @@ -665,6 +666,14 @@ interface IAvnGlSurfaceRenderingSession : IUnknown HRESULT GetScaling(double* ret); } +[uuid(60992d19-38f0-4141-a0a9-76ac303801f3)] +interface IAvnTrayIcon : IUnknown +{ + HRESULT SetIcon(void* data, size_t length); + HRESULT SetMenu(IAvnMenu* menu); + HRESULT SetIsVisible(bool isVisible); +} + [uuid(a7724dc1-cf6b-4fa8-9d23-228bf2593edc)] interface IAvnMenu : IUnknown { diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index 3a919c8814f..5d80c860a7b 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -100,6 +100,12 @@ public void Initialize(X11PlatformOptions options) public IntPtr DeferredDisplay { get; set; } public IntPtr Display { get; set; } + + public ITrayIconImpl CreateTrayIcon () + { + return new X11TrayIconImpl(); + } + public IWindowImpl CreateWindow() { return new X11Window(this, null); diff --git a/src/Avalonia.X11/X11TrayIconImpl.cs b/src/Avalonia.X11/X11TrayIconImpl.cs new file mode 100644 index 00000000000..ea3653fc839 --- /dev/null +++ b/src/Avalonia.X11/X11TrayIconImpl.cs @@ -0,0 +1,367 @@ +#nullable enable + +using System; +using System.Diagnostics; +using System.Reactive.Disposables; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Avalonia.Controls.Platform; +using Avalonia.FreeDesktop; +using Avalonia.Logging; +using Avalonia.Platform; +using Tmds.DBus; + +[assembly: InternalsVisibleTo(Connection.DynamicAssemblyName)] + +namespace Avalonia.X11 +{ + internal class X11TrayIconImpl : ITrayIconImpl + { + private static int s_trayIconInstanceId; + private readonly ObjectPath _dbusMenuPath; + private StatusNotifierItemDbusObj? _statusNotifierItemDbusObj; + private readonly Connection? _connection; + private DbusPixmap _icon; + + private IStatusNotifierWatcher? _statusNotifierWatcher; + + private string? _sysTrayServiceName; + private string? _tooltipText; + private bool _isActive; + private bool _isDisposed; + private readonly bool _ctorFinished; + + public INativeMenuExporter? MenuExporter { get; } + public Action? OnClicked { get; set; } + + public X11TrayIconImpl() + { + _connection = DBusHelper.TryGetConnection(); + + if (_connection is null) + { + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + ?.Log(this, "Unable to get a dbus connection for system tray icons."); + return; + } + + _dbusMenuPath = DBusMenuExporter.GenerateDBusMenuObjPath; + MenuExporter = DBusMenuExporter.TryCreateDetachedNativeMenu(_dbusMenuPath, _connection); + CreateTrayIcon(); + _ctorFinished = true; + } + + public async void CreateTrayIcon() + { + if (_connection is null) + return; + + try + { + _statusNotifierWatcher = _connection.CreateProxy( + "org.kde.StatusNotifierWatcher", + "/StatusNotifierWatcher"); + } + catch + { + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + ?.Log(this, + "DBUS: org.kde.StatusNotifierWatcher service is not available on this system. System Tray Icons will not work without it."); + } + + if (_statusNotifierWatcher is null) + return; + + var pid = Process.GetCurrentProcess().Id; + var tid = s_trayIconInstanceId++; + + _sysTrayServiceName = $"org.kde.StatusNotifierItem-{pid}-{tid}"; + _statusNotifierItemDbusObj = new StatusNotifierItemDbusObj(_dbusMenuPath); + + await _connection.RegisterObjectAsync(_statusNotifierItemDbusObj); + + await _connection.RegisterServiceAsync(_sysTrayServiceName); + + await _statusNotifierWatcher.RegisterStatusNotifierItemAsync(_sysTrayServiceName); + + _statusNotifierItemDbusObj.SetTitleAndTooltip(_tooltipText); + _statusNotifierItemDbusObj.SetIcon(_icon); + + _statusNotifierItemDbusObj.ActivationDelegate += OnClicked; + + _isActive = true; + } + + public async void DestroyTrayIcon() + { + if (_connection is null) + return; + _connection.UnregisterObject(_statusNotifierItemDbusObj); + await _connection.UnregisterServiceAsync(_sysTrayServiceName); + _isActive = false; + } + + public void Dispose() + { + _isDisposed = true; + DestroyTrayIcon(); + _connection?.Dispose(); + } + + public void SetIcon(IWindowIconImpl? icon) + { + if (_isDisposed) + return; + if (!(icon is X11IconData x11icon)) + return; + + var w = (int)x11icon.Data[0]; + var h = (int)x11icon.Data[1]; + + var pixLength = w * h; + var pixByteArrayCounter = 0; + var pixByteArray = new byte[w * h * 4]; + + for (var i = 0; i < pixLength; i++) + { + var rawPixel = x11icon.Data[i + 2].ToUInt32(); + pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF000000) >> 24); + pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF0000) >> 16); + pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF00) >> 8); + pixByteArray[pixByteArrayCounter++] = (byte)(rawPixel & 0xFF); + } + + _icon = new DbusPixmap(w, h, pixByteArray); + _statusNotifierItemDbusObj?.SetIcon(_icon); + } + + public void SetIsVisible(bool visible) + { + if (_isDisposed || !_ctorFinished) + return; + + if (visible & !_isActive) + { + DestroyTrayIcon(); + CreateTrayIcon(); + } + else if (!visible & _isActive) + { + DestroyTrayIcon(); + } + } + + public void SetToolTipText(string? text) + { + if (_isDisposed || text is null) + return; + _tooltipText = text; + _statusNotifierItemDbusObj?.SetTitleAndTooltip(_tooltipText); + } + } + + /// + /// DBus Object used for setting system tray icons. + /// + /// + /// Useful guide: https://web.archive.org/web/20210818173850/https://www.notmart.org/misc/statusnotifieritem/statusnotifieritem.html + /// + internal class StatusNotifierItemDbusObj : IStatusNotifierItem + { + private readonly StatusNotifierItemProperties _backingProperties; + public event Action? OnTitleChanged; + public event Action? OnIconChanged; + public event Action? OnAttentionIconChanged; + public event Action? OnOverlayIconChanged; + public event Action? OnTooltipChanged; + public Action? NewStatusAsync { get; set; } + public Action? ActivationDelegate { get; set; } + public ObjectPath ObjectPath { get; } + + public StatusNotifierItemDbusObj(ObjectPath dbusmenuPath) + { + ObjectPath = new ObjectPath($"/StatusNotifierItem"); + + _backingProperties = new StatusNotifierItemProperties + { + Menu = dbusmenuPath, // Needs a dbus menu somehow + ToolTip = new ToolTip("") + }; + + InvalidateAll(); + } + + public Task ContextMenuAsync(int x, int y) => Task.CompletedTask; + + public Task ActivateAsync(int x, int y) + { + ActivationDelegate?.Invoke(); + return Task.CompletedTask; + } + + public Task SecondaryActivateAsync(int x, int y) => Task.CompletedTask; + + public Task ScrollAsync(int delta, string orientation) => Task.CompletedTask; + + public void InvalidateAll() + { + OnTitleChanged?.Invoke(); + OnIconChanged?.Invoke(); + OnOverlayIconChanged?.Invoke(); + OnAttentionIconChanged?.Invoke(); + OnTooltipChanged?.Invoke(); + } + + public Task WatchNewTitleAsync(Action handler, Action onError) + { + OnTitleChanged += handler; + return Task.FromResult(Disposable.Create(() => OnTitleChanged -= handler)); + } + + public Task WatchNewIconAsync(Action handler, Action onError) + { + OnIconChanged += handler; + return Task.FromResult(Disposable.Create(() => OnIconChanged -= handler)); + } + + public Task WatchNewAttentionIconAsync(Action handler, Action onError) + { + OnAttentionIconChanged += handler; + return Task.FromResult(Disposable.Create(() => OnAttentionIconChanged -= handler)); + } + + public Task WatchNewOverlayIconAsync(Action handler, Action onError) + { + OnOverlayIconChanged += handler; + return Task.FromResult(Disposable.Create(() => OnOverlayIconChanged -= handler)); + } + + public Task WatchNewToolTipAsync(Action handler, Action onError) + { + OnTooltipChanged += handler; + return Task.FromResult(Disposable.Create(() => OnTooltipChanged -= handler)); + } + + public Task WatchNewStatusAsync(Action handler, Action onError) + { + NewStatusAsync += handler; + return Task.FromResult(Disposable.Create(() => NewStatusAsync -= handler)); + } + + public Task GetAsync(string prop) => Task.FromResult(new object()); + + public Task GetAllAsync() => Task.FromResult(_backingProperties); + + public Task SetAsync(string prop, object val) => Task.CompletedTask; + + public Task WatchPropertiesAsync(Action handler) => + Task.FromResult(Disposable.Empty); + + public void SetIcon(DbusPixmap dbusPixmap) + { + _backingProperties.IconPixmap = new[] { dbusPixmap }; + InvalidateAll(); + } + + public void SetTitleAndTooltip(string? text) + { + if (text is null) + return; + + _backingProperties.Id = text; + _backingProperties.Category = "ApplicationStatus"; + _backingProperties.Status = text; + _backingProperties.Title = text; + _backingProperties.ToolTip = new ToolTip(text); + + InvalidateAll(); + } + } + + [DBusInterface("org.kde.StatusNotifierWatcher")] + internal interface IStatusNotifierWatcher : IDBusObject + { + Task RegisterStatusNotifierItemAsync(string Service); + Task RegisterStatusNotifierHostAsync(string Service); + } + + [DBusInterface("org.kde.StatusNotifierItem")] + internal interface IStatusNotifierItem : IDBusObject + { + Task ContextMenuAsync(int x, int y); + Task ActivateAsync(int x, int y); + Task SecondaryActivateAsync(int x, int y); + Task ScrollAsync(int delta, string orientation); + Task WatchNewTitleAsync(Action handler, Action onError); + Task WatchNewIconAsync(Action handler, Action onError); + Task WatchNewAttentionIconAsync(Action handler, Action onError); + Task WatchNewOverlayIconAsync(Action handler, Action onError); + Task WatchNewToolTipAsync(Action handler, Action onError); + Task WatchNewStatusAsync(Action handler, Action onError); + Task GetAsync(string prop); + Task GetAllAsync(); + Task SetAsync(string prop, object val); + Task WatchPropertiesAsync(Action handler); + } + + [Dictionary] + /// This class is used by Tmds.Dbus to ferry properties + /// from the SNI spec. + /// Don't change this to actual C# properties since + /// Tmds.Dbus will get confused. + internal class StatusNotifierItemProperties + { + public string? Category; + + public string? Id; + + public string? Title; + + public string? Status; + + public ObjectPath Menu; + + public DbusPixmap[]? IconPixmap; + + public ToolTip ToolTip; + } + + internal struct ToolTip + { + public readonly string First; + public readonly DbusPixmap[] Second; + public readonly string Third; + public readonly string Fourth; + + private static readonly DbusPixmap[] s_blank = + { + new DbusPixmap(0, 0, Array.Empty()), new DbusPixmap(0, 0, Array.Empty()) + }; + + public ToolTip(string message) : this("", s_blank, message, "") + { + } + + public ToolTip(string first, DbusPixmap[] second, string third, string fourth) + { + First = first; + Second = second; + Third = third; + Fourth = fourth; + } + } + + internal readonly struct DbusPixmap + { + public readonly int Width; + public readonly int Height; + public readonly byte[] Data; + + public DbusPixmap(int width, int height, byte[] data) + { + Width = width; + Height = height; + Data = data; + } + } +} diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 14645bc415b..7bc8872fa77 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -190,7 +190,7 @@ public X11Window(AvaloniaX11Platform platform, IWindowImpl popupParent) if(_popup) PopupPositioner = new ManagedPopupPositioner(new ManagedPopupPositionerPopupImplHelper(popupParent, MoveResize)); if (platform.Options.UseDBusMenu) - NativeMenuExporter = DBusMenuExporter.TryCreate(_handle); + NativeMenuExporter = DBusMenuExporter.TryCreateTopLevelNativeMenu(_handle); NativeControlHost = new X11NativeControlHost(_platform, this); InitializeIme(); } diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index 6a6bcc57157..938f4222e0c 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -1110,6 +1110,9 @@ public static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr handle) [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr SetActiveWindow(IntPtr hWnd); + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern IntPtr SetCapture(IntPtr hWnd); @@ -1197,6 +1200,9 @@ public enum ClassLongIndex : int GCW_ATOM = -32 } + [DllImport("shell32", CharSet = CharSet.Auto)] + public static extern int Shell_NotifyIcon(NIM dwMessage, NOTIFYICONDATA lpData); + [DllImport("user32.dll", EntryPoint = "SetClassLongPtr")] private static extern IntPtr SetClassLong64(IntPtr hWnd, ClassLongIndex nIndex, IntPtr dwNewLong); @@ -2296,4 +2302,61 @@ internal struct PixelFormatDescriptor public uint VisibleMask; public uint DamageMask; } + + internal enum NIM : uint + { + ADD = 0x00000000, + MODIFY = 0x00000001, + DELETE = 0x00000002, + SETFOCUS = 0x00000003, + SETVERSION = 0x00000004 + } + + [Flags] + internal enum NIF : uint + { + MESSAGE = 0x00000001, + ICON = 0x00000002, + TIP = 0x00000004, + STATE = 0x00000008, + INFO = 0x00000010, + GUID = 0x00000020, + REALTIME = 0x00000040, + SHOWTIP = 0x00000080 + } + + [Flags] + internal enum NIIF : uint + { + NONE = 0x00000000, + INFO = 0x00000001, + WARNING = 0x00000002, + ERROR = 0x00000003, + USER = 0x00000004, + ICON_MASK = 0x0000000F, + NOSOUND = 0x00000010, + LARGE_ICON = 0x00000020, + RESPECT_QUIET_TIME = 0x00000080 + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + internal class NOTIFYICONDATA + { + public int cbSize = Marshal.SizeOf(); + public IntPtr hWnd; + public int uID; + public NIF uFlags; + public int uCallbackMessage; + public IntPtr hIcon; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string szTip; + public int dwState = 0; + public int dwStateMask = 0; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string szInfo; + public int uTimeoutOrVersion; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string szInfoTitle; + public NIIF dwInfoFlags; + } } diff --git a/src/Windows/Avalonia.Win32/TrayIconImpl.cs b/src/Windows/Avalonia.Win32/TrayIconImpl.cs new file mode 100644 index 00000000000..23395dd9b5b --- /dev/null +++ b/src/Windows/Avalonia.Win32/TrayIconImpl.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Platform; +using Avalonia.Controls.Primitives.PopupPositioning; +using Avalonia.LogicalTree; +using Avalonia.Platform; +using Avalonia.Styling; +using Avalonia.Win32.Interop; +using static Avalonia.Win32.Interop.UnmanagedMethods; + +#nullable enable + +namespace Avalonia.Win32 +{ + public class TrayIconImpl : ITrayIconImpl + { + private readonly int _uniqueId; + private static int s_nextUniqueId; + private bool _iconAdded; + private IconImpl? _icon; + private string? _tooltipText; + private readonly Win32NativeToManagedMenuExporter _exporter; + private static readonly Dictionary s_trayIcons = new Dictionary(); + private bool _disposedValue; + + public TrayIconImpl() + { + _exporter = new Win32NativeToManagedMenuExporter(); + + _uniqueId = ++s_nextUniqueId; + + s_trayIcons.Add(_uniqueId, this); + } + + public Action? OnClicked { get; set; } + + public INativeMenuExporter MenuExporter => _exporter; + + internal static void ProcWnd(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) + { + if (msg == (int)CustomWindowsMessage.WM_TRAYMOUSE && s_trayIcons.ContainsKey(wParam.ToInt32())) + { + s_trayIcons[wParam.ToInt32()].WndProc(hWnd, msg, wParam, lParam); + } + } + + public void SetIcon(IWindowIconImpl? icon) + { + _icon = icon as IconImpl; + UpdateIcon(); + } + + public void SetIsVisible(bool visible) + { + UpdateIcon(!visible); + } + + public void SetToolTipText(string? text) + { + _tooltipText = text; + UpdateIcon(!_iconAdded); + } + + private void UpdateIcon(bool remove = false) + { + var iconData = new NOTIFYICONDATA() + { + hWnd = Win32Platform.Instance.Handle, + uID = _uniqueId, + uFlags = NIF.TIP | NIF.MESSAGE, + uCallbackMessage = (int)CustomWindowsMessage.WM_TRAYMOUSE, + hIcon = _icon?.HIcon ?? new IconImpl(new System.Drawing.Bitmap(32, 32)).HIcon, + szTip = _tooltipText ?? "" + }; + + if (!remove) + { + iconData.uFlags |= NIF.ICON; + + if (!_iconAdded) + { + Shell_NotifyIcon(NIM.ADD, iconData); + _iconAdded = true; + } + else + { + Shell_NotifyIcon(NIM.MODIFY, iconData); + } + } + else + { + Shell_NotifyIcon(NIM.DELETE, iconData); + _iconAdded = false; + } + } + + private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) + { + if (msg == (uint)CustomWindowsMessage.WM_TRAYMOUSE) + { + // Determine the type of message and call the matching event handlers + switch (lParam.ToInt32()) + { + case (int)WindowsMessage.WM_LBUTTONUP: + OnClicked?.Invoke(); + break; + + case (int)WindowsMessage.WM_RBUTTONUP: + OnRightClicked(); + break; + } + + return IntPtr.Zero; + } + + return DefWindowProc(hWnd, msg, wParam, lParam); + } + + private void OnRightClicked() + { + var _trayMenu = new TrayPopupRoot() + { + SystemDecorations = SystemDecorations.None, + SizeToContent = SizeToContent.WidthAndHeight, + Background = null, + TransparencyLevelHint = WindowTransparencyLevel.Transparent, + Content = new TrayIconMenuFlyoutPresenter() + { + Items = _exporter.GetMenu() + } + }; + + GetCursorPos(out POINT pt); + + _trayMenu.Position = new PixelPoint(pt.X, pt.Y); + + _trayMenu.Show(); + } + + /// + /// Custom Win32 window messages for the NotifyIcon + /// + private enum CustomWindowsMessage : uint + { + WM_TRAYICON = WindowsMessage.WM_APP + 1024, + WM_TRAYMOUSE = WindowsMessage.WM_USER + 1024 + } + + private class TrayIconMenuFlyoutPresenter : MenuFlyoutPresenter, IStyleable + { + Type IStyleable.StyleKey => typeof(MenuFlyoutPresenter); + + public override void Close() + { + // DefaultMenuInteractionHandler calls this + var host = this.FindLogicalAncestorOfType(); + if (host != null) + { + SelectedIndex = -1; + host.Close(); + } + } + } + + private class TrayPopupRoot : Window + { + private readonly ManagedPopupPositioner _positioner; + + public TrayPopupRoot() + { + _positioner = new ManagedPopupPositioner(new TrayIconManagedPopupPositionerPopupImplHelper(MoveResize)); + Topmost = true; + + Deactivated += TrayPopupRoot_Deactivated; + + ShowInTaskbar = false; + + ShowActivated = true; + } + + private void TrayPopupRoot_Deactivated(object sender, EventArgs e) + { + Close(); + } + + private void MoveResize(PixelPoint position, Size size, double scaling) + { + PlatformImpl!.Move(position); + PlatformImpl!.Resize(size, PlatformResizeReason.Layout); + } + + protected override void ArrangeCore(Rect finalRect) + { + base.ArrangeCore(finalRect); + + _positioner.Update(new PopupPositionerParameters + { + Anchor = PopupAnchor.TopLeft, + Gravity = PopupGravity.BottomRight, + AnchorRectangle = new Rect(Position.ToPoint(1) / Screens.Primary.PixelDensity, new Size(1, 1)), + Size = finalRect.Size, + ConstraintAdjustment = PopupPositionerConstraintAdjustment.FlipX | PopupPositionerConstraintAdjustment.FlipY, + }); + } + + private class TrayIconManagedPopupPositionerPopupImplHelper : IManagedPopupPositionerPopup + { + private readonly Action _moveResize; + private readonly Window _hiddenWindow; + + public TrayIconManagedPopupPositionerPopupImplHelper(Action moveResize) + { + _moveResize = moveResize; + _hiddenWindow = new Window(); + } + + public IReadOnlyList Screens => + _hiddenWindow.Screens.All.Select(s => new ManagedPopupPositionerScreenInfo( + s.Bounds.ToRect(1), s.Bounds.ToRect(1))).ToList(); + + public Rect ParentClientAreaScreenGeometry + { + get + { + var point = _hiddenWindow.Screens.Primary.Bounds.TopLeft; + var size = _hiddenWindow.Screens.Primary.Bounds.Size; + return new Rect(point.X, point.Y, size.Width * _hiddenWindow.Screens.Primary.PixelDensity, size.Height * _hiddenWindow.Screens.Primary.PixelDensity); + } + } + + public void MoveAndResize(Point devicePoint, Size virtualSize) + { + _moveResize(new PixelPoint((int)devicePoint.X, (int)devicePoint.Y), virtualSize, _hiddenWindow.Screens.Primary.PixelDensity); + } + + public double Scaling => _hiddenWindow.Screens.Primary.PixelDensity; + } + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + UpdateIcon(true); + + _disposedValue = true; + } + } + + ~TrayIconImpl() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: false); + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs b/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs new file mode 100644 index 00000000000..fa6f9927b53 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Win32NativeToManagedMenuExporter.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Controls.Platform; + +#nullable enable + +namespace Avalonia.Win32 +{ + internal class Win32NativeToManagedMenuExporter : INativeMenuExporter + { + private NativeMenu? _nativeMenu; + + public void SetNativeMenu(NativeMenu? nativeMenu) + { + _nativeMenu = nativeMenu; + } + + private IEnumerable Populate(NativeMenu nativeMenu) + { + foreach (var menuItem in nativeMenu.Items) + { + if (menuItem is NativeMenuItemSeparator) + { + yield return new MenuItem { Header = "-" }; + } + else if (menuItem is NativeMenuItem item) + { + var newItem = new MenuItem { Header = item.Header, Icon = item.Icon, Command = item.Command, CommandParameter = item.CommandParameter }; + + if (item.Menu != null) + { + newItem.Items = Populate(item.Menu); + } + else if (item.HasClickHandlers && item is INativeMenuItemExporterEventsImplBridge bridge) + { + newItem.Click += (_, __) => bridge.RaiseClicked(); + } + + yield return newItem; + } + } + } + + public IEnumerable? GetMenu() + { + if (_nativeMenu != null) + { + return Populate(_nativeMenu); + } + + return null; + } + } +} diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index c011a458c30..45fa8f44ce0 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -108,6 +108,10 @@ public Win32Platform() CreateMessageWindow(); } + internal static Win32Platform Instance => s_instance; + + internal IntPtr Handle => _hwnd; + /// /// Gets the actual WindowsVersion. Same as the info returned from RtlGetVersion. /// @@ -261,6 +265,8 @@ private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) } } } + + TrayIconImpl.ProcWnd(hWnd, msg, wParam, lParam); return UnmanagedMethods.DefWindowProc(hWnd, msg, wParam, lParam); } @@ -293,6 +299,11 @@ private void CreateMessageWindow() } } + public ITrayIconImpl CreateTrayIcon () + { + return new TrayIconImpl(); + } + public IWindowImpl CreateWindow() { return new WindowImpl(); diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 8fc25f8cfa5..d1b2115cf6a 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -507,7 +507,7 @@ public void Resize(Size value, PlatformResizeReason reason) public void Activate() { - SetActiveWindow(_hwnd); + SetForegroundWindow(_hwnd); } public IPopupImpl CreatePopup() => Win32Platform.UseOverlayPopups ? null : new PopupImpl(this); @@ -1000,6 +1000,7 @@ private void ShowWindow(WindowState state, bool activate) if (!Design.IsDesignMode && activate) { SetFocus(_hwnd); + SetForegroundWindow(_hwnd); } } diff --git a/src/iOS/Avalonia.iOS/Stubs.cs b/src/iOS/Avalonia.iOS/Stubs.cs index c2526d7d9f7..9c46aa78cc1 100644 --- a/src/iOS/Avalonia.iOS/Stubs.cs +++ b/src/iOS/Avalonia.iOS/Stubs.cs @@ -21,6 +21,8 @@ class WindowingPlatformStub : IWindowingPlatform public IWindowImpl CreateWindow() => throw new NotSupportedException(); public IWindowImpl CreateEmbeddableWindow() => throw new NotSupportedException(); + + public ITrayIconImpl CreateTrayIcon() => null; } class PlatformIconLoaderStub : IPlatformIconLoader diff --git a/tests/Avalonia.Controls.UnitTests/WindowingPlatformMock.cs b/tests/Avalonia.Controls.UnitTests/WindowingPlatformMock.cs index bf1322afbc3..e8471d41fbd 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowingPlatformMock.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowingPlatformMock.cs @@ -25,6 +25,11 @@ public IWindowImpl CreateEmbeddableWindow() throw new NotImplementedException(); } + public ITrayIconImpl CreateTrayIcon() + { + return null; + } + public IPopupImpl CreatePopup() => _popupImpl?.Invoke() ?? Mock.Of(x => x.RenderScaling == 1); } } diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs index bc003537f47..eb18030ca87 100644 --- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs +++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs @@ -126,6 +126,11 @@ public IWindowImpl CreateEmbeddableWindow() throw new NotImplementedException(); } + public ITrayIconImpl CreateTrayIcon() + { + return null; + } + private static void SetupToplevel(Mock mock) where T : class, ITopLevelImpl { mock.SetupGet(x => x.MouseDevice).Returns(new MouseDevice());