Browse Source

Added native menu

Krzysztof Krysiński 7 months ago
parent
commit
4cf46b3287

+ 4 - 0
src/PixiEditor.Extensions/UI/Translator.cs

@@ -215,6 +215,10 @@ public class Translator : Control
         {
             contentControl.Bind(ContentControl.ContentProperty, valueObservable);
         }
+        else if (d is NativeMenuItem nativeMenuItem)
+        {
+            nativeMenuItem.Bind(NativeMenuItem.HeaderProperty, valueObservable);
+        }
 #if DEBUG
         else
         {

+ 25 - 2
src/PixiEditor.UI.Common/Controls/Window.axaml

@@ -1,5 +1,6 @@
 <ResourceDictionary xmlns="https://github.com/avaloniaui"
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                    xmlns:platform="clr-namespace:Avalonia.Platform;assembly=Avalonia.Controls"
                     x:ClassModifier="internal">
     <ControlTheme x:Key="{x:Type Window}"
                   TargetType="Window">
@@ -8,7 +9,18 @@
         <Setter Property="Foreground" Value="{DynamicResource ThemeForegroundBrush}" />
         <Setter Property="FontSize" Value="{DynamicResource FontSizeNormal}" />
         <Setter Property="WindowStartupLocation" Value="CenterScreen" />
-        <Setter Property="ExtendClientAreaChromeHints" Value="NoChrome" />
+        <Setter Property="ExtendClientAreaChromeHints">
+            <Setter.Value>
+                <OnPlatform>
+                    <OnPlatform.Default>
+                        <platform:ExtendClientAreaChromeHints>NoChrome</platform:ExtendClientAreaChromeHints>
+                    </OnPlatform.Default>
+                    <OnPlatform.macOS>
+                        <platform:ExtendClientAreaChromeHints>SystemChrome</platform:ExtendClientAreaChromeHints>
+                    </OnPlatform.macOS>
+                </OnPlatform>
+            </Setter.Value>
+        </Setter>
         <Setter Property="ExtendClientAreaToDecorationsHint" Value="True" />
         <Setter Property="ExtendClientAreaTitleBarHeightHint" Value="36" />
         <Setter Property="Template">
@@ -35,7 +47,18 @@
             </ControlTemplate>
         </Setter>
         <Style Selector="^Window[WindowState=Maximized] /template/ ContentPresenter#PART_ContentPresenter">
-            <Setter Property="Padding" Value="8"/>
+            <Setter Property="Padding">
+                <Setter.Value>
+                    <OnPlatform>
+                        <OnPlatform.Default>
+                            <Thickness>8</Thickness>
+                        </OnPlatform.Default>
+                        <OnPlatform.macOS>
+                            <Thickness>0</Thickness>
+                        </OnPlatform.macOS>
+                    </OnPlatform>
+                </Setter.Value>
+            </Setter>
         </Style>
     </ControlTheme>
 </ResourceDictionary>

+ 1 - 0
src/PixiEditor/App.axaml

@@ -5,6 +5,7 @@
              xmlns:templates="clr-namespace:ColorPicker.AvaloniaUI.Templates;assembly=ColorPicker.AvaloniaUI"
              xmlns:avalonia="clr-namespace:PixiDocks.Avalonia;assembly=PixiDocks.Avalonia"
              x:Class="PixiEditor.App"
+             Name="PixiEditor"
              RequestedThemeVariant="Dark">
     <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
     <Application.DataTemplates>

+ 26 - 0
src/PixiEditor/Helpers/Extensions/ImageExtensions.cs

@@ -0,0 +1,26 @@
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+
+namespace PixiEditor.Helpers.Extensions;
+
+public static class ImageExtensions
+{
+    public static Bitmap? ToBitmap(this IImage? image, PixelSize dimensions)
+    {
+        if (image is null)
+        {
+            return null;
+        }
+        
+        RenderTargetBitmap renderTarget = new RenderTargetBitmap(dimensions);
+        var context = renderTarget.CreateDrawingContext();
+        
+        Rect rect = new Rect(0, 0, dimensions.Width, dimensions.Height);
+        image.Draw(context, rect, rect);
+        
+        context.Dispose();
+        
+        return renderTarget;
+    }
+}

+ 80 - 0
src/PixiEditor/Models/Commands/XAML/NativeMenu.cs

@@ -0,0 +1,80 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Commands.CommandContext;
+using PixiEditor.Models.Input;
+
+namespace PixiEditor.Models.Commands.XAML;
+
+internal class NativeMenu : global::Avalonia.Controls.Menu
+{
+    public const double IconDimensions = 18;
+    public const double IconFontSize = 18;
+    
+    public static readonly AttachedProperty<string> CommandProperty =
+        AvaloniaProperty.RegisterAttached<NativeMenu, NativeMenuItem, string>(nameof(Command));
+
+    public static readonly AttachedProperty<string> LocalizationKeyHeaderProperty =
+        AvaloniaProperty.RegisterAttached<NativeMenu, NativeMenuItem, string>("LocalizationKeyHeader");
+
+    public static void SetLocalizationKeyHeader(NativeMenuItem obj, string value) => obj.SetValue(LocalizationKeyHeaderProperty, value);
+    public static string GetLocalizationKeyHeader(NativeMenuItem obj) => obj.GetValue(LocalizationKeyHeaderProperty);
+
+    static NativeMenu()
+    {
+        CommandProperty.Changed.Subscribe(CommandChanged);
+    }
+    public static string GetCommand(NativeMenuItem menu) => (string)menu.GetValue(CommandProperty);
+    public static void SetCommand(NativeMenuItem menu, string value) => menu.SetValue(CommandProperty, value);
+
+    public static async void CommandChanged(AvaloniaPropertyChangedEventArgs e) //TODO: Validate async void works
+    {
+        if (e.NewValue is not string value || e.Sender is not NativeMenuItem item)
+        {
+            throw new InvalidOperationException($"{nameof(NativeMenu)}.Command only works for NativeMenuItem's");
+        }
+
+        if (Design.IsDesignMode)
+        {
+            HandleDesignMode(item, value);
+            return;
+        }
+
+        var command = CommandController.Current.Commands[value];
+
+        bool canExecute = command.CanExecute();
+
+        Bitmap? bitmapIcon = command.GetIcon().ToBitmap(new PixelSize((int)IconDimensions, (int)IconDimensions));
+
+        item.Command = Command.GetICommand(command, new MenuSourceInfo(MenuType.Menu), false);
+        item.Icon = bitmapIcon;
+        item.Bind(NativeMenuItem.GestureProperty, ShortcutBinding.GetBinding(command, null, true));
+    }
+
+    private static void HandleDesignMode(NativeMenuItem item, string name)
+    {
+        var command = DesignCommandHelpers.GetCommandAttribute(name);
+        item.Gesture = new KeyCombination(command.Key, command.Modifiers).ToKeyGesture();
+    }
+    
+    private static Bitmap ImageToBitmap(IImage? image, int width, int height)
+    {
+        if (image is null)
+        {
+            return null;
+        }
+        
+        RenderTargetBitmap renderTargetBitmap = new RenderTargetBitmap(new PixelSize(width, height));
+        var ctx = renderTargetBitmap.CreateDrawingContext();
+        image.Draw(ctx, new Rect(0, 0, width, height), new Rect(0, 0, width, height));
+        ctx.Dispose();
+        
+        return renderTargetBitmap;
+    }
+}

+ 15 - 3
src/PixiEditor/Styles/PixiEditorPopupTemplate.axaml

@@ -3,7 +3,8 @@
         xmlns:controls="using:PixiEditor.Views.Dialogs"
         xmlns:behaviours="clr-namespace:PixiEditor.Helpers.Behaviours"
         xmlns:markupExtensions="clr-namespace:PixiEditor.Helpers.MarkupExtensions"
-        xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions">
+        xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+        xmlns:platform="clr-namespace:Avalonia.Platform;assembly=Avalonia.Controls">
     <Design.PreviewWith>
         <controls:PixiEditorPopup Width="400" Height="400" SizeToContent="Manual" />
     </Design.PreviewWith>
@@ -11,7 +12,18 @@
     <Style Selector="controls|PixiEditorPopup">
         <Setter Property="WindowStartupLocation" Value="CenterOwner" />
         <Setter Property="TransparencyLevelHint" Value="Transparent" />
-        <Setter Property="ExtendClientAreaChromeHints" Value="NoChrome" />
+        <Setter Property="ExtendClientAreaChromeHints">
+            <Setter.Value>
+                <OnPlatform>
+                    <OnPlatform.Default>
+                        <platform:ExtendClientAreaChromeHints>NoChrome</platform:ExtendClientAreaChromeHints>
+                    </OnPlatform.Default>
+                    <OnPlatform.macOS>
+                        <platform:ExtendClientAreaChromeHints>SystemChrome</platform:ExtendClientAreaChromeHints>
+                    </OnPlatform.macOS>
+                </OnPlatform>
+            </Setter.Value>
+        </Setter>
         <Setter Property="ExtendClientAreaToDecorationsHint" Value="True" />
         <Setter Property="ExtendClientAreaTitleBarHeightHint" Value="36" />
         <Setter Property="FontFamily" Value="{DynamicResource ContentControlThemeFontFamily}" />
@@ -26,7 +38,7 @@
                     <Panel>
                         <DockPanel>
                             <controls:DialogTitleBar
-                                DockPanel.Dock="Top"
+                                DockPanel.Dock="Top" 
                                 CloseCommand="{TemplateBinding CloseCommand}"
                                 CanMinimize="{TemplateBinding CanMinimize}"
                                 CanFullscreen="{TemplateBinding CanResize}"

+ 110 - 23
src/PixiEditor/ViewModels/Menu/MenuBarViewModel.cs

@@ -10,25 +10,30 @@ using PixiEditor.Models.Commands.XAML;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.UI;
 using PixiEditor.Models.Commands;
+using PixiEditor.OperatingSystem;
 using PixiEditor.ViewModels.SubViewModels.AdditionalContent;
 using Command = PixiEditor.Models.Commands.Commands.Command;
 using Commands_Command = PixiEditor.Models.Commands.Commands.Command;
+using NativeMenu = Avalonia.Controls.NativeMenu;
 
 namespace PixiEditor.ViewModels.Menu;
 
 internal class MenuBarViewModel : PixiObservableObject
 {
     private AdditionalContentViewModel additionalContentViewModel;
-   
+
     public AdditionalContentViewModel AdditionalContentSubViewModel
     {
         get => additionalContentViewModel;
         set => SetProperty(ref additionalContentViewModel, value);
     }
-    
-    public ObservableCollection<MenuItem> MenuEntries { get; set; } = new();
+
+    public ObservableCollection<MenuItem>? MenuEntries { get; set; }
+    public NativeMenu? NativeMenu { get; private set; }
 
     private Dictionary<string, MenuTreeItem> menuItems = new();
+    private List<NativeMenuItem> nativeMenuItems;
+
 
     private readonly Dictionary<string, int> menuOrderMultiplier = new Dictionary<string, int>()
     {
@@ -50,13 +55,15 @@ internal class MenuBarViewModel : PixiObservableObject
     {
         MenuItemBuilder[] builders = serviceProvider.GetServices<MenuItemBuilder>().ToArray();
 
-        var commandsWithMenuItems = controller.Commands.Where(x => !string.IsNullOrEmpty(x.MenuItemPath) && IsValid(x.MenuItemPath)).ToArray();
+        var commandsWithMenuItems = controller.Commands
+            .Where(x => !string.IsNullOrEmpty(x.MenuItemPath) && IsValid(x.MenuItemPath)).ToArray();
 
-        foreach (var command in commandsWithMenuItems.OrderBy(GetCategoryMultiplier).ThenBy(x => x.MenuItemOrder).ThenBy(x => x.InternalName))
+        foreach (var command in commandsWithMenuItems.OrderBy(GetCategoryMultiplier).ThenBy(x => x.MenuItemOrder)
+                     .ThenBy(x => x.InternalName))
         {
-           if(string.IsNullOrEmpty(command.MenuItemPath)) continue;
+            if (string.IsNullOrEmpty(command.MenuItemPath)) continue;
 
-           BuildMenuEntry(command);
+            BuildMenuEntry(command);
         }
 
         BuildMenu(controller, builders);
@@ -75,37 +82,50 @@ internal class MenuBarViewModel : PixiObservableObject
 
     private void BuildMenu(CommandController controller, MenuItemBuilder[] builders)
     {
-        BuildSimpleItems(controller, menuItems);
-        foreach (var builder in builders)
+        if (IOperatingSystem.Current.IsMacOs)
+        {
+            BuildBasicNativeMenuItems(controller, menuItems);
+            foreach (var builder in builders)
+            {
+                builder.ModifyMenuTree(nativeMenuItems);
+            }
+            
+            NativeMenu = [];
+            foreach (var item in nativeMenuItems)
+            {
+                NativeMenu.Items.Add(item);
+            }
+        }
+        else
         {
-            builder.ModifyMenuTree(MenuEntries);
+            BuildSimpleItems(controller, menuItems);
+            foreach (var builder in builders)
+            {
+                builder.ModifyMenuTree(MenuEntries);
+            }
         }
     }
 
-    private void BuildSimpleItems(CommandController controller, Dictionary<string, MenuTreeItem> root, MenuItem? parent = null)
+    private void BuildSimpleItems(CommandController controller, Dictionary<string, MenuTreeItem> root,
+        MenuItem? parent = null)
     {
         string? lastSubCommand = null;
 
         foreach (var item in root)
         {
             MenuItem menuItem = new();
-            
-            var headerBinding = new Binding(".")
-            {
-                Source = item.Key, 
-                Mode = BindingMode.OneWay,
-            };
-            
+
+            var headerBinding = new Binding(".") { Source = item.Key, Mode = BindingMode.OneWay, };
+
             menuItem.Bind(Translator.KeyProperty, headerBinding);
 
-            CommandGroup? group = controller.CommandGroups.FirstOrDefault(x => x.IsVisibleProperty != null && x.Commands.Contains(item.Value.Command));
+            CommandGroup? group = controller.CommandGroups.FirstOrDefault(x =>
+                x.IsVisibleProperty != null && x.Commands.Contains(item.Value.Command));
 
             if (group != null)
             {
-                menuItem.Bind(Visual.IsVisibleProperty, new Binding(group.IsVisibleProperty)
-                {
-                    Source = ViewModelMain.Current,
-                });
+                menuItem.Bind(Visual.IsVisibleProperty,
+                    new Binding(group.IsVisibleProperty) { Source = ViewModelMain.Current, });
             }
 
             if (item.Value.Items.Count == 0)
@@ -141,11 +161,76 @@ internal class MenuBarViewModel : PixiObservableObject
                 {
                     MenuEntries.Add(menuItem);
                 }
+
                 BuildSimpleItems(controller, item.Value.Items, menuItem);
             }
         }
     }
 
+    private void BuildBasicNativeMenuItems(CommandController controller, Dictionary<string, MenuTreeItem> root,
+        NativeMenu? parent = null)
+    {
+        string? lastSubCommand = null;
+
+        foreach (var item in root)
+        {
+            NativeMenuItem menuItem = new();
+
+            nativeMenuItems ??= new List<NativeMenuItem>();
+            var headerBinding = new Binding(".") { Source = item.Key, Mode = BindingMode.OneWay, };
+
+            menuItem.Bind(Translator.KeyProperty, headerBinding);
+            menuItem.Bind(PixiEditor.Models.Commands.XAML.NativeMenu.LocalizationKeyHeaderProperty, headerBinding);
+
+            CommandGroup? group = controller.CommandGroups.FirstOrDefault(x =>
+                x.IsVisibleProperty != null && x.Commands.Contains(item.Value.Command));
+
+            if (group != null)
+            {
+                menuItem.Bind(
+                    Visual.IsVisibleProperty,
+                    new Binding(group.IsVisibleProperty) { Source = ViewModelMain.Current, });
+            }
+
+            if (item.Value.Items.Count == 0)
+            {
+                Models.Commands.XAML.NativeMenu.SetCommand(menuItem, item.Value.Command.InternalName);
+
+                string internalName = item.Value.Command.InternalName;
+                internalName = internalName[..internalName.LastIndexOf('.')];
+
+                if (lastSubCommand != null && lastSubCommand != internalName)
+                {
+                    parent?.Items.Add(new NativeMenuItemSeparator());
+                }
+
+                if (parent != null)
+                {
+                    parent.Items.Add(menuItem);
+                }
+                else
+                {
+                    nativeMenuItems.Add(menuItem);
+                }
+
+                lastSubCommand = internalName;
+            }
+            else
+            {
+                if (parent != null)
+                {
+                    parent.Items.Add(menuItem);
+                }
+                else
+                {
+                    nativeMenuItems.Add(menuItem);
+                }
+
+                BuildBasicNativeMenuItems(controller, item.Value.Items, menuItem.Menu ??= []);
+            }
+        }
+    }
+
     private void BuildMenuEntry(Commands_Command command)
     {
         string[] path = command.MenuItemPath!.Split('/');
@@ -159,6 +244,7 @@ internal class MenuBarViewModel : PixiObservableObject
                 {
                     menuItems.Add(path[i], new MenuTreeItem(path[i], command));
                 }
+
                 current = menuItems[path[i]];
             }
             else
@@ -167,6 +253,7 @@ internal class MenuBarViewModel : PixiObservableObject
                 {
                     current.Items.Add(path[i], new MenuTreeItem(path[i], command));
                 }
+
                 current = current.Items[path[i]];
             }
         }

+ 5 - 0
src/PixiEditor/ViewModels/Menu/MenuBuilders/FileExitMenuBuilder.cs

@@ -31,4 +31,9 @@ internal class FileExitMenuBuilder : MenuItemBuilder
             fileMenuItem!.Items.Add(exitMenuItem);
         }
     }
+
+    public override void ModifyMenuTree(ICollection<NativeMenuItem> tree)
+    {
+        return; // macOS has default exit button
+    }
 }

+ 39 - 2
src/PixiEditor/ViewModels/Menu/MenuBuilders/OpenDockablesMenuBuilder.cs

@@ -1,9 +1,11 @@
 using System.Collections.Generic;
 using System.Windows.Input;
+using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Media;
 using PixiEditor.Models.Commands;
 using PixiEditor.Extensions.UI;
+using PixiEditor.Helpers.Extensions;
 using Dock_LayoutManager = PixiEditor.ViewModels.Dock.LayoutManager;
 using LayoutManager = PixiEditor.ViewModels.Dock.LayoutManager;
 
@@ -48,7 +50,7 @@ internal class OpenDockablesMenuBuilder : MenuItemBuilder
                         Height = Models.Commands.XAML.Menu.IconDimensions,
                     };
                 }
-                else if(dockable.TabCustomizationSettings.Icon is TextBlock tb)
+                else if (dockable.TabCustomizationSettings.Icon is TextBlock tb)
                 {
                     dockableItem.Icon = new TextBlock()
                     {
@@ -57,9 +59,44 @@ internal class OpenDockablesMenuBuilder : MenuItemBuilder
                         FontFamily = tb.FontFamily,
                     };
                 }
-                
+
                 dockablesItem.Items.Add(dockableItem);
             }
         }
     }
+
+    public override void ModifyMenuTree(ICollection<NativeMenuItem> tree)
+    {
+        if (TryFindMenuItem(tree, "VIEW", out NativeMenuItem? viewItem))
+        {
+            NativeMenuItem dockablesItem = new NativeMenuItem();
+            dockablesItem.Menu = new NativeMenu();
+
+            Translator.SetKey(dockablesItem, "OPEN_DOCKABLE_MENU");
+            PixiEditor.Models.Commands.XAML.NativeMenu.SetLocalizationKeyHeader(dockablesItem, "OPEN_DOCKABLE_MENU");
+
+            viewItem!.Menu.Items.Add(dockablesItem);
+
+            foreach (var dockable in LayoutManager.RegisteredDockables)
+            {
+                NativeMenuItem dockableItem = new NativeMenuItem();
+                Translator.SetKey(dockableItem, dockable.Title);
+
+                string commandId = "PixiEditor.Window.ShowDockWindow";
+
+                dockableItem.Command =
+                    (ICommand)new Models.Commands.XAML.Command(commandId) { UseProvided = true }
+                        .ProvideValue(null);
+                dockableItem.CommandParameter = dockable.Id;
+
+                if (dockable.TabCustomizationSettings.Icon is IImage image)
+                {
+                    int dimensions = (int)Models.Commands.XAML.Menu.IconDimensions;
+                    dockableItem.Icon = image.ToBitmap(new PixelSize(dimensions, dimensions));
+                }
+
+                dockablesItem.Menu.Items.Add(dockableItem);
+            }
+        }
+    }
 }

+ 27 - 1
src/PixiEditor/ViewModels/Menu/MenuBuilders/RecentFilesMenuBuilder.cs

@@ -1,4 +1,5 @@
-using Avalonia.Controls;
+using System.Windows.Input;
+using Avalonia.Controls;
 using Avalonia.Controls.Templates;
 using Avalonia.Data;
 using Avalonia.Styling;
@@ -44,6 +45,31 @@ internal class RecentFilesMenuBuilder : MenuItemBuilder
         }
     }
 
+    public override void ModifyMenuTree(ICollection<NativeMenuItem> tree)
+    {
+        if(TryFindMenuItem(tree, "FILE", out NativeMenuItem? fileMenuItem))
+        {
+            var recentFilesMenuItem = new NativeMenuItem();
+            recentFilesMenuItem.Menu = new NativeMenu();
+
+            Translator.SetKey(recentFilesMenuItem, "RECENT");
+            Models.Commands.XAML.NativeMenu.SetLocalizationKeyHeader(recentFilesMenuItem, "RECENT");
+
+            foreach (var recent in fileViewModel.RecentlyOpened)
+            {
+                recentFilesMenuItem.Menu.Add(new NativeMenuItem()
+                {
+                    Header = recent.FilePath,
+                    Command = (ICommand)new Models.Commands.XAML.Command("PixiEditor.File.OpenRecent") { UseProvided = true }.ProvideValue(null),
+                    CommandParameter = recent.FilePath
+                });
+            }
+
+            recentFilesMenuItem.IsEnabled = fileViewModel.HasRecent;
+            fileMenuItem!.Menu.Items.Add(recentFilesMenuItem);
+        }
+    }
+
     private IDataTemplate? BuildItemTemplate()
     {
         return new FuncDataTemplate<RecentlyOpenedDocument>((document, _) =>

+ 68 - 0
src/PixiEditor/ViewModels/Menu/MenuBuilders/SymmetryMenuBuilder.cs

@@ -3,9 +3,11 @@ using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Data;
 using Avalonia.Input;
+using CommunityToolkit.Mvvm.Input;
 using PixiEditor.Helpers.Converters;
 using PixiEditor.Views.Input;
 using PixiEditor.Extensions.UI;
+using PixiEditor.Helpers.Extensions;
 using PixiEditor.UI.Common.Controls;
 using PixiEditor.UI.Common.Fonts;
 
@@ -46,6 +48,54 @@ internal class SymmetryMenuBuilder : MenuItemBuilder
         }
     }
 
+    public override void ModifyMenuTree(ICollection<NativeMenuItem> tree)
+    {
+        if(TryFindMenuItem(tree, "IMAGE", out NativeMenuItem? viewItem))
+        {
+            int index = viewItem!.Menu.Items.Count >= 3 ? 3 : viewItem.Menu.Items.Count - 1;
+            viewItem!.Menu.Items.Insert(index, new NativeMenuItemSeparator());
+            NativeMenuItem horizontalSymmetryItem = new NativeMenuItem();
+            horizontalSymmetryItem.ToggleType = NativeMenuItemToggleType.CheckBox;
+            
+            PixelSize iconDimensions = new PixelSize((int)Models.Commands.XAML.Menu.IconDimensions, (int)Models.Commands.XAML.Menu.IconDimensions);
+            
+            Translator.SetKey(horizontalSymmetryItem, "HORIZONTAL_LINE_SYMMETRY");
+            horizontalSymmetryItem.Icon = PixiPerfectIcons.ToIcon(PixiPerfectIcons.XSymmetry)
+                .ToBitmap(iconDimensions);
+
+            BindItem(horizontalSymmetryItem, "DocumentManagerSubViewModel.ActiveDocument.HorizontalSymmetryAxisEnabledBindable",
+                () =>
+                {
+                    var activeDocument = ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument;
+                    if (activeDocument != null)
+                    {
+                        activeDocument.HorizontalSymmetryAxisEnabledBindable =
+                            !activeDocument.HorizontalSymmetryAxisEnabledBindable;
+                    }
+                });
+            viewItem.Menu.Items.Insert(index + 1, horizontalSymmetryItem);
+
+            NativeMenuItem verticalSymmetryItem = new NativeMenuItem();
+            Translator.SetKey(verticalSymmetryItem, "VERTICAL_LINE_SYMMETRY");
+            verticalSymmetryItem.ToggleType = NativeMenuItemToggleType.CheckBox;
+            verticalSymmetryItem.Icon = PixiPerfectIcons.ToIcon(PixiPerfectIcons.YSymmetry)
+                .ToBitmap(iconDimensions);
+
+            BindItem(verticalSymmetryItem, "DocumentManagerSubViewModel.ActiveDocument.VerticalSymmetryAxisEnabledBindable",
+                () =>
+                {
+                    var activeDocument = ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument;
+                    if (activeDocument != null)
+                    {
+                        activeDocument.VerticalSymmetryAxisEnabledBindable =
+                            !activeDocument.VerticalSymmetryAxisEnabledBindable;
+                    }
+                });
+            viewItem.Menu.Items.Insert(index + 2, verticalSymmetryItem);
+            viewItem.Menu.Items.Insert(index + 3, new NativeMenuItemSeparator());
+        }
+    }
+
     private void BindItem(ToggleableMenuItem item, string checkedBindingPath)
     {
         Binding isEnabledBinding = new Binding("!!DocumentManagerSubViewModel.ActiveDocument")
@@ -62,4 +112,22 @@ internal class SymmetryMenuBuilder : MenuItemBuilder
         item.Bind(ToggleableMenuItem.IsCheckedProperty, isCheckedBinding);
         item.Bind(InputElement.IsEnabledProperty, isEnabledBinding);
     }
+    
+    private void BindItem(NativeMenuItem item, string checkedBindingPath, Action updateAction)
+    {
+        Binding isEnabledBinding = new Binding("!!DocumentManagerSubViewModel.ActiveDocument")
+        {
+            Source = ViewModelMain.Current
+        };
+
+        Binding isCheckedBinding = new Binding(checkedBindingPath)
+        {
+            Source = ViewModelMain.Current,
+        };
+
+        item.Command = new RelayCommand(updateAction);
+        
+        item.Bind(NativeMenuItem.IsCheckedProperty, isCheckedBinding);
+        item.Bind(NativeMenuItem.IsEnabledProperty, isEnabledBinding);
+    }
 }

+ 41 - 1
src/PixiEditor/ViewModels/Menu/MenuBuilders/ToggleGridLinesMenuBuilder.cs

@@ -1,7 +1,10 @@
-using Avalonia.Controls;
+using Avalonia;
+using Avalonia.Controls;
 using Avalonia.Data;
 using Avalonia.Input;
+using CommunityToolkit.Mvvm.Input;
 using PixiEditor.Extensions.UI;
+using PixiEditor.Helpers.Extensions;
 using PixiEditor.UI.Common.Controls;
 using PixiEditor.UI.Common.Fonts;
 
@@ -28,6 +31,21 @@ internal class ToggleGridLinesMenuBuilder : MenuItemBuilder
         }
     }
 
+    public override void ModifyMenuTree(ICollection<NativeMenuItem> tree)
+    {
+        if (TryFindMenuItem(tree, "VIEW", out NativeMenuItem? viewItem))
+        {
+            viewItem.Menu.Items.Add(new NativeMenuItemSeparator());
+            NativeMenuItem gridLinesItem = new NativeMenuItem();
+            gridLinesItem.ToggleType = NativeMenuItemToggleType.CheckBox;
+            Translator.SetKey(gridLinesItem, "TOGGLE_GRIDLINES");
+
+            gridLinesItem.Icon = PixiPerfectIcons.ToIcon(PixiPerfectIcons.GridLines).ToBitmap(IconDimensions);
+            BindItem(gridLinesItem);
+            viewItem.Menu.Items.Add(gridLinesItem);
+        }
+    }
+
     private void BindItem(ToggleableMenuItem gridLinesItem)
     {
         gridLinesItem.Bind(ToggleableMenuItem.IsCheckedProperty, new Binding("ViewportSubViewModel.GridLinesEnabled")
@@ -41,4 +59,26 @@ internal class ToggleGridLinesMenuBuilder : MenuItemBuilder
             Source = ViewModelMain.Current
         });
     }
+    
+    private void BindItem(NativeMenuItem gridLinesItem)
+    {
+        gridLinesItem.Bind(NativeMenuItem.IsCheckedProperty, new Binding("ViewportSubViewModel.GridLinesEnabled")
+        {
+            Source = ViewModelMain.Current,
+        });
+
+        gridLinesItem.Bind(NativeMenuItem.IsEnabledProperty, new Binding("!!DocumentManagerSubViewModel.ActiveDocument")
+        {
+            Source = ViewModelMain.Current
+        });
+        
+        gridLinesItem.Command = new RelayCommand(() =>
+        {
+            var viewportOpotions = ViewModelMain.Current.ViewportSubViewModel;
+            if (viewportOpotions != null)
+            {
+                viewportOpotions.GridLinesEnabled = !viewportOpotions.GridLinesEnabled;
+            }
+        });
+    }
 }

+ 35 - 0
src/PixiEditor/ViewModels/Menu/MenuBuilders/ToggleHighResPreviewMenuBuilder.cs

@@ -1,7 +1,9 @@
 using Avalonia.Controls;
 using Avalonia.Data;
 using Avalonia.Input;
+using CommunityToolkit.Mvvm.Input;
 using PixiEditor.Extensions.UI;
+using PixiEditor.Helpers.Extensions;
 using PixiEditor.UI.Common.Controls;
 using PixiEditor.UI.Common.Fonts;
 
@@ -27,6 +29,21 @@ internal class ToggleHighResPreviewMenuBuilder : MenuItemBuilder
         }
     }
 
+    public override void ModifyMenuTree(ICollection<NativeMenuItem> tree)
+    {
+        if (TryFindMenuItem(tree, "VIEW", out NativeMenuItem? viewItem))
+        {
+            viewItem.Menu.Items.Add(new NativeMenuItemSeparator());
+            NativeMenuItem gridLinesItem = new NativeMenuItem();
+            gridLinesItem.ToggleType = NativeMenuItemToggleType.CheckBox;
+            Translator.SetKey(gridLinesItem, "TOGGLE_HIGH_RES_PREVIEW");
+
+            gridLinesItem.Icon = PixiPerfectIcons.ToIcon(PixiPerfectIcons.Circle).ToBitmap(IconDimensions);
+            BindItem(gridLinesItem);
+            viewItem.Menu.Items.Add(gridLinesItem);
+        }
+    }
+
     private void BindItem(ToggleableMenuItem gridLinesItem)
     {
         gridLinesItem.Bind(ToggleableMenuItem.IsCheckedProperty, new Binding("ViewportSubViewModel.HighResRender")
@@ -40,4 +57,22 @@ internal class ToggleHighResPreviewMenuBuilder : MenuItemBuilder
             Source = ViewModelMain.Current
         });
     }
+    
+    private void BindItem(NativeMenuItem gridLinesItem)
+    {
+        gridLinesItem.Bind(NativeMenuItem.IsCheckedProperty, new Binding("ViewportSubViewModel.HighResRender")
+        {
+            Source = ViewModelMain.Current,
+        });
+
+        gridLinesItem.Bind(NativeMenuItem.IsEnabledProperty, new Binding("!!DocumentManagerSubViewModel.ActiveDocument")
+        {
+            Source = ViewModelMain.Current
+        });
+        
+        gridLinesItem.Command = new RelayCommand(() =>
+        {
+            ViewModelMain.Current.ViewportSubViewModel.HighResRender = !ViewModelMain.Current.ViewportSubViewModel.HighResRender;
+        });
+    }
 }

+ 35 - 0
src/PixiEditor/ViewModels/Menu/MenuBuilders/ToggleSnappingMenuBuilder.cs

@@ -1,7 +1,9 @@
 using Avalonia.Controls;
 using Avalonia.Data;
 using Avalonia.Input;
+using CommunityToolkit.Mvvm.Input;
 using PixiEditor.Extensions.UI;
+using PixiEditor.Helpers.Extensions;
 using PixiEditor.UI.Common.Controls;
 using PixiEditor.UI.Common.Fonts;
 
@@ -27,6 +29,21 @@ internal class ToggleSnappingMenuBuilder : MenuItemBuilder
         }
     }
 
+    public override void ModifyMenuTree(ICollection<NativeMenuItem> tree)
+    {
+        if (TryFindMenuItem(tree, "VIEW", out NativeMenuItem? viewItem))
+        {
+            viewItem.Menu.Items.Add(new NativeMenuItemSeparator());
+            NativeMenuItem gridLinesItem = new NativeMenuItem();
+            gridLinesItem.ToggleType = NativeMenuItemToggleType.CheckBox;
+            Translator.SetKey(gridLinesItem, "TOGGLE_SNAPPING");
+
+            gridLinesItem.Icon = PixiPerfectIcons.ToIcon(PixiPerfectIcons.Snapping).ToBitmap(IconDimensions);
+            BindItem(gridLinesItem);
+            viewItem.Menu.Items.Add(gridLinesItem);
+        }
+    }
+
     private void BindItem(ToggleableMenuItem gridLinesItem)
     {
         gridLinesItem.Bind(ToggleableMenuItem.IsCheckedProperty, new Binding("ViewportSubViewModel.SnappingEnabled")
@@ -40,4 +57,22 @@ internal class ToggleSnappingMenuBuilder : MenuItemBuilder
             Source = ViewModelMain.Current
         });
     }
+    
+    private void BindItem(NativeMenuItem gridLinesItem)
+    {
+        gridLinesItem.Bind(NativeMenuItem.IsCheckedProperty, new Binding("ViewportSubViewModel.SnappingEnabled")
+        {
+            Source = ViewModelMain.Current,
+        });
+
+        gridLinesItem.Bind(NativeMenuItem.IsEnabledProperty, new Binding("!!DocumentManagerSubViewModel.ActiveDocument")
+        {
+            Source = ViewModelMain.Current
+        });
+        
+        gridLinesItem.Command = new RelayCommand(() =>
+        {
+            ViewModelMain.Current.ViewportSubViewModel.SnappingEnabled = !ViewModelMain.Current.ViewportSubViewModel.SnappingEnabled;
+        });
+    }
 }

+ 30 - 0
src/PixiEditor/ViewModels/Menu/MenuItemBuilder.cs

@@ -1,4 +1,5 @@
 using System.Collections.Generic;
+using Avalonia;
 using Avalonia.Controls;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.UI;
@@ -7,7 +8,9 @@ namespace PixiEditor.ViewModels.Menu;
 
 internal abstract class MenuItemBuilder
 {
+    protected PixelSize IconDimensions => new PixelSize((int)PixiEditor.Models.Commands.XAML.Menu.IconDimensions, (int)PixiEditor.Models.Commands.XAML.Menu.IconDimensions);
     public abstract void ModifyMenuTree(ICollection<MenuItem> tree);
+    public abstract void ModifyMenuTree(ICollection<NativeMenuItem> tree);
 
     protected bool TryFindMenuItem(ICollection<MenuItem> tree, string header, out MenuItem? menuItem)
     {
@@ -35,4 +38,31 @@ internal abstract class MenuItemBuilder
         menuItem = null;
         return false;
     }
+    
+    protected bool TryFindMenuItem(ICollection<NativeMenuItem> tree, string header, out NativeMenuItem? menuItem)
+    {
+        foreach (var item in tree)
+        {
+            if (Models.Commands.XAML.NativeMenu.GetLocalizationKeyHeader(item) == header)
+            {
+                menuItem = item;
+                return true;
+            }
+            
+            if(Translator.GetKey(item) == header)
+            {
+                menuItem = item;
+                return true;
+            }
+
+            if (item.Header == header)
+            {
+                menuItem = item;
+                return true;
+            }
+        }
+
+        menuItem = null;
+        return false;
+    }
 }

+ 1 - 1
src/PixiEditor/Views/Dialogs/DialogTitleBar.axaml

@@ -21,7 +21,7 @@
             FontSize="13"
             Margin="5,0,0,0"/>
         <DockPanel IsHitTestVisible="True">
-            <CaptionButtons Name="captionButtons" DockPanel.Dock="Right" />
+            <CaptionButtons Name="captionButtons" DockPanel.Dock="Right" IsVisible="{OnPlatform macOS=false}"/>
             <ContentPresenter DockPanel.Dock="Right" IsVisible="{Binding !!AdditionalElement}" Content="{Binding Path=AdditionalElement}"/>
             <Control /><!-- dummy control to occupy dockpanel center -->
         </DockPanel>

+ 8 - 5
src/PixiEditor/Views/Dialogs/DialogTitleBar.axaml.cs

@@ -22,7 +22,8 @@ internal partial class DialogTitleBar : UserControl, ICustomTranslatorElement
     public static readonly StyledProperty<ICommand?> CloseCommandProperty =
         AvaloniaProperty.Register<DialogTitleBar, ICommand?>(nameof(CloseCommand));
 
-    public static readonly StyledProperty<Control> AdditionalElementProperty = AvaloniaProperty.Register<DialogTitleBar, Control>("AdditionalElement");
+    public static readonly StyledProperty<Control> AdditionalElementProperty =
+        AvaloniaProperty.Register<DialogTitleBar, Control>("AdditionalElement");
 
     public ICommand? CloseCommand
     {
@@ -68,7 +69,8 @@ internal partial class DialogTitleBar : UserControl, ICustomTranslatorElement
         captionButtons.Attach(VisualRoot as Window);
     }
 
-    void ICustomTranslatorElement.SetTranslationBinding(AvaloniaProperty dependencyProperty, IObservable<string> binding)
+    void ICustomTranslatorElement.SetTranslationBinding(AvaloniaProperty dependencyProperty,
+        IObservable<string> binding)
     {
         Bind(dependencyProperty, binding);
     }
@@ -86,23 +88,24 @@ internal partial class DialogTitleBar : UserControl, ICustomTranslatorElement
                 command.Execute(null);
             return;
         }
+
         ((Window?)VisualRoot)?.Close();
     }
-    
+
     private void MaximizeWindow(object? sender, RoutedEventArgs e)
     {
         if (VisualRoot is not Window window || !CanFullscreen)
             return;
         window.WindowState = WindowState.Maximized;
     }
-    
+
     private void RestoreWindow(object? sender, RoutedEventArgs e)
     {
         if (VisualRoot is not Window window || !CanFullscreen)
             return;
         window.WindowState = WindowState.Normal;
     }
-    
+
     private void MinimizeWindow(object? sender, RoutedEventArgs e)
     {
         if (VisualRoot is not Window window || !CanMinimize)

+ 30 - 11
src/PixiEditor/Views/Main/MainTitleBar.axaml

@@ -29,16 +29,34 @@
                 </Border>
             </dialogs:DialogTitleBar.AdditionalElement>
         </dialogs:DialogTitleBar>
-        <Svg DockPanel.Dock="Left" Margin="10, 0, 0, 0" HorizontalAlignment="Left" Path="/Images/PixiEditorLogo.svg"
-             Width="20" Height="20" />
+        <Svg DockPanel.Dock="Left" HorizontalAlignment="Left" Path="/Images/PixiEditorLogo.svg"
+             Width="20" Height="20">
+            <Svg.Margin>
+                <OnPlatform>
+                    <OnPlatform.macOS>
+                        <Thickness>75, 0, 0, 0</Thickness>
+                    </OnPlatform.macOS>
+                    <OnPlatform.Default>
+                        <Thickness>10, 0, 0, 0</Thickness>
+                    </OnPlatform.Default>
+                </OnPlatform>
+            </Svg.Margin>
+        </Svg>
         <StackPanel Orientation="Horizontal">
-            <xaml:Menu
-                Margin="40, 0, 0, 0"
-                DockPanel.Dock="Left"
-                HorizontalAlignment="Left"
-                VerticalAlignment="Center"
-                ItemsSource="{Binding MenuEntries}"
-                Background="Transparent" />
+            <StackPanel.Margin>
+                <OnPlatform>
+                    <OnPlatform.macOS>
+                        <Thickness>95, 0, 0, 0</Thickness>
+                    </OnPlatform.macOS>
+                </OnPlatform>
+            </StackPanel.Margin>
+            <xaml:Menu IsVisible="{OnPlatform macOS=false}"
+                       Margin="40, 0, 0, 0"
+                       DockPanel.Dock="Left"
+                       HorizontalAlignment="Left"
+                       VerticalAlignment="Center"
+                       ItemsSource="{Binding MenuEntries}"
+                       Background="Transparent" />
             <Border DockPanel.Dock="Left" Height="25" Width="150"
                     Background="{DynamicResource ThemeBackgroundBrush}"
                     CornerRadius="5" BorderThickness="1"
@@ -61,8 +79,9 @@
                 </Interaction.Behaviors>
                 <Grid Margin="5,0" VerticalAlignment="Center">
                     <TextBlock ui:Translator.Key="SEARCH" />
-                    <TextBlock Text="{xaml:ShortcutBinding PixiEditor.Search.Toggle, Converter={converters:KeyToStringConverter}}"
-                               HorizontalAlignment="Right" />
+                    <TextBlock
+                        Text="{xaml:ShortcutBinding PixiEditor.Search.Toggle, Converter={converters:KeyToStringConverter}}"
+                        HorizontalAlignment="Right" />
                 </Grid>
             </Border>
         </StackPanel>

+ 10 - 0
src/PixiEditor/Views/Main/MainTitleBar.axaml.cs

@@ -1,6 +1,7 @@
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Markup.Xaml;
+using PixiEditor.ViewModels.Menu;
 
 namespace PixiEditor.Views.Main;
 
@@ -15,5 +16,14 @@ public partial class MainTitleBar : UserControl {
     {
         AvaloniaXamlLoader.Load(this);
     }
+
+    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+    {
+        base.OnAttachedToVisualTree(e);
+        if (DataContext is MenuBarViewModel menuBarViewModel)
+        {
+            NativeMenu.SetMenu(MainWindow.Current, menuBarViewModel.NativeMenu);
+        }
+    }
 }
 

+ 4 - 4
src/PixiEditor/Views/MainView.axaml

@@ -21,17 +21,17 @@
     </Interaction.Behaviors>
     <Grid DragDrop.AllowDrop="True" Name="DropGrid">
         <DockPanel>
-            <main1:MainTitleBar DockPanel.Dock="Top" DataContext="{Binding MenuBarViewModel}"/>
+            <main1:MainTitleBar DockPanel.Dock="Top" DataContext="{Binding MenuBarViewModel}" />
             <Grid Focusable="True" Name="FocusableGrid">
                 <Grid.RowDefinitions>
                     <RowDefinition Height="*" />
-                    <RowDefinition Height="30"/>
+                    <RowDefinition Height="30" />
                 </Grid.RowDefinitions>
 
                 <controls:DockableAreaRegion Grid.Row="0"
                                              Root="{Binding LayoutSubViewModel.LayoutManager.ActiveLayout.Root}"
-                                             Context="{Binding LayoutSubViewModel.LayoutManager.DockContext}"/>
-                <main1:ActionDisplayBar Grid.Row="1" DataContext="{Binding .}"/>
+                                             Context="{Binding LayoutSubViewModel.LayoutManager.DockContext}" />
+                <main1:ActionDisplayBar Grid.Row="1" DataContext="{Binding .}" />
             </Grid>
         </DockPanel>
         <commandSearch:CommandSearchControl