Browse Source

New way of creating menu items done

Krzysztof Krysiński 1 year ago
parent
commit
a6de1ffc51
20 changed files with 361 additions and 26 deletions
  1. 11 1
      src/PixiEditor.AvaloniaUI/Helpers/ServiceCollectionHelpers.cs
  2. 10 0
      src/PixiEditor.AvaloniaUI/Models/Commands/Attributes/Commands/CommandAttribute.cs
  3. 2 0
      src/PixiEditor.AvaloniaUI/Models/Commands/CommandController.cs
  4. 4 0
      src/PixiEditor.AvaloniaUI/Models/Commands/Commands/Command.cs
  5. 2 1
      src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentManagerViewModel.cs
  6. 116 0
      src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuBarViewModel.cs
  7. 25 0
      src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuBuilders/FileExitMenuBuilder.cs
  8. 90 0
      src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuBuilders/RecentFilesMenuBuilder.cs
  9. 31 0
      src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuItemBuilder.cs
  10. 17 0
      src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuTreeItem.cs
  11. 6 3
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/ClipboardViewModel.cs
  12. 10 5
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/FileViewModel.cs
  13. 2 1
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/LayersViewModel.cs
  14. 16 8
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/SelectionViewModel.cs
  15. 2 2
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/UndoViewModel.cs
  16. 2 1
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/WindowViewModel.cs
  17. 1 1
      src/PixiEditor.AvaloniaUI/ViewModels/SystemCommands.cs
  18. 5 0
      src/PixiEditor.AvaloniaUI/ViewModels/ViewModelMain.cs
  19. 8 2
      src/PixiEditor.AvaloniaUI/Views/Main/MainTitleBar.axaml
  20. 1 1
      src/PixiEditor.AvaloniaUI/Views/MainView.axaml

+ 11 - 1
src/PixiEditor.AvaloniaUI/Helpers/ServiceCollectionHelpers.cs

@@ -12,6 +12,8 @@ using PixiEditor.AvaloniaUI.Models.Localization;
 using PixiEditor.AvaloniaUI.Models.Palettes;
 using PixiEditor.AvaloniaUI.Models.Palettes;
 using PixiEditor.AvaloniaUI.Models.Preferences;
 using PixiEditor.AvaloniaUI.Models.Preferences;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiEditor.AvaloniaUI.ViewModels.Menu;
+using PixiEditor.AvaloniaUI.ViewModels.Menu.MenuBuilders;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels.AdditionalContent;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels.AdditionalContent;
 using PixiEditor.AvaloniaUI.ViewModels.Tools.Tools;
 using PixiEditor.AvaloniaUI.ViewModels.Tools.Tools;
@@ -106,7 +108,15 @@ internal static class ServiceCollectionHelpers
             .AddSingleton<PaletteFileParser, GimpGplParser>()
             .AddSingleton<PaletteFileParser, GimpGplParser>()
             .AddSingleton<PaletteFileParser, PixiPaletteParser>()
             .AddSingleton<PaletteFileParser, PixiPaletteParser>()
             // Palette data sources
             // Palette data sources
-            .AddSingleton<PaletteListDataSource, LocalPalettesFetcher>();
+            .AddSingleton<PaletteListDataSource, LocalPalettesFetcher>()
+            .AddMenuBuilders();
+    }
+
+    private static IServiceCollection AddMenuBuilders(this IServiceCollection collection)
+    {
+        return collection
+            .AddSingleton<MenuItemBuilder, RecentFilesMenuBuilder>()
+            .AddSingleton<MenuItemBuilder, FileExitMenuBuilder>();
     }
     }
 
 
     public static IServiceCollection AddExtensionServices(this IServiceCollection collection, ExtensionLoader loader) =>
     public static IServiceCollection AddExtensionServices(this IServiceCollection collection, ExtensionLoader loader) =>

+ 10 - 0
src/PixiEditor.AvaloniaUI/Models/Commands/Attributes/Commands/CommandAttribute.cs

@@ -37,6 +37,16 @@ internal partial class Command
         /// </summary>
         /// </summary>
         public string IconPath { get; set; }
         public string IconPath { get; set; }
 
 
+        /// <summary>
+        ///     Gets or sets the path to the menu item. If null, command will not be added to menu.
+        /// </summary>
+        public string? MenuItemPath { get; set; }
+
+        /// <summary>
+        ///     Gets or sets the order of the menu item. By default, order is 100, so commands are added to the end of the menu.
+        /// </summary>
+        public int MenuItemOrder { get; set; } = 100;
+
         protected CommandAttribute([InternalName] string internalName, string displayName, string description)
         protected CommandAttribute([InternalName] string internalName, string displayName, string description)
         {
         {
             InternalName = internalName;
             InternalName = internalName;

+ 2 - 0
src/PixiEditor.AvaloniaUI/Models/Commands/CommandController.cs

@@ -252,6 +252,8 @@ internal class CommandController
                                 DefaultShortcut = attribute.GetShortcut(),
                                 DefaultShortcut = attribute.GetShortcut(),
                                 Shortcut = GetShortcut(name, attribute.GetShortcut(), template),
                                 Shortcut = GetShortcut(name, attribute.GetShortcut(), template),
                                 Parameter = basic.Parameter,
                                 Parameter = basic.Parameter,
+                                MenuItemPath = basic.MenuItemPath,
+                                MenuItemOrder = basic.MenuItemOrder,
                             });
                             });
                     }
                     }
                     else if (attribute is CommandAttribute.FilterAttribute menu)
                     else if (attribute is CommandAttribute.FilterAttribute menu)

+ 4 - 0
src/PixiEditor.AvaloniaUI/Models/Commands/Commands/Command.cs

@@ -41,6 +41,10 @@ internal abstract partial class Command : PixiObservableObject
         }
         }
     }
     }
 
 
+    public string? MenuItemPath { get; init; }
+
+    public int MenuItemOrder { get; init; } = 100;
+
     public event ShortcutChangedEventHandler ShortcutChanged;
     public event ShortcutChangedEventHandler ShortcutChanged;
     public event Action CanExecuteChanged;
     public event Action CanExecuteChanged;
 
 

+ 2 - 1
src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentManagerViewModel.cs

@@ -138,7 +138,8 @@ internal class DocumentManagerViewModel : SubViewModel<ViewModelMain>, IDocument
         ActiveDocument.EventInlet.OnSymmetryDragEnded(dir);
         ActiveDocument.EventInlet.OnSymmetryDragEnded(dir);
     }
     }
 
 
-    [Command.Basic("PixiEditor.Document.DeletePixels", "DELETE_PIXELS", "DELETE_PIXELS_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.Delete, IconPath = "Tools/EraserImage.png")]
+    [Command.Basic("PixiEditor.Document.DeletePixels", "DELETE_PIXELS", "DELETE_PIXELS_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.Delete, IconPath = "Tools/EraserImage.png",
+        MenuItemPath = "EDIT/DELETE_SELECTED_PIXELS", MenuItemOrder = 6)]
     public void DeletePixels()
     public void DeletePixels()
     {
     {
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.DeleteSelectedPixels();
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.DeleteSelectedPixels();

+ 116 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuBarViewModel.cs

@@ -0,0 +1,116 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Windows.Input;
+using Avalonia.Controls;
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.AvaloniaUI.Models.Commands;
+using PixiEditor.AvaloniaUI.Models.Commands.XAML;
+using PixiEditor.Extensions.Common.Localization;
+using Command = PixiEditor.AvaloniaUI.Models.Commands.Commands.Command;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Menu;
+
+internal class MenuBarViewModel : PixiObservableObject
+{
+    public ObservableCollection<MenuItem> MenuEntries { get; set; } = new();
+
+    private Dictionary<string, MenuTreeItem> menuItems = new();
+
+    public void Init(IServiceProvider serviceProvider, CommandController controller)
+    {
+        MenuItemBuilder[] builders = serviceProvider.GetServices<MenuItemBuilder>().ToArray();
+        foreach (var command in controller.Commands.OrderBy(x => x.MenuItemOrder).ThenBy(x => x.InternalName))
+        {
+           if(string.IsNullOrEmpty(command.MenuItemPath)) continue;
+
+           BuildMenuEntry(command);
+        }
+
+        BuildMenu(builders);
+    }
+
+    private void BuildMenu(MenuItemBuilder[] builders)
+    {
+        BuildSimpleItems(menuItems);
+        foreach (var builder in builders)
+        {
+            builder.ModifyMenuTree(MenuEntries);
+        }
+    }
+
+    private void BuildSimpleItems(Dictionary<string, MenuTreeItem> root, MenuItem? parent = null)
+    {
+        string? lastSubCommand = null;
+
+        foreach (var item in root)
+        {
+            MenuItem menuItem = new()
+            {
+                Header = new LocalizedString(item.Key),
+            };
+
+            if (item.Value.Items.Count == 0)
+            {
+                Models.Commands.XAML.Menu.SetCommand(menuItem, item.Value.Command.InternalName);
+
+                string internalName = item.Value.Command.InternalName;
+                internalName = internalName.Substring(0, internalName.LastIndexOf('.'));
+
+                if (lastSubCommand != null && lastSubCommand != internalName)
+                {
+                    parent?.Items.Add(new Separator());
+                }
+
+                if (parent != null)
+                {
+                    parent.Items.Add(menuItem);
+                }
+                else
+                {
+                    MenuEntries.Add(menuItem);
+                }
+
+                lastSubCommand = internalName;
+            }
+            else
+            {
+                if (parent != null)
+                {
+                    parent.Items.Add(menuItem);
+                }
+                else
+                {
+                    MenuEntries.Add(menuItem);
+                }
+                BuildSimpleItems(item.Value.Items, menuItem);
+            }
+        }
+    }
+
+    private void BuildMenuEntry(Command command)
+    {
+        string[] path = command.MenuItemPath!.Split('/');
+        MenuTreeItem current = null;
+
+        for (int i = 0; i < path.Length; i++)
+        {
+            if (current == null)
+            {
+                if (!menuItems.ContainsKey(path[i]))
+                {
+                    menuItems.Add(path[i], new MenuTreeItem(path[i], command));
+                }
+                current = menuItems[path[i]];
+            }
+            else
+            {
+                if (!current.Items.ContainsKey(path[i]))
+                {
+                    current.Items.Add(path[i], new MenuTreeItem(path[i], command));
+                }
+                current = current.Items[path[i]];
+            }
+        }
+    }
+}

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

@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using Avalonia.Controls;
+using PixiEditor.AvaloniaUI.Views;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Menu.MenuBuilders;
+
+internal class FileExitMenuBuilder : MenuItemBuilder
+{
+    public override void ModifyMenuTree(ICollection<MenuItem> tree)
+    {
+        if (TryFindMenuItem(tree, "FILE", out MenuItem? fileMenuItem))
+        {
+            var exitMenuItem = new MenuItem
+            {
+                Header = new LocalizedString("EXIT"),
+                Command = SystemCommands.CloseWindowCommand,
+                CommandParameter = MainWindow.Current
+            };
+
+            fileMenuItem!.Items.Add(new Separator());
+            fileMenuItem!.Items.Add(exitMenuItem);
+        }
+    }
+}

+ 90 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuBuilders/RecentFilesMenuBuilder.cs

@@ -0,0 +1,90 @@
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Styling;
+using PixiEditor.AvaloniaUI.Models.UserData;
+using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Menu;
+
+internal class RecentFilesMenuBuilder : MenuItemBuilder
+{
+    private readonly FileViewModel fileViewModel;
+
+    public RecentFilesMenuBuilder(FileViewModel fileViewModel)
+    {
+        this.fileViewModel = fileViewModel;
+    }
+
+    public override void ModifyMenuTree(ICollection<MenuItem> tree)
+    {
+        if(TryFindMenuItem(tree, "FILE", out MenuItem? fileMenuItem))
+        {
+            var recentFilesMenuItem = new MenuItem
+            {
+                Header = new LocalizedString("RECENT")
+            };
+
+            Style style = new Style((selector => selector.OfType<MenuItem>()))
+            {
+                Setters =
+                {
+                    new Setter(MenuItem.CommandProperty, new Models.Commands.XAML.Command("PixiEditor.File.OpenRecent") { UseProvided = true }.ProvideValue(null)),
+                    new Setter(MenuItem.CommandParameterProperty, new Binding() { Path = "FilePath"} )
+                }
+            };
+
+            recentFilesMenuItem.ItemsSource = fileViewModel.RecentlyOpened;
+            recentFilesMenuItem.Styles.Add(style);
+            recentFilesMenuItem.IsEnabled = fileViewModel.HasRecent;
+            recentFilesMenuItem.ItemTemplate = BuildItemTemplate();
+
+            fileMenuItem!.Items.Add(recentFilesMenuItem);
+        }
+    }
+
+    private IDataTemplate? BuildItemTemplate()
+    {
+        return new FuncDataTemplate<RecentlyOpenedDocument>((document, _) =>
+        {
+            var grid = new Grid
+            {
+                ColumnDefinitions =
+                {
+                    new ColumnDefinition(),
+                    new ColumnDefinition(new GridLength(1, GridUnitType.Auto))
+                }
+            };
+
+            var filePathTextBlock = new TextBlock
+            {
+                [!TextBlock.TextProperty] = new Binding("FilePath")
+            };
+
+            var removeButton = new Button
+            {
+                Content = "",
+                FontFamily = "{DynamicResource Feather}"
+            };
+
+            Style style = new Style((selector => selector.OfType<Button>()))
+            {
+                Setters =
+                {
+                    new Setter(Button.CommandProperty, new Models.Commands.XAML.Command("PixiEditor.File.RemoveRecent") { UseProvided = true }.ProvideValue(null)),
+                    new Setter(Button.CommandParameterProperty, new Binding("FilePath"))
+                }
+            };
+
+            removeButton.Styles.Add(style);
+
+            grid.Children.Add(filePathTextBlock);
+            grid.Children.Add(removeButton);
+            Grid.SetColumn(removeButton, 1);
+
+            return grid;
+        });
+    }
+}

+ 31 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuItemBuilder.cs

@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using Avalonia.Controls;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Menu;
+
+internal abstract class MenuItemBuilder
+{
+    public abstract void ModifyMenuTree(ICollection<MenuItem> tree);
+
+    protected bool TryFindMenuItem(ICollection<MenuItem> tree, string header, out MenuItem? menuItem)
+    {
+        foreach (var item in tree)
+        {
+            if (item.Header is LocalizedString localizedString && localizedString.Key == header)
+            {
+                menuItem = item;
+                return true;
+            }
+
+            if (item.Header is string headerString && headerString == header)
+            {
+                menuItem = item;
+                return true;
+            }
+        }
+
+        menuItem = null;
+        return false;
+    }
+}

+ 17 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Menu/MenuTreeItem.cs

@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using PixiEditor.AvaloniaUI.Models.Commands.Commands;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Menu;
+
+internal class MenuTreeItem
+{
+    public string HeaderKey { get; set; }
+    public Command Command { get; set; }
+    public Dictionary<string, MenuTreeItem> Items { get; set; } = new();
+
+    public MenuTreeItem(string headerKey, Command command)
+    {
+        HeaderKey = headerKey;
+        Command = command;
+    }
+}

+ 6 - 3
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/ClipboardViewModel.cs

@@ -29,7 +29,8 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         });
         });
     }
     }
 
 
-    [Command.Basic("PixiEditor.Clipboard.Cut", "CUT", "CUT_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.X, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.Clipboard.Cut", "CUT", "CUT_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.X, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "EDIT/CUT", MenuItemOrder = 2)]
     public async Task Cut()
     public async Task Cut()
     {
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
@@ -39,7 +40,8 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         doc.Operations.DeleteSelectedPixels(true);
         doc.Operations.DeleteSelectedPixels(true);
     }
     }
 
 
-    [Command.Basic("PixiEditor.Clipboard.Paste", false, "PASTE", "PASTE_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Shift)]
+    [Command.Basic("PixiEditor.Clipboard.Paste", false, "PASTE", "PASTE_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Shift,
+        MenuItemPath = "EDIT/PASTE", MenuItemOrder = 4)]
     [Command.Basic("PixiEditor.Clipboard.PasteAsNewLayer", true, "PASTE_AS_NEW_LAYER", "PASTE_AS_NEW_LAYER_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Control)]
     [Command.Basic("PixiEditor.Clipboard.PasteAsNewLayer", true, "PASTE_AS_NEW_LAYER", "PASTE_AS_NEW_LAYER_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Control)]
     public void Paste(bool pasteAsNewLayer)
     public void Paste(bool pasteAsNewLayer)
     {
     {
@@ -102,7 +104,8 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
-    [Command.Basic("PixiEditor.Clipboard.Copy", "COPY", "COPY_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.C, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.Clipboard.Copy", "COPY", "COPY_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.C, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "EDIT/COPY", MenuItemOrder = 3)]
     public async Task Copy()
     public async Task Copy()
     {
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;

+ 10 - 5
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/FileViewModel.cs

@@ -129,7 +129,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         OpenFromPath(path);
         OpenFromPath(path);
     }
     }
 
 
-    [Command.Basic("PixiEditor.File.Open", "OPEN", "OPEN_FILE", Key = Key.O, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.File.Open", "OPEN", "OPEN_FILE", Key = Key.O, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "FILE/OPEN_FILE", MenuItemOrder = 1)]
     public async Task OpenFromOpenFileDialog()
     public async Task OpenFromOpenFileDialog()
     {
     {
         var filter = SupportedFilesHelper.BuildOpenFilter();
         var filter = SupportedFilesHelper.BuildOpenFilter();
@@ -270,7 +271,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         AddRecentlyOpened(path);
         AddRecentlyOpened(path);
     }
     }
 
 
-    [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "FILE/NEW_FILE", MenuItemOrder = 0)]
     public async Task CreateFromNewFileDialog()
     public async Task CreateFromNewFileDialog()
     {
     {
         Window mainWindow = (Application.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime).MainWindow;
         Window mainWindow = (Application.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime).MainWindow;
@@ -299,8 +301,10 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         Owner.WindowSubViewModel.MakeDocumentViewportActive(doc);
         Owner.WindowSubViewModel.MakeDocumentViewportActive(doc);
     }
     }
 
 
-    [Command.Basic("PixiEditor.File.Save", false, "SAVE", "SAVE_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control, IconPath = "Save.png")]
-    [Command.Basic("PixiEditor.File.SaveAsNew", true, "SAVE_AS", "SAVE_IMAGE_AS", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control | KeyModifiers.Shift, IconPath = "Save.png")]
+    [Command.Basic("PixiEditor.File.Save", false, "SAVE", "SAVE_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control, IconPath = "Save.png",
+        MenuItemPath = "FILE/SAVE_PIXI", MenuItemOrder = 3)]
+    [Command.Basic("PixiEditor.File.SaveAsNew", true, "SAVE_AS", "SAVE_IMAGE_AS", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control | KeyModifiers.Shift, IconPath = "Save.png",
+        MenuItemPath = "FILE/SAVE_AS_PIXI", MenuItemOrder = 4)]
     public async Task<bool> SaveActiveDocument(bool asNew)
     public async Task<bool> SaveActiveDocument(bool asNew)
     {
     {
         DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
@@ -346,7 +350,8 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
     ///     Generates export dialog or saves directly if save data is known.
     ///     Generates export dialog or saves directly if save data is known.
     /// </summary>
     /// </summary>
     /// <param name="parameter">CommandProperty.</param>
     /// <param name="parameter">CommandProperty.</param>
-    [Command.Basic("PixiEditor.File.Export", "EXPORT", "EXPORT_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.E, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.File.Export", "EXPORT", "EXPORT_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.E, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "FILE/EXPORT_IMG", MenuItemOrder = 5)]
     public async Task ExportFile()
     public async Task ExportFile()
     {
     {
         try
         try

+ 2 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/LayersViewModel.cs

@@ -167,7 +167,8 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
-    [Command.Basic("PixiEditor.Layer.DuplicateSelectedLayer", "DUPLICATE_SELECTED_LAYER", "DUPLICATE_SELECTED_LAYER", CanExecute = "PixiEditor.Layer.SelectedMemberIsLayer")]
+    [Command.Basic("PixiEditor.Layer.DuplicateSelectedLayer", "DUPLICATE_SELECTED_LAYER", "DUPLICATE_SELECTED_LAYER", CanExecute = "PixiEditor.Layer.SelectedMemberIsLayer",
+        MenuItemPath = "EDIT/DUPLICATE", MenuItemOrder = 5)]
     public void DuplicateLayer()
     public void DuplicateLayer()
     {
     {
         var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;
         var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;

+ 16 - 8
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/SelectionViewModel.cs

@@ -14,7 +14,8 @@ internal class SelectionViewModel : SubViewModel<ViewModelMain>
     {
     {
     }
     }
 
 
-    [Command.Basic("PixiEditor.Selection.SelectAll", "SELECT_ALL", "SELECT_ALL_DESCRIPTIVE", CanExecute = "PixiEditor.HasDocument", Key = Key.A, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.Selection.SelectAll", "SELECT_ALL", "SELECT_ALL_DESCRIPTIVE", CanExecute = "PixiEditor.HasDocument", Key = Key.A, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "EDIT/SELECT/SELECT_ALL", MenuItemOrder = 8)]
     public void SelectAll()
     public void SelectAll()
     {
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
@@ -23,7 +24,8 @@ internal class SelectionViewModel : SubViewModel<ViewModelMain>
         doc.Operations.SelectAll();
         doc.Operations.SelectAll();
     }
     }
 
 
-    [Command.Basic("PixiEditor.Selection.Clear", "CLEAR_SELECTION", "CLEAR_SELECTION", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.D, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.Selection.Clear", "CLEAR_SELECTION", "CLEAR_SELECTION", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.D, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "EDIT/SELECT/DESELECT", MenuItemOrder = 9)]
     public void ClearSelection()
     public void ClearSelection()
     {
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
@@ -32,7 +34,8 @@ internal class SelectionViewModel : SubViewModel<ViewModelMain>
         doc.Operations.ClearSelection();
         doc.Operations.ClearSelection();
     }
     }
 
 
-    [Command.Basic("PixiEditor.Selection.InvertSelection", "INVERT_SELECTION", "INVERT_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.I, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.Selection.InvertSelection", "INVERT_SELECTION", "INVERT_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.I, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "EDIT/SELECT/INVERT", MenuItemOrder = 10)]
     public void InvertSelection()
     public void InvertSelection()
     {
     {
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.InvertSelection();
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.InvertSelection();
@@ -66,17 +69,22 @@ internal class SelectionViewModel : SubViewModel<ViewModelMain>
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.NudgeSelectedObject(distance);
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.NudgeSelectedObject(distance);
     }
     }
 
 
-    [Command.Basic("PixiEditor.Selection.NewToMask", SelectionMode.New, "MASK_FROM_SELECTION", "MASK_FROM_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty")]
-    [Command.Basic("PixiEditor.Selection.AddToMask", SelectionMode.Add, "ADD_SELECTION_TO_MASK", "ADD_SELECTION_TO_MASK", CanExecute = "PixiEditor.Selection.IsNotEmpty")]
-    [Command.Basic("PixiEditor.Selection.SubtractFromMask", SelectionMode.Subtract, "SUBTRACT_SELECTION_FROM_MASK", "SUBTRACT_SELECTION_FROM_MASK", CanExecute = "PixiEditor.Selection.IsNotEmptyAndHasMask")]
-    [Command.Basic("PixiEditor.Selection.IntersectSelectionMask", SelectionMode.Intersect, "INTERSECT_SELECTION_MASK", "INTERSECT_SELECTION_MASK", CanExecute = "PixiEditor.Selection.IsNotEmptyAndHasMask")]
+    [Command.Basic("PixiEditor.Selection.NewToMask", SelectionMode.New, "MASK_FROM_SELECTION", "MASK_FROM_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty",
+        MenuItemPath = "EDIT/SELECT/SELECTION_TO_MASK/TO_NEW_MASK", MenuItemOrder = 12)]
+    [Command.Basic("PixiEditor.Selection.AddToMask", SelectionMode.Add, "ADD_SELECTION_TO_MASK", "ADD_SELECTION_TO_MASK", CanExecute = "PixiEditor.Selection.IsNotEmpty",
+        MenuItemPath = "EDIT/SELECT/SELECTION_TO_MASK/ADD_TO_MASK", MenuItemOrder = 13)]
+    [Command.Basic("PixiEditor.Selection.SubtractFromMask", SelectionMode.Subtract, "SUBTRACT_SELECTION_FROM_MASK", "SUBTRACT_SELECTION_FROM_MASK", CanExecute = "PixiEditor.Selection.IsNotEmptyAndHasMask",
+        MenuItemPath = "EDIT/SELECT/SELECTION_TO_MASK/SUBTRACT_FROM_MASK", MenuItemOrder = 14)]
+    [Command.Basic("PixiEditor.Selection.IntersectSelectionMask", SelectionMode.Intersect, "INTERSECT_SELECTION_MASK", "INTERSECT_SELECTION_MASK", CanExecute = "PixiEditor.Selection.IsNotEmptyAndHasMask",
+        MenuItemPath = "EDIT/SELECT/SELECTION_TO_MASK/INTERSECT_WITH_MASK", MenuItemOrder = 15)]
     [Command.Filter("PixiEditor.Selection.ToMaskMenu", "SELECTION_TO_MASK", "SELECTION_TO_MASK", Key = Key.M, Modifiers = KeyModifiers.Control)]
     [Command.Filter("PixiEditor.Selection.ToMaskMenu", "SELECTION_TO_MASK", "SELECTION_TO_MASK", Key = Key.M, Modifiers = KeyModifiers.Control)]
     public void SelectionToMask(SelectionMode mode)
     public void SelectionToMask(SelectionMode mode)
     {
     {
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.SelectionToMask(mode);
         Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.SelectionToMask(mode);
     }
     }
 
 
-    [Command.Basic("PixiEditor.Selection.CropToSelection", "CROP_TO_SELECTION", "CROP_TO_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty")]
+    [Command.Basic("PixiEditor.Selection.CropToSelection", "CROP_TO_SELECTION", "CROP_TO_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty",
+        MenuItemPath = "EDIT/SELECT/CROP_TO_SELECTION", MenuItemOrder = 11)]
     public void CropToSelection()
     public void CropToSelection()
     {
     {
         var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var document = Owner.DocumentManagerSubViewModel.ActiveDocument;

+ 2 - 2
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/UndoViewModel.cs

@@ -16,7 +16,7 @@ internal class UndoViewModel : SubViewModel<ViewModelMain>
     ///     Redo last action.
     ///     Redo last action.
     /// </summary>
     /// </summary>
     [Command.Basic("PixiEditor.Undo.Redo", "REDO", "REDO_DESCRIPTIVE", CanExecute = "PixiEditor.Undo.CanRedo", Key = Key.Y, Modifiers = KeyModifiers.Control,
     [Command.Basic("PixiEditor.Undo.Redo", "REDO", "REDO_DESCRIPTIVE", CanExecute = "PixiEditor.Undo.CanRedo", Key = Key.Y, Modifiers = KeyModifiers.Control,
-        IconPath = "Redo.png")]
+        IconPath = "Redo.png", MenuItemPath = "EDIT/REDO", MenuItemOrder = 1)]
     public void Redo()
     public void Redo()
     {
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
@@ -29,7 +29,7 @@ internal class UndoViewModel : SubViewModel<ViewModelMain>
     ///     Undo last action.
     ///     Undo last action.
     /// </summary>
     /// </summary>
     [Command.Basic("PixiEditor.Undo.Undo", "UNDO", "UNDO_DESCRIPTIVE", CanExecute = "PixiEditor.Undo.CanUndo", Key = Key.Z, Modifiers = KeyModifiers.Control,
     [Command.Basic("PixiEditor.Undo.Undo", "UNDO", "UNDO_DESCRIPTIVE", CanExecute = "PixiEditor.Undo.CanUndo", Key = Key.Z, Modifiers = KeyModifiers.Control,
-        IconPath = "Undo.png")]
+        IconPath = "Undo.png", MenuItemPath = "EDIT/UNDO", MenuItemOrder = 0)]
     public void Undo()
     public void Undo()
     {
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;

+ 2 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/WindowViewModel.cs

@@ -144,7 +144,8 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
-    [Command.Basic("PixiEditor.Window.OpenSettingsWindow", "OPEN_SETTINGS", "OPEN_SETTINGS_DESCRIPTIVE", Key = Key.OemComma, Modifiers = KeyModifiers.Control)]
+    [Command.Basic("PixiEditor.Window.OpenSettingsWindow", "OPEN_SETTINGS", "OPEN_SETTINGS_DESCRIPTIVE", Key = Key.OemComma, Modifiers = KeyModifiers.Control,
+        MenuItemPath = "EDIT/SETTINGS", MenuItemOrder = 16)]
     public static void OpenSettingsWindow(int page)
     public static void OpenSettingsWindow(int page)
     {
     {
         if (page < 0)
         if (page < 0)

+ 1 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SystemCommands.cs

@@ -20,6 +20,6 @@ public static class SystemCommands
 
 
     public static void CloseWindow(Window? obj)
     public static void CloseWindow(Window? obj)
     {
     {
-        // TODO: Close window, this is just a placeholder, won't work
+        obj?.Close();
     }
     }
 }
 }

+ 5 - 0
src/PixiEditor.AvaloniaUI/ViewModels/ViewModelMain.cs

@@ -10,6 +10,7 @@ using PixiEditor.AvaloniaUI.Models.Dialogs;
 using PixiEditor.AvaloniaUI.Models.DocumentModels;
 using PixiEditor.AvaloniaUI.Models.DocumentModels;
 using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiEditor.AvaloniaUI.ViewModels.Menu;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels.AdditionalContent;
 using PixiEditor.AvaloniaUI.ViewModels.SubViewModels.AdditionalContent;
 using PixiEditor.AvaloniaUI.ViewModels.Tools;
 using PixiEditor.AvaloniaUI.ViewModels.Tools;
@@ -73,6 +74,8 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
 
     public LayoutViewModel LayoutSubViewModel { get; set; }
     public LayoutViewModel LayoutSubViewModel { get; set; }
 
 
+    public MenuBarViewModel MenuBarViewModel { get; set; } = new();
+
     public IPreferences Preferences { get; set; }
     public IPreferences Preferences { get; set; }
     public ILocalizationProvider LocalizationProvider { get; set; }
     public ILocalizationProvider LocalizationProvider { get; set; }
 
 
@@ -147,6 +150,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
 
         CommandController.Init(services);
         CommandController.Init(services);
         LayoutSubViewModel.LayoutManager.InitLayout(this);
         LayoutSubViewModel.LayoutManager.InitLayout(this);
+        MenuBarViewModel.Init(services, CommandController);
 
 
         MiscSubViewModel = services.GetService<MiscViewModel>();
         MiscSubViewModel = services.GetService<MiscViewModel>();
 
 
@@ -156,6 +160,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
 
         SearchSubViewModel = services.GetService<SearchViewModel>();
         SearchSubViewModel = services.GetService<SearchViewModel>();
 
 
+
         ExtensionsSubViewModel = services.GetService<ExtensionsViewModel>(); // Must be last
         ExtensionsSubViewModel = services.GetService<ExtensionsViewModel>(); // Must be last
 
 
         DocumentManagerSubViewModel.ActiveDocumentChanged += OnActiveDocumentChanged;
         DocumentManagerSubViewModel.ActiveDocumentChanged += OnActiveDocumentChanged;

+ 8 - 2
src/PixiEditor.AvaloniaUI/Views/Main/MainTitleBar.axaml

@@ -8,8 +8,13 @@
              xmlns:viewModels="clr-namespace:PixiEditor.AvaloniaUI.ViewModels"
              xmlns:viewModels="clr-namespace:PixiEditor.AvaloniaUI.ViewModels"
              xmlns:dialogs="clr-namespace:PixiEditor.AvaloniaUI.Views.Dialogs"
              xmlns:dialogs="clr-namespace:PixiEditor.AvaloniaUI.Views.Dialogs"
              xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input;assembly=PixiEditor.UI.Common"
              xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input;assembly=PixiEditor.UI.Common"
+             xmlns:menu="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.Menu"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:DataType="menu:MenuBarViewModel"
              x:Class="PixiEditor.AvaloniaUI.Views.Main.MainTitleBar">
              x:Class="PixiEditor.AvaloniaUI.Views.Main.MainTitleBar">
+    <Design.DataContext>
+        <menu:MenuBarViewModel/>
+    </Design.DataContext>
     <Grid>
     <Grid>
         <dialogs:DialogTitleBar 
         <dialogs:DialogTitleBar 
             DockPanel.Dock="Top"/>
             DockPanel.Dock="Top"/>
@@ -19,8 +24,9 @@
                 DockPanel.Dock="Left"
                 DockPanel.Dock="Left"
                 HorizontalAlignment="Left"
                 HorizontalAlignment="Left"
                 VerticalAlignment="Center"
                 VerticalAlignment="Center"
+                ItemsSource="{Binding MenuEntries}"
                 Background="Transparent">
                 Background="Transparent">
-            <MenuItem
+            <!--<MenuItem
                 ui:Translator.Key="FILE"
                 ui:Translator.Key="FILE"
                 Focusable="False">
                 Focusable="False">
                 <MenuItem
                 <MenuItem
@@ -299,7 +305,7 @@
                         ui:Translator.Key="CLEAR_RECENT_DOCUMENTS"
                         ui:Translator.Key="CLEAR_RECENT_DOCUMENTS"
                         xaml:Menu.Command="PixiEditor.Debug.ClearRecentDocument"/>
                         xaml:Menu.Command="PixiEditor.Debug.ClearRecentDocument"/>
                 </MenuItem>
                 </MenuItem>
-            </MenuItem>
+            </MenuItem>-->
         </xaml:Menu>
         </xaml:Menu>
         <Border Width="300" Height="25"
         <Border Width="300" Height="25"
                 Background="{DynamicResource ThemeBackgroundBrush}"
                 Background="{DynamicResource ThemeBackgroundBrush}"

+ 1 - 1
src/PixiEditor.AvaloniaUI/Views/MainView.axaml

@@ -20,7 +20,7 @@
     </Interaction.Behaviors>
     </Interaction.Behaviors>
     <Grid>
     <Grid>
         <DockPanel>
         <DockPanel>
-            <main1:MainTitleBar DockPanel.Dock="Top" />
+            <main1:MainTitleBar DockPanel.Dock="Top" DataContext="{Binding MenuBarViewModel}"/>
             <Grid Focusable="True" Name="FocusableGrid">
             <Grid Focusable="True" Name="FocusableGrid">
                 <Grid.RowDefinitions>
                 <Grid.RowDefinitions>
                     <RowDefinition Height="40" />
                     <RowDefinition Height="40" />