From 19962e4c4dad850aec3208a5b2b8d72fba583ffe Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Wed, 11 Sep 2019 22:41:12 +0300 Subject: [PATCH] NativeMenu/NativeMenu item with dbusmenu-based exporter --- Avalonia.sln.DotSettings | 1 + samples/ControlCatalog.NetCore/Program.cs | 6 +- samples/ControlCatalog/MainWindow.xaml | 29 +- samples/ControlCatalog/MainWindow.xaml.cs | 14 + src/Avalonia.Controls/Application.cs | 17 + src/Avalonia.Controls/NativeMenu.Export.cs | 100 +++++ src/Avalonia.Controls/NativeMenu.cs | 61 +++ src/Avalonia.Controls/NativeMenuItem.cs | 171 ++++++++ .../Platform/ITopLevelNativeMenuExporter.cs | 19 + .../Avalonia.FreeDesktop.csproj | 4 + src/Avalonia.FreeDesktop/DBusHelper.cs | 85 ++++ src/Avalonia.FreeDesktop/DBusMenu.cs | 56 +++ src/Avalonia.FreeDesktop/DBusMenuExporter.cs | 364 ++++++++++++++++++ src/Avalonia.X11/X11Platform.cs | 5 +- src/Avalonia.X11/X11Window.cs | 7 +- 15 files changed, 935 insertions(+), 4 deletions(-) create mode 100644 src/Avalonia.Controls/NativeMenu.Export.cs create mode 100644 src/Avalonia.Controls/NativeMenu.cs create mode 100644 src/Avalonia.Controls/NativeMenuItem.cs create mode 100644 src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs create mode 100644 src/Avalonia.FreeDesktop/DBusHelper.cs create mode 100644 src/Avalonia.FreeDesktop/DBusMenu.cs create mode 100644 src/Avalonia.FreeDesktop/DBusMenuExporter.cs diff --git a/Avalonia.sln.DotSettings b/Avalonia.sln.DotSettings index 1361172fff..7060f4a62a 100644 --- a/Avalonia.sln.DotSettings +++ b/Avalonia.sln.DotSettings @@ -3,6 +3,7 @@ ExplicitlyExcluded ExplicitlyExcluded ExplicitlyExcluded + DO_NOT_SHOW HINT <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> diff --git a/samples/ControlCatalog.NetCore/Program.cs b/samples/ControlCatalog.NetCore/Program.cs index d683092edf..854cae484c 100644 --- a/samples/ControlCatalog.NetCore/Program.cs +++ b/samples/ControlCatalog.NetCore/Program.cs @@ -55,7 +55,11 @@ namespace ControlCatalog.NetCore public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() - .With(new X11PlatformOptions { EnableMultiTouch = true }) + .With(new X11PlatformOptions + { + EnableMultiTouch = true, + UseDBusMenu = true + }) .With(new Win32PlatformOptions { EnableMultitouch = true, diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml index 9527ac3b4e..fca155ef70 100644 --- a/samples/ControlCatalog/MainWindow.xaml +++ b/samples/ControlCatalog/MainWindow.xaml @@ -8,7 +8,34 @@ xmlns:vm="clr-namespace:ControlCatalog.ViewModels" xmlns:v="clr-namespace:ControlCatalog.Views" x:Class="ControlCatalog.MainWindow"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/MainWindow.xaml.cs b/samples/ControlCatalog/MainWindow.xaml.cs index 95c65ed92f..9f62c0da38 100644 --- a/samples/ControlCatalog/MainWindow.xaml.cs +++ b/samples/ControlCatalog/MainWindow.xaml.cs @@ -14,6 +14,7 @@ namespace ControlCatalog public class MainWindow : Window { private WindowNotificationManager _notificationArea; + private NativeMenu _recentMenu; public MainWindow() { @@ -29,8 +30,21 @@ namespace ControlCatalog }; DataContext = new MainWindowViewModel(_notificationArea); + _recentMenu = NativeMenu.GetMenu(this).Items[0].Menu.Items[1].Menu; } + public void OnOpenClicked(object sender, EventArgs args) + { + _recentMenu.Items.Insert(0, new NativeMenuItem("Item " + (_recentMenu.Items.Count + 1))); + } + + public void OnCloseClicked(object sender, EventArgs args) + { + Close(); + } + + + private void InitializeComponent() { // TODO: iOS does not support dynamically loading assemblies diff --git a/src/Avalonia.Controls/Application.cs b/src/Avalonia.Controls/Application.cs index ba842d8825..ce60a0f0b9 100644 --- a/src/Avalonia.Controls/Application.cs +++ b/src/Avalonia.Controls/Application.cs @@ -210,5 +210,22 @@ namespace Avalonia { ResourcesChanged?.Invoke(this, e); } + + private string _name; + /// + /// Defines Name property + /// + public static readonly DirectProperty NameProperty = + AvaloniaProperty.RegisterDirect("Name", o => o.Name, (o, v) => o.Name = v); + + /// + /// Application name to be used for various platform-specific purposes + /// + public string Name + { + get => _name; + set => SetAndRaise(NameProperty, ref _name, value); + } + } } diff --git a/src/Avalonia.Controls/NativeMenu.Export.cs b/src/Avalonia.Controls/NativeMenu.Export.cs new file mode 100644 index 0000000000..9ce83f3e3c --- /dev/null +++ b/src/Avalonia.Controls/NativeMenu.Export.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls.Platform; +using Avalonia.Data; + +namespace Avalonia.Controls +{ + public partial class NativeMenu + { + public static readonly AttachedProperty IsNativeMenuExportedProperty = + AvaloniaProperty.RegisterAttached("IsNativeMenuExported", + defaultBindingMode: BindingMode.OneWayToSource); + + public static bool GetIsNativeMenuExported(TopLevel tl) => tl.GetValue(IsNativeMenuExportedProperty); + + private static readonly AttachedProperty s_nativeMenuInfoProperty = + AvaloniaProperty.RegisterAttached("___NativeMenuInfo"); + + class NativeMenuInfo + { + public bool ChangingIsExported { get; set; } + public ITopLevelNativeMenuExporter Exporter { get; } + + public NativeMenuInfo(TopLevel target) + { + Exporter = (target.PlatformImpl as ITopLevelImplWithNativeMenuExporter)?.NativeMenuExporter; + if (Exporter != null) + { + Exporter.OnIsNativeMenuExportedChanged += delegate + { + SetIsNativeMenuExported(target, Exporter.IsNativeMenuExported); + }; + } + } + } + + static NativeMenuInfo GetInfo(TopLevel target) + { + var rv = target.GetValue(s_nativeMenuInfoProperty); + if (rv == null) + { + target.SetValue(s_nativeMenuInfoProperty, rv = new NativeMenuInfo(target)); + SetIsNativeMenuExported(target, rv.Exporter.IsNativeMenuExported); + } + + return rv; + } + + static void SetIsNativeMenuExported(TopLevel tl, bool value) + { + GetInfo(tl).ChangingIsExported = true; + tl.SetValue(IsNativeMenuExportedProperty, value); + } + + public static readonly AttachedProperty MenuProperty + = AvaloniaProperty.RegisterAttached("NativeMenuItems", validate: + (o, v) => + { + if(!(o is Application || o is TopLevel)) + throw new InvalidOperationException("NativeMenu.Menu property isn't valid on "+o.GetType()); + return v; + }); + + public static void SetMenu(AvaloniaObject o, NativeMenu menu) => o.SetValue(MenuProperty, menu); + public static NativeMenu GetMenu(AvaloniaObject o) => o.GetValue(MenuProperty); + + + public static readonly AttachedProperty PrependApplicationMenuProperty + = AvaloniaProperty.RegisterAttached("PrependApplicationMenu"); + + public static void SetPrependApplicationMenu(TopLevel tl, bool value) => + tl.SetValue(PrependApplicationMenuProperty, value); + + public static bool GetPrependApplicationMenu(TopLevel tl) => tl.GetValue(PrependApplicationMenuProperty); + + static NativeMenu() + { + // This is needed because of the lack of attached direct properties + IsNativeMenuExportedProperty.Changed.Subscribe(args => + { + var info = GetInfo((TopLevel)args.Sender); + if (!info.ChangingIsExported) + throw new InvalidOperationException("IsNativeMenuExported property is read-only"); + info.ChangingIsExported = false; + }); + MenuProperty.Changed.Subscribe(args => + { + if (args.Sender is TopLevel tl) + { + GetInfo(tl).Exporter?.SetNativeMenu((NativeMenu)args.NewValue); + } + }); + + PrependApplicationMenuProperty.Changed.Subscribe(args => + { + GetInfo((TopLevel)args.Sender).Exporter?.SetPrependApplicationMenu((bool)args.NewValue); + }); + } + } +} diff --git a/src/Avalonia.Controls/NativeMenu.cs b/src/Avalonia.Controls/NativeMenu.cs new file mode 100644 index 0000000000..1e2966ff2b --- /dev/null +++ b/src/Avalonia.Controls/NativeMenu.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Collections; +using Avalonia.Data; +using Avalonia.LogicalTree; +using Avalonia.Metadata; + +namespace Avalonia.Controls +{ + public partial class NativeMenu : AvaloniaObject, IEnumerable + { + private AvaloniaList _items = + new AvaloniaList { ResetBehavior = ResetBehavior.Remove }; + private NativeMenuItem _parent; + [Content] + public IList Items => _items; + + public NativeMenu() + { + _items.Validate = Validator; + _items.CollectionChanged += ItemsChanged; + } + + private void Validator(NativeMenuItem obj) + { + if (obj.Parent != null) + throw new InvalidOperationException("NativeMenuItem already has a parent"); + } + + private void ItemsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if(e.OldItems!=null) + foreach (NativeMenuItem i in e.OldItems) + i.Parent = null; + if(e.NewItems!=null) + foreach (NativeMenuItem i in e.NewItems) + i.Parent = this; + } + + public static readonly DirectProperty ParentProperty = + AvaloniaProperty.RegisterDirect("Parent", o => o.Parent, (o, v) => o.Parent = v); + + public NativeMenuItem Parent + { + get => _parent; + set => SetAndRaise(ParentProperty, ref _parent, value); + } + + + public void Add(NativeMenuItem item) => _items.Add(item); + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/src/Avalonia.Controls/NativeMenuItem.cs b/src/Avalonia.Controls/NativeMenuItem.cs new file mode 100644 index 0000000000..3f1a80dcfe --- /dev/null +++ b/src/Avalonia.Controls/NativeMenuItem.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Windows.Input; +using Avalonia.Collections; +using Avalonia.Input; +using Avalonia.Metadata; +using Avalonia.Utilities; + +namespace Avalonia.Controls +{ + public class NativeMenuItem : AvaloniaObject + { + private string _header; + private KeyGesture _gesture; + private bool _enabled = true; + private NativeMenu _menu; + private NativeMenu _parent; + + class CanExecuteChangedSubscriber : IWeakSubscriber + { + private readonly NativeMenuItem _parent; + + public CanExecuteChangedSubscriber(NativeMenuItem parent) + { + _parent = parent; + } + + public void OnEvent(object sender, EventArgs e) + { + _parent.CanExecuteChanged(); + } + } + + private readonly CanExecuteChangedSubscriber _canExecuteChangedSubscriber; + + static NativeMenuItem() + { + MenuProperty.Changed.Subscribe(args => + { + var item = (NativeMenuItem)args.Sender; + var value = (NativeMenu)args.NewValue; + if (value.Parent != null && value.Parent != item) + throw new InvalidOperationException("NativeMenu already has a parent"); + value.Parent = item; + }); + } + + public NativeMenuItem() + { + _canExecuteChangedSubscriber = new CanExecuteChangedSubscriber(this); + } + + public NativeMenuItem(string header) : this() + { + Header = header; + } + + public static readonly DirectProperty HeaderProperty = + AvaloniaProperty.RegisterDirect(nameof(Header), o => o._header, (o, v) => o._header = v); + + public string Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public static readonly DirectProperty GestureProperty = + AvaloniaProperty.RegisterDirect(nameof(Gesture), o => o._gesture, (o,v)=> o._gesture = v); + + public KeyGesture Gesture + { + get => GetValue(GestureProperty); + set => SetValue(GestureProperty, value); + } + + private ICommand _command; + + public static readonly DirectProperty CommandProperty = + AvaloniaProperty.RegisterDirect(nameof(Command), + o => o._command, (o, v) => + { + if (o._command != null) + WeakSubscriptionManager.Unsubscribe(o._command, + nameof(ICommand.CanExecuteChanged), o._canExecuteChangedSubscriber); + o._command = v; + if (o._command != null) + WeakSubscriptionManager.Subscribe(o._command, + nameof(ICommand.CanExecuteChanged), o._canExecuteChangedSubscriber); + o.CanExecuteChanged(); + }); + + /// + /// Defines the property. + /// + public static readonly StyledProperty CommandParameterProperty = + Button.CommandParameterProperty.AddOwner(); + + public static readonly DirectProperty EnabledProperty = + AvaloniaProperty.RegisterDirect(nameof(Enabled), o => o._enabled, + (o, v) => o._enabled = v, true); + + public bool Enabled + { + get => GetValue(EnabledProperty); + set => SetValue(EnabledProperty, value); + } + + void CanExecuteChanged() + { + Enabled = _command?.CanExecute(null) ?? true; + } + + public ICommand Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + /// + /// Gets or sets the parameter to pass to the property of a + /// . + /// + public object CommandParameter + { + get { return GetValue(CommandParameterProperty); } + set { SetValue(CommandParameterProperty, value); } + } + + public static readonly DirectProperty MenuProperty = + AvaloniaProperty.RegisterDirect(nameof(Menu), o => o._menu, + (o, v) => + { + if (v.Parent != null && v.Parent != o) + throw new InvalidOperationException("NativeMenu already has a parent"); + o._menu = v; + }); + + public NativeMenu Menu + { + get => _menu; + set + { + if (value.Parent != null && value.Parent != this) + throw new InvalidOperationException("NativeMenu already has a parent"); + SetAndRaise(MenuProperty, ref _menu, value); + } + } + + public static readonly DirectProperty ParentProperty = + AvaloniaProperty.RegisterDirect("Parent", o => o.Parent, (o, v) => o.Parent = v); + + public NativeMenu Parent + { + get => _parent; + set => SetAndRaise(ParentProperty, ref _parent, value); + } + + + public event EventHandler Clicked; + + public void RaiseClick() + { + Clicked?.Invoke(this, new EventArgs()); + + if (Command?.CanExecute(CommandParameter) == true) + { + Command.Execute(CommandParameter); + } + } + } +} diff --git a/src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs b/src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs new file mode 100644 index 0000000000..5112424c3c --- /dev/null +++ b/src/Avalonia.Controls/Platform/ITopLevelNativeMenuExporter.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Avalonia.Platform; + +namespace Avalonia.Controls.Platform +{ + public interface ITopLevelNativeMenuExporter + { + bool IsNativeMenuExported { get; } + event EventHandler OnIsNativeMenuExportedChanged; + void SetNativeMenu(NativeMenu menu); + void SetPrependApplicationMenu(bool prepend); + } + + public interface ITopLevelImplWithNativeMenuExporter : ITopLevelImpl + { + ITopLevelNativeMenuExporter NativeMenuExporter { get; } + } +} diff --git a/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj b/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj index d7e1d8cdb3..d9fd3b78a2 100644 --- a/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj +++ b/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj @@ -8,4 +8,8 @@ + + + + diff --git a/src/Avalonia.FreeDesktop/DBusHelper.cs b/src/Avalonia.FreeDesktop/DBusHelper.cs new file mode 100644 index 0000000000..509e312e1b --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusHelper.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading; +using Avalonia.Threading; +using Tmds.DBus; + +namespace Avalonia.FreeDesktop +{ + public class DBusHelper + { + /// + /// This class uses synchronous execution at DBus connection establishment stage + /// then switches to using AvaloniaSynchronizationContext + /// + class DBusSyncContext : SynchronizationContext + { + private SynchronizationContext _ctx; + private object _lock = new object(); + + public override void Post(SendOrPostCallback d, object state) + { + lock (_lock) + { + if (_ctx != null) + _ctx?.Post(d, state); + else + lock (_lock) + d(state); + } + } + + public override void Send(SendOrPostCallback d, object state) + { + lock (_lock) + { + if (_ctx != null) + _ctx?.Send(d, state); + else + + d(state); + } + } + + public void Initialized() + { + lock (_lock) + _ctx = new AvaloniaSynchronizationContext(); + } + } + public static Connection Connection { get; private set; } + + public static Exception TryInitialize(string dbusAddress = null) + { + + Dispatcher.UIThread.VerifyAccess(); + AvaloniaSynchronizationContext.InstallIfNeeded(); + var oldContext = SynchronizationContext.Current; + try + { + + var dbusContext = new DBusSyncContext(); + SynchronizationContext.SetSynchronizationContext(dbusContext); + var conn = new Connection(new ClientConnectionOptions(dbusAddress ?? Address.Session) + { + AutoConnect = false, + SynchronizationContext = dbusContext + }); + // Connect synchronously + conn.ConnectAsync().Wait(); + + // Initialize a brand new sync-context + dbusContext.Initialized(); + Connection = conn; + } + catch (Exception e) + { + return e; + } + finally + { + SynchronizationContext.SetSynchronizationContext(oldContext); + } + return null; + } + } +} diff --git a/src/Avalonia.FreeDesktop/DBusMenu.cs b/src/Avalonia.FreeDesktop/DBusMenu.cs new file mode 100644 index 0000000000..7180345386 --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusMenu.cs @@ -0,0 +1,56 @@ + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Tmds.DBus; + +[assembly: InternalsVisibleTo(Tmds.DBus.Connection.DynamicAssemblyName)] +namespace Avalonia.FreeDesktop.DBusMenu +{ + + [DBusInterface("org.freedesktop.DBus.Properties")] + interface IFreeDesktopDBusProperties : IDBusObject + { + Task GetAsync(string prop); + Task GetAllAsync(); + Task SetAsync(string prop, object val); + Task WatchPropertiesAsync(Action handler); + } + + [DBusInterface("com.canonical.dbusmenu")] + interface IDBusMenu : IFreeDesktopDBusProperties + { + Task<(uint revision, (int, KeyValuePair[], object[]) layout)> GetLayoutAsync(int ParentId, int RecursionDepth, string[] PropertyNames); + Task<(int, KeyValuePair[])[]> GetGroupPropertiesAsync(int[] Ids, string[] PropertyNames); + Task GetPropertyAsync(int Id, string Name); + Task EventAsync(int Id, string EventId, object Data, uint Timestamp); + Task EventGroupAsync((int id, string eventId, object data, uint timestamp)[] events); + Task AboutToShowAsync(int Id); + Task<(int[] updatesNeeded, int[] idErrors)> AboutToShowGroupAsync(int[] Ids); + Task WatchItemsPropertiesUpdatedAsync(Action<((int, IDictionary)[] updatedProps, (int, string[])[] removedProps)> handler, Action onError = null); + Task WatchLayoutUpdatedAsync(Action<(uint revision, int parent)> handler, Action onError = null); + Task WatchItemActivationRequestedAsync(Action<(int id, uint timestamp)> handler, Action onError = null); + } + + [Dictionary] + class DBusMenuProperties + { + public uint Version { get; set; } = default (uint); + public string TextDirection { get; set; } = default (string); + public string Status { get; set; } = default (string); + public string[] IconThemePath { get; set; } = default (string[]); + } + + + [DBusInterface("com.canonical.AppMenu.Registrar")] + interface IRegistrar : IDBusObject + { + Task RegisterWindowAsync(uint WindowId, ObjectPath MenuObjectPath); + Task UnregisterWindowAsync(uint WindowId); + Task<(string service, ObjectPath menuObjectPath)> GetMenuForWindowAsync(uint WindowId); + Task<(uint, string, ObjectPath)[]> GetMenusAsync(); + Task WatchWindowRegisteredAsync(Action<(uint windowId, string service, ObjectPath menuObjectPath)> handler, Action onError = null); + Task WatchWindowUnregisteredAsync(Action handler, Action onError = null); + } +} diff --git a/src/Avalonia.FreeDesktop/DBusMenuExporter.cs b/src/Avalonia.FreeDesktop/DBusMenuExporter.cs new file mode 100644 index 0000000000..d9b37607f6 --- /dev/null +++ b/src/Avalonia.FreeDesktop/DBusMenuExporter.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Reactive.Disposables; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Platform; +using Avalonia.FreeDesktop.DBusMenu; +using Avalonia.Input; +using Avalonia.Threading; +using Tmds.DBus; +#pragma warning disable 1998 + +namespace Avalonia.FreeDesktop +{ + public class DBusMenuExporter + { + public static ITopLevelNativeMenuExporter TryCreate(IntPtr xid) + { + if (DBusHelper.Connection == null) + return null; + + return new DBusMenuExporterImpl(DBusHelper.Connection, xid); + } + + class DBusMenuExporterImpl : ITopLevelNativeMenuExporter, IDBusMenu, IDisposable + { + private readonly Connection _dbus; + private readonly uint _xid; + private IRegistrar _registar; + private bool _disposed; + private uint _revision = 1; + private NativeMenu _menu; + private Dictionary _idsToItems = new Dictionary(); + private Dictionary _itemsToIds = new Dictionary(); + private bool _resetQueued; + private int _nextId = 1; + public DBusMenuExporterImpl(Connection dbus, IntPtr xid) + { + _dbus = dbus; + _xid = (uint)xid.ToInt32(); + ObjectPath = new ObjectPath("/net/avaloniaui/dbusmenu/" + + Guid.NewGuid().ToString().Replace("-", "")); + 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); + } + catch (Exception e) + { + Console.Error.WriteLine(e); + // 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 + // menu will be actually exported + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _dbus.UnregisterObject(this); + // Fire and forget + _registar?.UnregisterWindowAsync(_xid); + } + + + + public bool IsNativeMenuExported { get; } + public event EventHandler OnIsNativeMenuExportedChanged; + + public void SetNativeMenu(NativeMenu menu) + { + if (menu == null) + menu = new NativeMenu(); + + if (_menu != null) + ((INotifyCollectionChanged)_menu.Items).CollectionChanged -= OnMenuItemsChanged; + _menu = menu; + ((INotifyCollectionChanged)_menu.Items).CollectionChanged += OnMenuItemsChanged; + + DoLayoutReset(); + } + + /* + This is basic initial implementation, so we don't actually track anything and + just reset the whole layout on *ANY* change + + This is not how it should work and will prevent us from implementing various features, + but that's the fastest way to get things working, so... + */ + void DoLayoutReset() + { + _resetQueued = false; + foreach (var i in _idsToItems.Values) + { + i.PropertyChanged -= OnItemPropertyChanged; + if (i.Menu != null) + ((INotifyCollectionChanged)i.Menu.Items).CollectionChanged -= OnMenuItemsChanged; + } + _idsToItems.Clear(); + _itemsToIds.Clear(); + LayoutUpdated?.Invoke((_revision++, 0)); + } + + void QueueReset() + { + if(_resetQueued) + return; + _resetQueued = true; + Dispatcher.UIThread.Post(DoLayoutReset, DispatcherPriority.Background); + } + + private (NativeMenuItem item, NativeMenu menu) GetMenu(int id) + { + if (id == 0) + return (null, _menu); + _idsToItems.TryGetValue(id, out var item); + return (item, item?.Menu); + } + + private int GetId(NativeMenuItem item) + { + if (_itemsToIds.TryGetValue(item, out var id)) + return id; + id = _nextId++; + _idsToItems[id] = item; + _itemsToIds[item] = id; + item.PropertyChanged += OnItemPropertyChanged; + if (item.Menu != null) + ((INotifyCollectionChanged)item.Menu.Items).CollectionChanged += OnMenuItemsChanged; + return id; + } + + private void OnMenuItemsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + QueueReset(); + } + + private void OnItemPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + QueueReset(); + } + + public void SetPrependApplicationMenu(bool prepend) + { + // Not implemented yet :( + } + + public ObjectPath ObjectPath { get; } + + + async Task IFreeDesktopDBusProperties.GetAsync(string prop) + { + if (prop == "Version") + return 2; + if (prop == "Status") + return "normal"; + return 0; + } + + async Task IFreeDesktopDBusProperties.GetAllAsync() + { + return new DBusMenuProperties + { + Version = 2, + Status = "normal", + }; + } + + private static string[] AllProperties = new[] + { + "type", "label", "enabled", "visible", "shortcut", "toggle-type", "children-display" + }; + + object GetProperty((NativeMenuItem item, NativeMenu menu) i, string name) + { + var (item, menu) = i; + if (name == "type") + { + if (item != null && item.Header == null) + return "separator"; + return null; + } + if (name == "label") + return item?.Header ?? ""; + if (name == "enabled") + { + if (item == null) + return null; + if (item.Menu != null && item.Menu.Items.Count == 0) + return false; + if (item.Enabled == false) + return false; + return null; + } + if (name == "shortcut") + { + if (item?.Gesture == null) + return null; + if (item.Gesture.KeyModifiers == 0) + return null; + var lst = new List(); + var mod = item.Gesture; + if ((mod.KeyModifiers & KeyModifiers.Control) != 0) + lst.Add("Control"); + if ((mod.KeyModifiers & KeyModifiers.Alt) != 0) + lst.Add("Alt"); + if ((mod.KeyModifiers & KeyModifiers.Shift) != 0) + lst.Add("Shift"); + if ((mod.KeyModifiers & KeyModifiers.Meta) != 0) + lst.Add("Super"); + lst.Add(item.Gesture.Key.ToString()); + return new[] { lst.ToArray() }; + } + + if (name == "children-display") + return menu != null ? "submenu" : null; + return null; + } + + private List> _reusablePropertyList = new List>(); + KeyValuePair[] GetProperties((NativeMenuItem item, NativeMenu menu) i, string[] names) + { + if (names?.Length > 0 != true) + names = AllProperties; + _reusablePropertyList.Clear(); + foreach (var n in names) + { + var v = GetProperty(i, n); + if (v != null) + _reusablePropertyList.Add(new KeyValuePair(n, v)); + } + + return _reusablePropertyList.ToArray(); + } + + + public Task SetAsync(string prop, object val) => Task.CompletedTask; + + public Task<(uint revision, (int, KeyValuePair[], object[]) layout)> GetLayoutAsync( + int ParentId, int RecursionDepth, string[] PropertyNames) + { + var menu = GetMenu(ParentId); + var rv = (_revision, GetLayout(menu.item, menu.menu, RecursionDepth, PropertyNames)); + return Task.FromResult(rv); + } + + (int, KeyValuePair[], object[]) GetLayout(NativeMenuItem item, NativeMenu menu, int depth, string[] propertyNames) + { + var id = item == null ? 0 : GetId(item); + var props = GetProperties((item, menu), propertyNames); + var children = (depth == 0 || menu == null) ? new object[0] : new object[menu.Items.Count]; + if(menu != null) + for (var c = 0; c < children.Length; c++) + { + var ch = menu.Items[c]; + children[c] = GetLayout(ch, ch.Menu, depth == -1 ? -1 : depth - 1, propertyNames); + } + + return (id, props, children); + } + + public Task<(int, KeyValuePair[])[]> GetGroupPropertiesAsync(int[] Ids, string[] PropertyNames) + { + var arr = new (int, KeyValuePair[])[Ids.Length]; + for (var c = 0; c < Ids.Length; c++) + { + var id = Ids[c]; + var item = GetMenu(id); + var props = GetProperties(item, PropertyNames); + arr[c] = (id, props); + } + + return Task.FromResult(arr); + } + + public async Task GetPropertyAsync(int Id, string Name) + { + return GetProperty(GetMenu(Id), Name) ?? 0; + } + + + + public void HandleEvent(int id, string eventId, object data, uint timestamp) + { + if (eventId == "clicked") + { + var item = GetMenu(id).item; + if (item?.Enabled == true) + item.RaiseClick(); + } + } + + public Task EventAsync(int Id, string EventId, object Data, uint Timestamp) + { + HandleEvent(Id, EventId, Data, Timestamp); + return Task.CompletedTask; + } + + public Task EventGroupAsync((int id, string eventId, object data, uint timestamp)[] Events) + { + foreach (var e in Events) + HandleEvent(e.id, e.eventId, e.data, e.timestamp); + return Task.FromResult(new int[0]); + } + + public async Task AboutToShowAsync(int Id) + { + return false; + } + + public async Task<(int[] updatesNeeded, int[] idErrors)> AboutToShowGroupAsync(int[] Ids) + { + return (new int[0], new int[0]); + } + + #region Events + + private event Action<((int, IDictionary)[] updatedProps, (int, string[])[] removedProps)> + ItemsPropertiesUpdated; + private event Action<(uint revision, int parent)> LayoutUpdated; + private event Action<(int id, uint timestamp)> ItemActivationRequested; + private event Action PropertiesChanged; + + async Task IDBusMenu.WatchItemsPropertiesUpdatedAsync(Action<((int, IDictionary)[] updatedProps, (int, string[])[] removedProps)> handler, Action onError) + { + ItemsPropertiesUpdated += handler; + return Disposable.Create(() => ItemsPropertiesUpdated -= handler); + } + async Task IDBusMenu.WatchLayoutUpdatedAsync(Action<(uint revision, int parent)> handler, Action onError) + { + LayoutUpdated += handler; + return Disposable.Create(() => LayoutUpdated -= handler); + } + + async Task IDBusMenu.WatchItemActivationRequestedAsync(Action<(int id, uint timestamp)> handler, Action onError) + { + ItemActivationRequested+= handler; + return Disposable.Create(() => ItemActivationRequested -= handler); + } + + async Task IFreeDesktopDBusProperties.WatchPropertiesAsync(Action handler) + { + PropertiesChanged += handler; + return Disposable.Create(() => PropertiesChanged -= handler); + } + + #endregion + } + } +} diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index 1d2290236c..d7a7bb97fd 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -38,7 +38,9 @@ namespace Avalonia.X11 throw new Exception("XOpenDisplay failed"); XError.Init(); Info = new X11Info(Display, DeferredDisplay); - + //TODO: log + if (options.UseDBusMenu) + DBusHelper.TryInitialize(); AvaloniaLocator.CurrentMutable.BindToSelf(this) .Bind().ToConstant(this) .Bind().ToConstant(new X11PlatformThreading(this)) @@ -95,6 +97,7 @@ namespace Avalonia public bool UseEGL { get; set; } public bool UseGpu { get; set; } = true; public bool OverlayPopups { get; set; } + public bool UseDBusMenu { get; set; } public List GlxRendererBlacklist { get; set; } = new List { diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 975b3d11d7..0fab85d681 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -6,7 +6,9 @@ using System.Linq; using System.Reactive.Disposables; using System.Text; using Avalonia.Controls; +using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives.PopupPositioning; +using Avalonia.FreeDesktop; using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.OpenGL; @@ -19,7 +21,7 @@ using static Avalonia.X11.XLib; // ReSharper disable StringLiteralTypo namespace Avalonia.X11 { - unsafe class X11Window : IWindowImpl, IPopupImpl, IXI2Client + unsafe class X11Window : IWindowImpl, IPopupImpl, IXI2Client, ITopLevelImplWithNativeMenuExporter { private readonly AvaloniaX11Platform _platform; private readonly IWindowImpl _popupParent; @@ -155,6 +157,8 @@ namespace Avalonia.X11 XFlush(_x11.Display); if(_popup) PopupPositioner = new ManagedPopupPositioner(new ManagedPopupPositionerPopupImplHelper(popupParent, MoveResize)); + if (platform.Options.UseDBusMenu) + NativeMenuExporter = DBusMenuExporter.TryCreate(_handle); } class SurfaceInfo : EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo @@ -960,5 +964,6 @@ namespace Avalonia.X11 } public IPopupPositioner PopupPositioner { get; } + public ITopLevelNativeMenuExporter NativeMenuExporter { get; } } }