Browse Source
Feature/tray icon support # Conflicts: # src/Avalonia.Controls/ApiCompatBaseline.txtrelease/0.10.8
36 changed files with 1440 additions and 51 deletions
@ -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<IAvnTrayIcon, &IID_IAvnTrayIcon> |
||||
|
{ |
||||
|
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 */ |
||||
@ -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<AvnAppMenu*>(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; |
||||
|
} |
||||
@ -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; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
using System; |
||||
|
using Avalonia.Controls.Platform; |
||||
|
|
||||
|
#nullable enable |
||||
|
|
||||
|
namespace Avalonia.Platform |
||||
|
{ |
||||
|
public interface ITrayIconImpl : IDisposable |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Sets the icon of this tray icon.
|
||||
|
/// </summary>
|
||||
|
void SetIcon(IWindowIconImpl? icon); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Sets the icon of this tray icon.
|
||||
|
/// </summary>
|
||||
|
void SetToolTipText(string? text); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Sets if the tray icon is visible or not.
|
||||
|
/// </summary>
|
||||
|
void SetIsVisible(bool visible); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets the MenuExporter to allow native menus to be exported to the TrayIcon.
|
||||
|
/// </summary>
|
||||
|
INativeMenuExporter? MenuExporter { get; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or Sets the Action that is called when the TrayIcon is clicked.
|
||||
|
/// </summary>
|
||||
|
Action? OnClicked { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -1,8 +1,13 @@ |
|||||
|
#nullable enable |
||||
|
|
||||
namespace Avalonia.Platform |
namespace Avalonia.Platform |
||||
{ |
{ |
||||
public interface IWindowingPlatform |
public interface IWindowingPlatform |
||||
{ |
{ |
||||
IWindowImpl CreateWindow(); |
IWindowImpl CreateWindow(); |
||||
|
|
||||
IWindowImpl CreateEmbeddableWindow(); |
IWindowImpl CreateEmbeddableWindow(); |
||||
|
|
||||
|
ITrayIconImpl? CreateTrayIcon(); |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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<TrayIcon> |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Raised when the TrayIcon is clicked.
|
||||
|
/// Note, this is only supported on Win32 and some Linux DEs,
|
||||
|
/// on OSX this event is not raised.
|
||||
|
/// </summary>
|
||||
|
public event EventHandler? Clicked; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Defines the <see cref="TrayIcons"/> attached property.
|
||||
|
/// </summary>
|
||||
|
public static readonly AttachedProperty<TrayIcons> IconsProperty |
||||
|
= AvaloniaProperty.RegisterAttached<TrayIcon, Application, TrayIcons>("Icons"); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Defines the <see cref="Menu"/> property.
|
||||
|
/// </summary>
|
||||
|
public static readonly StyledProperty<NativeMenu?> MenuProperty |
||||
|
= AvaloniaProperty.Register<TrayIcon, NativeMenu?>(nameof(Menu)); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Defines the <see cref="Icon"/> property.
|
||||
|
/// </summary>
|
||||
|
public static readonly StyledProperty<WindowIcon> IconProperty = |
||||
|
Window.IconProperty.AddOwner<TrayIcon>(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Defines the <see cref="ToolTipText"/> property.
|
||||
|
/// </summary>
|
||||
|
public static readonly StyledProperty<string?> ToolTipTextProperty = |
||||
|
AvaloniaProperty.Register<TrayIcon, string?>(nameof(ToolTipText)); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Defines the <see cref="IsVisible"/> property.
|
||||
|
/// </summary>
|
||||
|
public static readonly StyledProperty<bool> IsVisibleProperty = |
||||
|
Visual.IsVisibleProperty.AddOwner<TrayIcon>(); |
||||
|
|
||||
|
public static void SetIcons(AvaloniaObject o, TrayIcons trayIcons) => o.SetValue(IconsProperty, trayIcons); |
||||
|
|
||||
|
public static TrayIcons GetIcons(AvaloniaObject o) => o.GetValue(IconsProperty); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the Menu of the TrayIcon.
|
||||
|
/// </summary>
|
||||
|
public NativeMenu? Menu |
||||
|
{ |
||||
|
get => GetValue(MenuProperty); |
||||
|
set => SetValue(MenuProperty, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the icon of the TrayIcon.
|
||||
|
/// </summary>
|
||||
|
public WindowIcon Icon |
||||
|
{ |
||||
|
get => GetValue(IconProperty); |
||||
|
set => SetValue(IconProperty, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the tooltip text of the TrayIcon.
|
||||
|
/// </summary>
|
||||
|
public string? ToolTipText |
||||
|
{ |
||||
|
get => GetValue(ToolTipTextProperty); |
||||
|
set => SetValue(ToolTipTextProperty, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Gets or sets the visibility of the TrayIcon.
|
||||
|
/// </summary>
|
||||
|
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<TrayIcon>()); |
||||
|
} |
||||
|
|
||||
|
private static void RemoveIcons(IEnumerable<TrayIcon> icons) |
||||
|
{ |
||||
|
foreach (var icon in icons) |
||||
|
{ |
||||
|
icon.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected override void OnPropertyChanged<T>(AvaloniaPropertyChangedEventArgs<T> change) |
||||
|
{ |
||||
|
base.OnPropertyChanged(change); |
||||
|
|
||||
|
if (change.Property == IconProperty) |
||||
|
{ |
||||
|
_impl?.SetIcon(Icon.PlatformImpl); |
||||
|
} |
||||
|
else if (change.Property == IsVisibleProperty) |
||||
|
{ |
||||
|
_impl?.SetIsVisible(change.NewValue.GetValueOrDefault<bool>()); |
||||
|
} |
||||
|
else if (change.Property == ToolTipTextProperty) |
||||
|
{ |
||||
|
_impl?.SetToolTipText(change.NewValue.GetValueOrDefault<string?>()); |
||||
|
} |
||||
|
else if (change.Property == MenuProperty) |
||||
|
{ |
||||
|
_impl?.MenuExporter?.SetNativeMenu(change.NewValue.GetValueOrDefault<NativeMenu>()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Disposes the tray icon (removing it from the tray area).
|
||||
|
/// </summary>
|
||||
|
public void Dispose() => _impl?.Dispose(); |
||||
|
} |
||||
|
} |
||||
@ -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; } |
||||
|
} |
||||
|
} |
||||
@ -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<IStatusNotifierWatcher>( |
||||
|
"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); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// DBus Object used for setting system tray icons.
|
||||
|
/// </summary>
|
||||
|
/// <remarks>
|
||||
|
/// Useful guide: https://web.archive.org/web/20210818173850/https://www.notmart.org/misc/statusnotifieritem/statusnotifieritem.html
|
||||
|
/// </remarks>
|
||||
|
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<string>? 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<IDisposable> WatchNewTitleAsync(Action handler, Action<Exception> onError) |
||||
|
{ |
||||
|
OnTitleChanged += handler; |
||||
|
return Task.FromResult(Disposable.Create(() => OnTitleChanged -= handler)); |
||||
|
} |
||||
|
|
||||
|
public Task<IDisposable> WatchNewIconAsync(Action handler, Action<Exception> onError) |
||||
|
{ |
||||
|
OnIconChanged += handler; |
||||
|
return Task.FromResult(Disposable.Create(() => OnIconChanged -= handler)); |
||||
|
} |
||||
|
|
||||
|
public Task<IDisposable> WatchNewAttentionIconAsync(Action handler, Action<Exception> onError) |
||||
|
{ |
||||
|
OnAttentionIconChanged += handler; |
||||
|
return Task.FromResult(Disposable.Create(() => OnAttentionIconChanged -= handler)); |
||||
|
} |
||||
|
|
||||
|
public Task<IDisposable> WatchNewOverlayIconAsync(Action handler, Action<Exception> onError) |
||||
|
{ |
||||
|
OnOverlayIconChanged += handler; |
||||
|
return Task.FromResult(Disposable.Create(() => OnOverlayIconChanged -= handler)); |
||||
|
} |
||||
|
|
||||
|
public Task<IDisposable> WatchNewToolTipAsync(Action handler, Action<Exception> onError) |
||||
|
{ |
||||
|
OnTooltipChanged += handler; |
||||
|
return Task.FromResult(Disposable.Create(() => OnTooltipChanged -= handler)); |
||||
|
} |
||||
|
|
||||
|
public Task<IDisposable> WatchNewStatusAsync(Action<string> handler, Action<Exception> onError) |
||||
|
{ |
||||
|
NewStatusAsync += handler; |
||||
|
return Task.FromResult(Disposable.Create(() => NewStatusAsync -= handler)); |
||||
|
} |
||||
|
|
||||
|
public Task<object> GetAsync(string prop) => Task.FromResult(new object()); |
||||
|
|
||||
|
public Task<StatusNotifierItemProperties> GetAllAsync() => Task.FromResult(_backingProperties); |
||||
|
|
||||
|
public Task SetAsync(string prop, object val) => Task.CompletedTask; |
||||
|
|
||||
|
public Task<IDisposable> WatchPropertiesAsync(Action<PropertyChanges> 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<IDisposable> WatchNewTitleAsync(Action handler, Action<Exception> onError); |
||||
|
Task<IDisposable> WatchNewIconAsync(Action handler, Action<Exception> onError); |
||||
|
Task<IDisposable> WatchNewAttentionIconAsync(Action handler, Action<Exception> onError); |
||||
|
Task<IDisposable> WatchNewOverlayIconAsync(Action handler, Action<Exception> onError); |
||||
|
Task<IDisposable> WatchNewToolTipAsync(Action handler, Action<Exception> onError); |
||||
|
Task<IDisposable> WatchNewStatusAsync(Action<string> handler, Action<Exception> onError); |
||||
|
Task<object> GetAsync(string prop); |
||||
|
Task<StatusNotifierItemProperties> GetAllAsync(); |
||||
|
Task SetAsync(string prop, object val); |
||||
|
Task<IDisposable> WatchPropertiesAsync(Action<PropertyChanges> 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<byte>()), new DbusPixmap(0, 0, Array.Empty<byte>()) |
||||
|
}; |
||||
|
|
||||
|
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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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<int, TrayIconImpl> s_trayIcons = new Dictionary<int, TrayIconImpl>(); |
||||
|
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(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Custom Win32 window messages for the NotifyIcon
|
||||
|
/// </summary>
|
||||
|
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<TrayPopupRoot>(); |
||||
|
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<PixelPoint, Size, double> _moveResize; |
||||
|
private readonly Window _hiddenWindow; |
||||
|
|
||||
|
public TrayIconManagedPopupPositionerPopupImplHelper(Action<PixelPoint, Size, double> moveResize) |
||||
|
{ |
||||
|
_moveResize = moveResize; |
||||
|
_hiddenWindow = new Window(); |
||||
|
} |
||||
|
|
||||
|
public IReadOnlyList<ManagedPopupPositionerScreenInfo> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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<MenuItem> 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<MenuItem>? GetMenu() |
||||
|
{ |
||||
|
if (_nativeMenu != null) |
||||
|
{ |
||||
|
return Populate(_nativeMenu); |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue