Browse Source

Merge pull request #927 from PixiEditor/api/commands

Added commands extensions api
Krzysztof Krysiński 3 months ago
parent
commit
83a57dd55f
44 changed files with 1825 additions and 75 deletions
  1. 6 0
      samples/PixiEditorExtensionSamples.sln
  2. 1 1
      samples/Sample1_HelloWorld/Sample1_HelloWorld.csproj
  3. 1 1
      samples/Sample2_LocalizationSample/Sample2_LocalizationSample.csproj
  4. 1 1
      samples/Sample3_Preferences/Sample3_Preferences.csproj
  5. 1 1
      samples/Sample4_CreatePopup/Sample4_CreatePopup.csproj
  6. 1 1
      samples/Sample5_Resources/Sample5_Resources.csproj
  7. 1 1
      samples/Sample6_Palettes/Sample6_Palettes.csproj
  8. 1 1
      samples/Sample7_FlyUI/Sample7_FlyUI.csproj
  9. 51 0
      samples/Sample8_Commands/CommandsSampleExtension.cs
  10. 3 0
      samples/Sample8_Commands/Localization/en.json
  11. 3 0
      samples/Sample8_Commands/Localization/pl.json
  12. 9 0
      samples/Sample8_Commands/Program.cs
  13. 39 0
      samples/Sample8_Commands/Sample8_Commands.csproj
  14. 41 0
      samples/Sample8_Commands/extension.json
  15. 7 0
      samples/global.json
  16. 14 0
      src/PixiEditor.Extensions.CommonApi/Commands/CommandMetadata.Impl.cs
  17. 1144 0
      src/PixiEditor.Extensions.CommonApi/Commands/Shortcut.Impl.cs
  18. 17 0
      src/PixiEditor.Extensions.CommonApi/DataContracts/CommandMetadata.proto
  19. 10 0
      src/PixiEditor.Extensions.CommonApi/DataContracts/Shortcut.proto
  20. 8 0
      src/PixiEditor.Extensions.CommonApi/Menu/ICommandProvider.cs
  21. 50 0
      src/PixiEditor.Extensions.CommonApi/ProtoAutogen/CommandMetadata.cs
  22. 30 0
      src/PixiEditor.Extensions.CommonApi/ProtoAutogen/Shortcut.cs
  23. 11 1
      src/PixiEditor.Extensions.CommonApi/Utilities/PrefixedNameUtility.cs
  24. 41 0
      src/PixiEditor.Extensions.Sdk/Api/Commands/CommandProvider.cs
  25. 23 0
      src/PixiEditor.Extensions.Sdk/Bridge/Interop.Commands.cs
  26. 0 6
      src/PixiEditor.Extensions.Sdk/Bridge/Interop.Preferences.cs
  27. 7 0
      src/PixiEditor.Extensions.Sdk/Bridge/Interop.cs
  28. 17 0
      src/PixiEditor.Extensions.Sdk/Bridge/Native.Commands.cs
  29. 3 0
      src/PixiEditor.Extensions.Sdk/PixiEditorApi.cs
  30. BIN
      src/PixiEditor.Extensions.Sdk/build/PixiEditor.Api.CGlueMSBuild.dll
  31. BIN
      src/PixiEditor.Extensions.Sdk/build/PixiEditor.Extensions.MSPackageBuilder.dll
  32. 31 0
      src/PixiEditor.Extensions.WasmRuntime/Api/CommandApi.cs
  33. 21 0
      src/PixiEditor.Extensions.WasmRuntime/Api/Modules/CommandModule.cs
  34. 1 0
      src/PixiEditor.Extensions.WasmRuntime/WasmExtensionInstance.cs
  35. 2 0
      src/PixiEditor.Extensions/ExtensionServices.cs
  36. 5 1
      src/PixiEditor/Helpers/ServiceCollectionHelpers.cs
  37. 3 0
      src/PixiEditor/Models/Commands/CommandCollection.cs
  38. 49 33
      src/PixiEditor/Models/Commands/CommandController.cs
  39. 20 6
      src/PixiEditor/Models/Commands/CommandGroup.cs
  40. 67 0
      src/PixiEditor/Models/ExtensionServices/CommandProvider.cs
  41. 11 0
      src/PixiEditor/Models/ExtensionServices/DynamicResourceIconLookupProvider.cs
  42. 6 0
      src/PixiEditor/Models/ExtensionServices/IIconLookupProvider.cs
  43. 61 15
      src/PixiEditor/ViewModels/Menu/MenuBarViewModel.cs
  44. 7 6
      src/PixiEditor/ViewModels/ViewModelMain.cs

+ 6 - 0
samples/PixiEditorExtensionSamples.sln

@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample6_Palettes", "Sample6
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample7_FlyUI", "Sample7_FlyUI\Sample7_FlyUI.csproj", "{432A224A-8035-47C1-AC41-6715021B3AA3}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample8_Commands", "Sample8_Commands\Sample8_Commands.csproj", "{25DA4758-9F82-494E-96A3-B9C48637C0E0}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -62,6 +64,10 @@ Global
 		{432A224A-8035-47C1-AC41-6715021B3AA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{432A224A-8035-47C1-AC41-6715021B3AA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{432A224A-8035-47C1-AC41-6715021B3AA3}.Release|Any CPU.Build.0 = Release|Any CPU
+		{25DA4758-9F82-494E-96A3-B9C48637C0E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{25DA4758-9F82-494E-96A3-B9C48637C0E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{25DA4758-9F82-494E-96A3-B9C48637C0E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{25DA4758-9F82-494E-96A3-B9C48637C0E0}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{FD9B4C32-4D2E-410E-BC6B-787779BEB6E2} = {7CC35BC4-829F-4EF4-8EB6-E1D46206E7DC}

+ 1 - 1
samples/Sample1_HelloWorld/Sample1_HelloWorld.csproj

@@ -6,7 +6,7 @@
         <PublishTrimmed>true</PublishTrimmed>
         <WasmSingleFileBundle>true</WasmSingleFileBundle>
         <GenerateExtensionPackage>true</GenerateExtensionPackage>
-        <PixiExtOutputPath>..\..\src\PixiEditor.AvaloniaUI.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
         <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
         <RootNamespace>HelloWorld</RootNamespace>
         <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>

+ 1 - 1
samples/Sample2_LocalizationSample/Sample2_LocalizationSample.csproj

@@ -6,7 +6,7 @@
         <PublishTrimmed>true</PublishTrimmed>
         <WasmSingleFileBundle>true</WasmSingleFileBundle>
         <GenerateExtensionPackage>true</GenerateExtensionPackage>
-        <PixiExtOutputPath>..\..\src\PixiEditor.AvaloniaUI.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
         <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
         <RootNamespace>LocalizationSample</RootNamespace>
         <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>

+ 1 - 1
samples/Sample3_Preferences/Sample3_Preferences.csproj

@@ -6,7 +6,7 @@
         <PublishTrimmed>true</PublishTrimmed>
         <WasmSingleFileBundle>true</WasmSingleFileBundle>
         <GenerateExtensionPackage>true</GenerateExtensionPackage>
-        <PixiExtOutputPath>..\..\src\PixiEditor.AvaloniaUI.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
         <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
         <RootNamespace>Preferences</RootNamespace>
         <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>

+ 1 - 1
samples/Sample4_CreatePopup/Sample4_CreatePopup.csproj

@@ -6,7 +6,7 @@
         <PublishTrimmed>true</PublishTrimmed>
         <WasmSingleFileBundle>true</WasmSingleFileBundle>
         <GenerateExtensionPackage>true</GenerateExtensionPackage>
-        <PixiExtOutputPath>..\..\src\PixiEditor.AvaloniaUI.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
         <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
         <RootNamespace>CreatePopupSample</RootNamespace>
         <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>

+ 1 - 1
samples/Sample5_Resources/Sample5_Resources.csproj

@@ -6,7 +6,7 @@
         <PublishTrimmed>true</PublishTrimmed>
         <WasmSingleFileBundle>true</WasmSingleFileBundle>
         <GenerateExtensionPackage>true</GenerateExtensionPackage>
-        <PixiExtOutputPath>..\..\src\PixiEditor.AvaloniaUI.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
         <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
         <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>
         <RootNamespace>ResourcesSample</RootNamespace>

+ 1 - 1
samples/Sample6_Palettes/Sample6_Palettes.csproj

@@ -6,7 +6,7 @@
         <PublishTrimmed>true</PublishTrimmed>
         <WasmSingleFileBundle>true</WasmSingleFileBundle>
         <GenerateExtensionPackage>true</GenerateExtensionPackage>
-        <PixiExtOutputPath>..\..\src\PixiEditor.AvaloniaUI.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
         <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
         <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>
         <RootNamespace>PalettesSample</RootNamespace>

+ 1 - 1
samples/Sample7_FlyUI/Sample7_FlyUI.csproj

@@ -6,7 +6,7 @@
         <PublishTrimmed>true</PublishTrimmed>
         <WasmSingleFileBundle>true</WasmSingleFileBundle>
         <GenerateExtensionPackage>true</GenerateExtensionPackage>
-        <PixiExtOutputPath>..\..\src\PixiEditor.AvaloniaUI.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\win-x64\Extensions</PixiExtOutputPath>
         <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
         <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>
         <RootNamespace>FlyUISample</RootNamespace>

+ 51 - 0
samples/Sample8_Commands/CommandsSampleExtension.cs

@@ -0,0 +1,51 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+using PixiEditor.Extensions.Sdk;
+
+namespace Sample8_Menu;
+
+public class CommandsSampleExtension : PixiEditorExtension
+{
+    /// <summary>
+    ///     This method is called when extension is loaded.
+    ///  All extensions are first loaded and then initialized. This method is called before <see cref="OnInitialized"/>.
+    /// </summary>
+    public override void OnLoaded()
+    {
+    }
+
+    /// <summary>
+    ///     This method is called when extension is initialized. After this method is called, you can use Api property to access PixiEditor API.
+    /// </summary>
+    public override void OnInitialized()
+    {
+        // A good practice is to use localization keys instead of hardcoded strings.
+        // And add them to the localization file. Check Sample2_LocalizationSample for more information.
+
+        CommandMetadata firstCommand = new CommandMetadata("Loggers.WriteHello");
+        firstCommand.DisplayName = "Write Hello"; // can be localized
+        firstCommand.Description = "Writes Hello to the log"; // can be localized
+
+        // Either an icon key (https://github.com/PixiEditor/PixiEditor/blob/master/src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml)
+        // or unicode character
+        firstCommand.Icon = "icon-terminal";
+        firstCommand.MenuItemPath = "AWESOME_LOGGER/Write Hello"; // AWESOME_LOGGER is taken from localization, same can be done for the rest
+        firstCommand.Shortcut = new Shortcut(Key.H, KeyModifiers.Control | KeyModifiers.Alt);
+
+        Api.Commands.RegisterCommand(firstCommand, () => { Api.Logger.Log("Hello from the command!"); });
+
+        int clickedCount = 0;
+        CommandMetadata secondCommand = new CommandMetadata("Loggers.WriteClickedCount");
+        secondCommand.DisplayName = "Write Clicked Count";
+        secondCommand.Description = "Writes clicked count to the log";
+        secondCommand.Icon = "icon-terminal";
+        secondCommand.MenuItemPath = "EDIT/Write Clicked Count"; // append to EDIT menu
+        secondCommand.Order = 1000; // Last
+
+        secondCommand.Shortcut = new Shortcut(Key.C, KeyModifiers.Control | KeyModifiers.Alt);
+        Api.Commands.RegisterCommand(secondCommand, () =>
+        {
+            clickedCount++;
+            Api.Logger.Log($"Clicked {clickedCount} times");
+        });
+    }
+}

+ 3 - 0
samples/Sample8_Commands/Localization/en.json

@@ -0,0 +1,3 @@
+{
+  "AWESOME_LOGGER": "Awesome Logger"
+}

+ 3 - 0
samples/Sample8_Commands/Localization/pl.json

@@ -0,0 +1,3 @@
+{
+  "AWESOME_LOGGER": "Znakomity rejestrator"
+}

+ 9 - 0
samples/Sample8_Commands/Program.cs

@@ -0,0 +1,9 @@
+namespace Sample8_Menu;
+
+public static class Program
+{
+    public static void Main()
+    {
+        
+    }
+}

+ 39 - 0
samples/Sample8_Commands/Sample8_Commands.csproj

@@ -0,0 +1,39 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net8.0</TargetFramework>
+        <RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
+        <OutputType>Exe</OutputType>
+        <PublishTrimmed>true</PublishTrimmed>
+        <WasmSingleFileBundle>true</WasmSingleFileBundle>
+        <GenerateExtensionPackage>true</GenerateExtensionPackage>
+        <PixiExtOutputPath>..\..\src\PixiEditor.Desktop\bin\Debug\net8.0\Extensions</PixiExtOutputPath>
+        <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
+        <ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>
+        <RootNamespace>Sample8_Commands</RootNamespace>
+    </PropertyGroup>
+
+    <!--Below is not required if you use Nuget package, this sample references project directly, so it must be here-->
+    <ItemGroup>
+        <ProjectReference Include="..\..\src\PixiEditor.Extensions.Sdk\PixiEditor.Extensions.Sdk.csproj"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <None Remove="extension.json"/>
+        <Content Include="extension.json">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </Content>
+    </ItemGroup>
+
+    <ItemGroup>
+        <Content Include="Localization\*">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </Content>
+    </ItemGroup>
+
+    <!--Below is not required if you use Nuget package, this sample references project directly, so it must be here-->
+    <Import Project="..\..\src\PixiEditor.Extensions.Sdk\build\PixiEditor.Extensions.Sdk.props"/>
+    <Import Project="..\..\src\PixiEditor.Extensions.Sdk\build\PixiEditor.Extensions.Sdk.targets"/>
+
+
+</Project>

+ 41 - 0
samples/Sample8_Commands/extension.json

@@ -0,0 +1,41 @@
+{
+  "displayName": "Sample Extension - Commands",
+  "uniqueName": "yourCompany.Samples.Commands",
+  "description": "Sample Commands extension for PixiEditor",
+  "version": "1.0.0",
+  "localization": {
+    "languages": [
+      {
+        "name": "English",
+        "code": "en",
+        "localeFileName": "Localization/en.json"
+      },
+      {
+        "name": "Polish",
+        "code": "pl",
+        "localeFileName": "Localization/pl.json"
+      }
+    ]
+  },
+  "author": {
+    "name": "PixiEditor",
+    "email": "[email protected]",
+    "website": "https://pixieditor.net"
+  },
+  "publisher": {
+    "name": "PixiEditor",
+    "email": "[email protected]",
+    "website": "https://pixieditor.net"
+  },
+  "contributors": [
+    {
+      "name": "flabbet",
+      "email": "[email protected]",
+      "website": "https://github.com/flabbet"
+    }
+  ],
+  "license": "MIT",
+  "categories": [
+    "Extension"
+  ]
+}

+ 7 - 0
samples/global.json

@@ -0,0 +1,7 @@
+{
+  "sdk": {
+    "version": "8.0.405",
+    "rollForward": "latestMinor",
+    "allowPrerelease": false
+  }
+}

+ 14 - 0
src/PixiEditor.Extensions.CommonApi/Commands/CommandMetadata.Impl.cs

@@ -0,0 +1,14 @@
+namespace PixiEditor.Extensions.CommonApi.Commands;
+
+public partial class CommandMetadata
+{
+    public CommandMetadata()
+    {
+
+    }
+
+    public CommandMetadata(string uniqueName)
+    {
+        UniqueName = uniqueName;
+    }
+}

+ 1144 - 0
src/PixiEditor.Extensions.CommonApi/Commands/Shortcut.Impl.cs

@@ -0,0 +1,1144 @@
+namespace PixiEditor.Extensions.CommonApi.Commands;
+
+public partial class Shortcut
+{
+    public Shortcut()
+    {
+    }
+
+    public Shortcut(Key key, KeyModifiers modifiers)
+    {
+        Key = (int)key;
+        Modifiers = (int)modifiers;
+    }
+}
+
+[Flags]
+public enum KeyModifiers
+{
+    None = 0,
+    Alt = 1,
+    Control = 2,
+    Shift = 4,
+    System = 8,
+}
+
+// Avalonia Key.cs
+public enum Key
+{
+    /// <summary>
+    /// No key pressed.
+    /// </summary>
+    None = 0,
+
+    /// <summary>
+    /// The Cancel key.
+    /// </summary>
+    Cancel = 1,
+
+    /// <summary>
+    /// The Back key.
+    /// </summary>
+    Back = 2,
+
+    /// <summary>
+    /// The Tab key.
+    /// </summary>
+    Tab = 3,
+
+    /// <summary>
+    /// The Linefeed key.
+    /// </summary>
+    LineFeed = 4,
+
+    /// <summary>
+    /// The Clear key.
+    /// </summary>
+    Clear = 5,
+
+    /// <summary>
+    /// The Return key.
+    /// </summary>
+    Return = 6,
+
+    /// <summary>
+    /// The Enter key.
+    /// </summary>
+    Enter = 6,
+
+    /// <summary>
+    /// The Pause key.
+    /// </summary>
+    Pause = 7,
+
+    /// <summary>
+    /// The Caps Lock key.
+    /// </summary>
+    CapsLock = 8,
+
+    /// <summary>
+    /// The Caps Lock key.
+    /// </summary>
+    Capital = 8,
+
+    /// <summary>
+    /// The IME Hangul mode key.
+    /// </summary>
+    HangulMode = 9,
+
+    /// <summary>
+    /// The IME Kana mode key.
+    /// </summary>
+    KanaMode = 9,
+
+    /// <summary>
+    /// The IME Junja mode key.
+    /// </summary>
+    JunjaMode = 10,
+
+    /// <summary>
+    /// The IME Final mode key.
+    /// </summary>
+    FinalMode = 11,
+
+    /// <summary>
+    /// The IME Kanji mode key.
+    /// </summary>
+    KanjiMode = 12,
+
+    /// <summary>
+    /// The IME Hanja mode key.
+    /// </summary>
+    HanjaMode = 12,
+
+    /// <summary>
+    /// The Escape key.
+    /// </summary>
+    Escape = 13,
+
+    /// <summary>
+    /// The IME Convert key.
+    /// </summary>
+    ImeConvert = 14,
+
+    /// <summary>
+    /// The IME NonConvert key.
+    /// </summary>
+    ImeNonConvert = 15,
+
+    /// <summary>
+    /// The IME Accept key.
+    /// </summary>
+    ImeAccept = 16,
+
+    /// <summary>
+    /// The IME Mode change key.
+    /// </summary>
+    ImeModeChange = 17,
+
+    /// <summary>
+    /// The space bar.
+    /// </summary>
+    Space = 18,
+
+    /// <summary>
+    /// The Page Up key.
+    /// </summary>
+    PageUp = 19,
+
+    /// <summary>
+    /// The Page Up key.
+    /// </summary>
+    Prior = 19,
+
+    /// <summary>
+    /// The Page Down key.
+    /// </summary>
+    PageDown = 20,
+
+    /// <summary>
+    /// The Page Down key.
+    /// </summary>
+    Next = 20,
+
+    /// <summary>
+    /// The End key.
+    /// </summary>
+    End = 21,
+
+    /// <summary>
+    /// The Home key.
+    /// </summary>
+    Home = 22,
+
+    /// <summary>
+    /// The Left arrow key.
+    /// </summary>
+    Left = 23,
+
+    /// <summary>
+    /// The Up arrow key.
+    /// </summary>
+    Up = 24,
+
+    /// <summary>
+    /// The Right arrow key.
+    /// </summary>
+    Right = 25,
+
+    /// <summary>
+    /// The Down arrow key.
+    /// </summary>
+    Down = 26,
+
+    /// <summary>
+    /// The Select key.
+    /// </summary>
+    Select = 27,
+
+    /// <summary>
+    /// The Print key.
+    /// </summary>
+    Print = 28,
+
+    /// <summary>
+    /// The Execute key.
+    /// </summary>
+    Execute = 29,
+
+    /// <summary>
+    /// The Print Screen key.
+    /// </summary>
+    Snapshot = 30,
+
+    /// <summary>
+    /// The Print Screen key.
+    /// </summary>
+    PrintScreen = 30,
+
+    /// <summary>
+    /// The Insert key.
+    /// </summary>
+    Insert = 31,
+
+    /// <summary>
+    /// The Delete key.
+    /// </summary>
+    Delete = 32,
+
+    /// <summary>
+    /// The Help key.
+    /// </summary>
+    Help = 33,
+
+    /// <summary>
+    /// The 0 key.
+    /// </summary>
+    D0 = 34,
+
+    /// <summary>
+    /// The 1 key.
+    /// </summary>
+    D1 = 35,
+
+    /// <summary>
+    /// The 2 key.
+    /// </summary>
+    D2 = 36,
+
+    /// <summary>
+    /// The 3 key.
+    /// </summary>
+    D3 = 37,
+
+    /// <summary>
+    /// The 4 key.
+    /// </summary>
+    D4 = 38,
+
+    /// <summary>
+    /// The 5 key.
+    /// </summary>
+    D5 = 39,
+
+    /// <summary>
+    /// The 6 key.
+    /// </summary>
+    D6 = 40,
+
+    /// <summary>
+    /// The 7 key.
+    /// </summary>
+    D7 = 41,
+
+    /// <summary>
+    /// The 8 key.
+    /// </summary>
+    D8 = 42,
+
+    /// <summary>
+    /// The 9 key.
+    /// </summary>
+    D9 = 43,
+
+    /// <summary>
+    /// The A key.
+    /// </summary>
+    A = 44,
+
+    /// <summary>
+    /// The B key.
+    /// </summary>
+    B = 45,
+
+    /// <summary>
+    /// The C key.
+    /// </summary>
+    C = 46,
+
+    /// <summary>
+    /// The D key.
+    /// </summary>
+    D = 47,
+
+    /// <summary>
+    /// The E key.
+    /// </summary>
+    E = 48,
+
+    /// <summary>
+    /// The F key.
+    /// </summary>
+    F = 49,
+
+    /// <summary>
+    /// The G key.
+    /// </summary>
+    G = 50,
+
+    /// <summary>
+    /// The H key.
+    /// </summary>
+    H = 51,
+
+    /// <summary>
+    /// The I key.
+    /// </summary>
+    I = 52,
+
+    /// <summary>
+    /// The J key.
+    /// </summary>
+    J = 53,
+
+    /// <summary>
+    /// The K key.
+    /// </summary>
+    K = 54,
+
+    /// <summary>
+    /// The L key.
+    /// </summary>
+    L = 55,
+
+    /// <summary>
+    /// The M key.
+    /// </summary>
+    M = 56,
+
+    /// <summary>
+    /// The N key.
+    /// </summary>
+    N = 57,
+
+    /// <summary>
+    /// The O key.
+    /// </summary>
+    O = 58,
+
+    /// <summary>
+    /// The P key.
+    /// </summary>
+    P = 59,
+
+    /// <summary>
+    /// The Q key.
+    /// </summary>
+    Q = 60,
+
+    /// <summary>
+    /// The R key.
+    /// </summary>
+    R = 61,
+
+    /// <summary>
+    /// The S key.
+    /// </summary>
+    S = 62,
+
+    /// <summary>
+    /// The T key.
+    /// </summary>
+    T = 63,
+
+    /// <summary>
+    /// The U key.
+    /// </summary>
+    U = 64,
+
+    /// <summary>
+    /// The V key.
+    /// </summary>
+    V = 65,
+
+    /// <summary>
+    /// The W key.
+    /// </summary>
+    W = 66,
+
+    /// <summary>
+    /// The X key.
+    /// </summary>
+    X = 67,
+
+    /// <summary>
+    /// The Y key.
+    /// </summary>
+    Y = 68,
+
+    /// <summary>
+    /// The Z key.
+    /// </summary>
+    Z = 69,
+
+    /// <summary>
+    /// The left Windows key.
+    /// </summary>
+    LWin = 70,
+
+    /// <summary>
+    /// The right Windows key.
+    /// </summary>
+    RWin = 71,
+
+    /// <summary>
+    /// The Application key.
+    /// </summary>
+    Apps = 72,
+
+    /// <summary>
+    /// The Sleep key.
+    /// </summary>
+    Sleep = 73,
+
+    /// <summary>
+    /// The 0 key on the numeric keypad.
+    /// </summary>
+    NumPad0 = 74,
+
+    /// <summary>
+    /// The 1 key on the numeric keypad.
+    /// </summary>
+    NumPad1 = 75,
+
+    /// <summary>
+    /// The 2 key on the numeric keypad.
+    /// </summary>
+    NumPad2 = 76,
+
+    /// <summary>
+    /// The 3 key on the numeric keypad.
+    /// </summary>
+    NumPad3 = 77,
+
+    /// <summary>
+    /// The 4 key on the numeric keypad.
+    /// </summary>
+    NumPad4 = 78,
+
+    /// <summary>
+    /// The 5 key on the numeric keypad.
+    /// </summary>
+    NumPad5 = 79,
+
+    /// <summary>
+    /// The 6 key on the numeric keypad.
+    /// </summary>
+    NumPad6 = 80,
+
+    /// <summary>
+    /// The 7 key on the numeric keypad.
+    /// </summary>
+    NumPad7 = 81,
+
+    /// <summary>
+    /// The 8 key on the numeric keypad.
+    /// </summary>
+    NumPad8 = 82,
+
+    /// <summary>
+    /// The 9 key on the numeric keypad.
+    /// </summary>
+    NumPad9 = 83,
+
+    /// <summary>
+    /// The Multiply key.
+    /// </summary>
+    Multiply = 84,
+
+    /// <summary>
+    /// The Add key.
+    /// </summary>
+    Add = 85,
+
+    /// <summary>
+    /// The Separator key.
+    /// </summary>
+    Separator = 86,
+
+    /// <summary>
+    /// The Subtract key.
+    /// </summary>
+    Subtract = 87,
+
+    /// <summary>
+    /// The Decimal key.
+    /// </summary>
+    Decimal = 88,
+
+    /// <summary>
+    /// The Divide key.
+    /// </summary>
+    Divide = 89,
+
+    /// <summary>
+    /// The F1 key.
+    /// </summary>
+    F1 = 90,
+
+    /// <summary>
+    /// The F2 key.
+    /// </summary>
+    F2 = 91,
+
+    /// <summary>
+    /// The F3 key.
+    /// </summary>
+    F3 = 92,
+
+    /// <summary>
+    /// The F4 key.
+    /// </summary>
+    F4 = 93,
+
+    /// <summary>
+    /// The F5 key.
+    /// </summary>
+    F5 = 94,
+
+    /// <summary>
+    /// The F6 key.
+    /// </summary>
+    F6 = 95,
+
+    /// <summary>
+    /// The F7 key.
+    /// </summary>
+    F7 = 96,
+
+    /// <summary>
+    /// The F8 key.
+    /// </summary>
+    F8 = 97,
+
+    /// <summary>
+    /// The F9 key.
+    /// </summary>
+    F9 = 98,
+
+    /// <summary>
+    /// The F10 key.
+    /// </summary>
+    F10 = 99,
+
+    /// <summary>
+    /// The F11 key.
+    /// </summary>
+    F11 = 100,
+
+    /// <summary>
+    /// The F12 key.
+    /// </summary>
+    F12 = 101,
+
+    /// <summary>
+    /// The F13 key.
+    /// </summary>
+    F13 = 102,
+
+    /// <summary>
+    /// The F14 key.
+    /// </summary>
+    F14 = 103,
+
+    /// <summary>
+    /// The F15 key.
+    /// </summary>
+    F15 = 104,
+
+    /// <summary>
+    /// The F16 key.
+    /// </summary>
+    F16 = 105,
+
+    /// <summary>
+    /// The F17 key.
+    /// </summary>
+    F17 = 106,
+
+    /// <summary>
+    /// The F18 key.
+    /// </summary>
+    F18 = 107,
+
+    /// <summary>
+    /// The F19 key.
+    /// </summary>
+    F19 = 108,
+
+    /// <summary>
+    /// The F20 key.
+    /// </summary>
+    F20 = 109,
+
+    /// <summary>
+    /// The F21 key.
+    /// </summary>
+    F21 = 110,
+
+    /// <summary>
+    /// The F22 key.
+    /// </summary>
+    F22 = 111,
+
+    /// <summary>
+    /// The F23 key.
+    /// </summary>
+    F23 = 112,
+
+    /// <summary>
+    /// The F24 key.
+    /// </summary>
+    F24 = 113,
+
+    /// <summary>
+    /// The Numlock key.
+    /// </summary>
+    NumLock = 114,
+
+    /// <summary>
+    /// The Scroll key.
+    /// </summary>
+    Scroll = 115,
+
+    /// <summary>
+    /// The left Shift key.
+    /// </summary>
+    LeftShift = 116,
+
+    /// <summary>
+    /// The right Shift key.
+    /// </summary>
+    RightShift = 117,
+
+    /// <summary>
+    /// The left Ctrl key.
+    /// </summary>
+    LeftCtrl = 118,
+
+    /// <summary>
+    /// The right Ctrl key.
+    /// </summary>
+    RightCtrl = 119,
+
+    /// <summary>
+    /// The left Alt key.
+    /// </summary>
+    LeftAlt = 120,
+
+    /// <summary>
+    /// The right Alt key.
+    /// </summary>
+    RightAlt = 121,
+
+    /// <summary>
+    /// The browser Back key.
+    /// </summary>
+    BrowserBack = 122,
+
+    /// <summary>
+    /// The browser Forward key.
+    /// </summary>
+    BrowserForward = 123,
+
+    /// <summary>
+    /// The browser Refresh key.
+    /// </summary>
+    BrowserRefresh = 124,
+
+    /// <summary>
+    /// The browser Stop key.
+    /// </summary>
+    BrowserStop = 125,
+
+    /// <summary>
+    /// The browser Search key.
+    /// </summary>
+    BrowserSearch = 126,
+
+    /// <summary>
+    /// The browser Favorites key.
+    /// </summary>
+    BrowserFavorites = 127,
+
+    /// <summary>
+    /// The browser Home key.
+    /// </summary>
+    BrowserHome = 128,
+
+    /// <summary>
+    /// The Volume Mute key.
+    /// </summary>
+    VolumeMute = 129,
+
+    /// <summary>
+    /// The Volume Down key.
+    /// </summary>
+    VolumeDown = 130,
+
+    /// <summary>
+    /// The Volume Up key.
+    /// </summary>
+    VolumeUp = 131,
+
+    /// <summary>
+    /// The media Next Track key.
+    /// </summary>
+    MediaNextTrack = 132,
+
+    /// <summary>
+    /// The media Previous Track key.
+    /// </summary>
+    MediaPreviousTrack = 133,
+
+    /// <summary>
+    /// The media Stop key.
+    /// </summary>
+    MediaStop = 134,
+
+    /// <summary>
+    /// The media Play/Pause key.
+    /// </summary>
+    MediaPlayPause = 135,
+
+    /// <summary>
+    /// The Launch Mail key.
+    /// </summary>
+    LaunchMail = 136,
+
+    /// <summary>
+    /// The Select Media key.
+    /// </summary>
+    SelectMedia = 137,
+
+    /// <summary>
+    /// The Launch Application 1 key.
+    /// </summary>
+    LaunchApplication1 = 138,
+
+    /// <summary>
+    /// The Launch Application 2 key.
+    /// </summary>
+    LaunchApplication2 = 139,
+
+    /// <summary>
+    /// The OEM Semicolon key.
+    /// </summary>
+    OemSemicolon = 140,
+
+    /// <summary>
+    /// The OEM 1 key.
+    /// </summary>
+    Oem1 = 140,
+
+    /// <summary>
+    /// The OEM Plus key.
+    /// </summary>
+    OemPlus = 141,
+
+    /// <summary>
+    /// The OEM Comma key.
+    /// </summary>
+    OemComma = 142,
+
+    /// <summary>
+    /// The OEM Minus key.
+    /// </summary>
+    OemMinus = 143,
+
+    /// <summary>
+    /// The OEM Period key.
+    /// </summary>
+    OemPeriod = 144,
+
+    /// <summary>
+    /// The OEM Question Mark key.
+    /// </summary>
+    OemQuestion = 145,
+
+    /// <summary>
+    /// The OEM 2 key.
+    /// </summary>
+    Oem2 = 145,
+
+    /// <summary>
+    /// The OEM Tilde key.
+    /// </summary>
+    OemTilde = 146,
+
+    /// <summary>
+    /// The OEM 3 key.
+    /// </summary>
+    Oem3 = 146,
+
+    /// <summary>
+    /// The ABNT_C1 (Brazilian) key.
+    /// </summary>
+    AbntC1 = 147,
+
+    /// <summary>
+    /// The ABNT_C2 (Brazilian) key.
+    /// </summary>
+    AbntC2 = 148,
+
+    /// <summary>
+    /// The OEM Open Brackets key.
+    /// </summary>
+    OemOpenBrackets = 149,
+
+    /// <summary>
+    /// The OEM 4 key.
+    /// </summary>
+    Oem4 = 149,
+
+    /// <summary>
+    /// The OEM Pipe key.
+    /// </summary>
+    OemPipe = 150,
+
+    /// <summary>
+    /// The OEM 5 key.
+    /// </summary>
+    Oem5 = 150,
+
+    /// <summary>
+    /// The OEM Close Brackets key.
+    /// </summary>
+    OemCloseBrackets = 151,
+
+    /// <summary>
+    /// The OEM 6 key.
+    /// </summary>
+    Oem6 = 151,
+
+    /// <summary>
+    /// The OEM Quotes key.
+    /// </summary>
+    OemQuotes = 152,
+
+    /// <summary>
+    /// The OEM 7 key.
+    /// </summary>
+    Oem7 = 152,
+
+    /// <summary>
+    /// The OEM 8 key.
+    /// </summary>
+    Oem8 = 153,
+
+    /// <summary>
+    /// The OEM Backslash key.
+    /// </summary>
+    OemBackslash = 154,
+
+    /// <summary>
+    /// The OEM 3 key.
+    /// </summary>
+    Oem102 = 154,
+
+    /// <summary>
+    /// A special key masking the real key being processed by an IME.
+    /// </summary>
+    ImeProcessed = 155,
+
+    /// <summary>
+    /// A special key masking the real key being processed as a system key.
+    /// </summary>
+    System = 156,
+
+    /// <summary>
+    /// The OEM ATTN key.
+    /// </summary>
+    OemAttn = 157,
+
+    /// <summary>
+    /// The DBE_ALPHANUMERIC key.
+    /// </summary>
+    DbeAlphanumeric = 157,
+
+    /// <summary>
+    /// The OEM Finish key.
+    /// </summary>
+    OemFinish = 158,
+
+    /// <summary>
+    /// The DBE_KATAKANA key.
+    /// </summary>
+    DbeKatakana = 158,
+
+    /// <summary>
+    /// The DBE_HIRAGANA key.
+    /// </summary>
+    DbeHiragana = 159,
+
+    /// <summary>
+    /// The OEM Copy key.
+    /// </summary>
+    OemCopy = 159,
+
+    /// <summary>
+    /// The DBE_SBCSCHAR key.
+    /// </summary>
+    DbeSbcsChar = 160,
+
+    /// <summary>
+    /// The OEM Auto key.
+    /// </summary>
+    OemAuto = 160,
+
+    /// <summary>
+    /// The DBE_DBCSCHAR key.
+    /// </summary>
+    DbeDbcsChar = 161,
+
+    /// <summary>
+    /// The OEM ENLW key.
+    /// </summary>
+    OemEnlw = 161,
+
+    /// <summary>
+    /// The OEM BackTab key.
+    /// </summary>
+    OemBackTab = 162,
+
+    /// <summary>
+    /// The DBE_ROMAN key.
+    /// </summary>
+    DbeRoman = 162,
+
+    /// <summary>
+    /// The DBE_NOROMAN key.
+    /// </summary>
+    DbeNoRoman = 163,
+
+    /// <summary>
+    /// The ATTN key.
+    /// </summary>
+    Attn = 163,
+
+    /// <summary>
+    /// The CRSEL key.
+    /// </summary>
+    CrSel = 164,
+
+    /// <summary>
+    /// The DBE_ENTERWORDREGISTERMODE key.
+    /// </summary>
+    DbeEnterWordRegisterMode = 164,
+
+    /// <summary>
+    /// The EXSEL key.
+    /// </summary>
+    ExSel = 165,
+
+    /// <summary>
+    /// The DBE_ENTERIMECONFIGMODE key.
+    /// </summary>
+    DbeEnterImeConfigureMode = 165,
+
+    /// <summary>
+    /// The ERASE EOF Key.
+    /// </summary>
+    EraseEof = 166,
+
+    /// <summary>
+    /// The DBE_FLUSHSTRING key.
+    /// </summary>
+    DbeFlushString = 166,
+
+    /// <summary>
+    /// The Play key.
+    /// </summary>
+    Play = 167,
+
+    /// <summary>
+    /// The DBE_CODEINPUT key.
+    /// </summary>
+    DbeCodeInput = 167,
+
+    /// <summary>
+    /// The DBE_NOCODEINPUT key.
+    /// </summary>
+    DbeNoCodeInput = 168,
+
+    /// <summary>
+    /// The Zoom key.
+    /// </summary>
+    Zoom = 168,
+
+    /// <summary>
+    /// Reserved for future use.
+    /// </summary>
+    NoName = 169,
+
+    /// <summary>
+    /// The DBE_DETERMINESTRING key.
+    /// </summary>
+    DbeDetermineString = 169,
+
+    /// <summary>
+    /// The DBE_ENTERDLGCONVERSIONMODE key.
+    /// </summary>
+    DbeEnterDialogConversionMode = 170,
+
+    /// <summary>
+    /// The PA1 key.
+    /// </summary>
+    Pa1 = 170,
+
+    /// <summary>
+    /// The OEM Clear key.
+    /// </summary>
+    OemClear = 171,
+
+    /// <summary>
+    /// The key is used with another key to create a single combined character.
+    /// </summary>
+    DeadCharProcessed = 172,
+
+
+    /// <summary>
+    /// OSX Platform-specific Fn+Left key
+    /// </summary>
+    FnLeftArrow = 10001,
+
+    /// <summary>
+    /// OSX Platform-specific Fn+Right key
+    /// </summary>
+    FnRightArrow = 10002,
+
+    /// <summary>
+    /// OSX Platform-specific Fn+Up key
+    /// </summary>
+    FnUpArrow = 10003,
+
+    /// <summary>
+    /// OSX Platform-specific Fn+Down key
+    /// </summary>
+    FnDownArrow = 10004,
+
+    /// <summary>
+    /// Remove control home button
+    /// </summary>
+    MediaHome = 100000,
+
+    /// <summary>
+    /// TV Channel up
+    /// </summary>
+    MediaChannelList = 100001,
+
+    /// <summary>
+    /// TV Channel up
+    /// </summary>
+    MediaChannelRaise = 100002,
+
+    /// <summary>
+    /// TV Channel down
+    /// </summary>
+    MediaChannelLower = 100003,
+
+    /// <summary>
+    /// TV Channel down
+    /// </summary>
+    MediaRecord = 100005,
+
+    /// <summary>
+    /// Remote control Red button
+    /// </summary>
+    MediaRed = 100010,
+
+    /// <summary>
+    /// Remote control Green button
+    /// </summary>
+    MediaGreen = 100011,
+
+    /// <summary>
+    /// Remote control Yellow button
+    /// </summary>
+    MediaYellow = 100012,
+
+    /// <summary>
+    /// Remote control Blue button
+    /// </summary>
+    MediaBlue = 100013,
+
+    /// <summary>
+    /// Remote control Menu button
+    /// </summary>
+    MediaMenu = 100020,
+
+    /// <summary>
+    /// Remote control dots button
+    /// </summary>
+    MediaMore = 100021,
+
+    /// <summary>
+    /// Remote control option button
+    /// </summary>
+    MediaOption = 100022,
+
+    /// <summary>
+    /// Remote control channel info button
+    /// </summary>
+    MediaInfo = 100023,
+
+    /// <summary>
+    /// Remote control search button
+    /// </summary>
+    MediaSearch = 100024,
+
+    /// <summary>
+    /// Remote control subtitle/caption button
+    /// </summary>
+    MediaSubtitle = 100025,
+
+    /// <summary>
+    /// Remote control Tv guide detail button
+    /// </summary>
+    MediaTvGuide = 100026,
+
+    /// <summary>
+    /// Remote control Previous Channel
+    /// </summary>
+    MediaPreviousChannel = 100027,
+}

+ 17 - 0
src/PixiEditor.Extensions.CommonApi/DataContracts/CommandMetadata.proto

@@ -0,0 +1,17 @@
+syntax = "proto3";
+package PixiEditor.Commands;
+
+import "Shortcut.proto";
+option csharp_namespace = "PixiEditor.Extensions.CommonApi.Commands";
+
+message CommandMetadata
+{
+  string UniqueName = 1;
+  string DisplayName = 2;
+  string Description = 3;
+  Shortcut Shortcut = 4;
+  string Icon = 5;
+  string MenuItemPath = 6;
+  int32 Order = 7;
+
+}

+ 10 - 0
src/PixiEditor.Extensions.CommonApi/DataContracts/Shortcut.proto

@@ -0,0 +1,10 @@
+syntax = "proto3";
+package PixiEditor.Commands;
+
+option csharp_namespace = "PixiEditor.Extensions.CommonApi.Commands";
+
+message Shortcut
+{
+   int32 Key = 1;
+   int32 Modifiers = 2;
+}

+ 8 - 0
src/PixiEditor.Extensions.CommonApi/Menu/ICommandProvider.cs

@@ -0,0 +1,8 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+
+namespace PixiEditor.Extensions.CommonApi.Menu;
+
+public interface ICommandProvider
+{
+    public void RegisterCommand(CommandMetadata command, Action execute, Func<bool>? canExecute = null);
+}

+ 50 - 0
src/PixiEditor.Extensions.CommonApi/ProtoAutogen/CommandMetadata.cs

@@ -0,0 +1,50 @@
+// <auto-generated>
+//   This file was generated by a tool; you should avoid making direct changes.
+//   Consider using 'partial classes' to extend these types
+//   Input: CommandMetadata.proto
+// </auto-generated>
+
+#region Designer generated code
+#pragma warning disable CS0612, CS0618, CS1591, CS3021, CS8981, IDE0079, IDE1006, RCS1036, RCS1057, RCS1085, RCS1192
+namespace PixiEditor.Extensions.CommonApi.Commands
+{
+
+    [global::ProtoBuf.ProtoContract()]
+    public partial class CommandMetadata : global::ProtoBuf.IExtensible
+    {
+        private global::ProtoBuf.IExtension __pbn__extensionData;
+        global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing)
+            => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing);
+
+        [global::ProtoBuf.ProtoMember(1)]
+        [global::System.ComponentModel.DefaultValue("")]
+        public string UniqueName { get; set; } = "";
+
+        [global::ProtoBuf.ProtoMember(2)]
+        [global::System.ComponentModel.DefaultValue("")]
+        public string DisplayName { get; set; } = "";
+
+        [global::ProtoBuf.ProtoMember(3)]
+        [global::System.ComponentModel.DefaultValue("")]
+        public string Description { get; set; } = "";
+
+        [global::ProtoBuf.ProtoMember(4)]
+        public Shortcut Shortcut { get; set; }
+
+        [global::ProtoBuf.ProtoMember(5)]
+        [global::System.ComponentModel.DefaultValue("")]
+        public string Icon { get; set; } = "";
+
+        [global::ProtoBuf.ProtoMember(6)]
+        [global::System.ComponentModel.DefaultValue("")]
+        public string MenuItemPath { get; set; } = "";
+
+        [global::ProtoBuf.ProtoMember(7)]
+        public int Order { get; set; }
+
+    }
+
+}
+
+#pragma warning restore CS0612, CS0618, CS1591, CS3021, CS8981, IDE0079, IDE1006, RCS1036, RCS1057, RCS1085, RCS1192
+#endregion

+ 30 - 0
src/PixiEditor.Extensions.CommonApi/ProtoAutogen/Shortcut.cs

@@ -0,0 +1,30 @@
+// <auto-generated>
+//   This file was generated by a tool; you should avoid making direct changes.
+//   Consider using 'partial classes' to extend these types
+//   Input: Shortcut.proto
+// </auto-generated>
+
+#region Designer generated code
+#pragma warning disable CS0612, CS0618, CS1591, CS3021, CS8981, IDE0079, IDE1006, RCS1036, RCS1057, RCS1085, RCS1192
+namespace PixiEditor.Extensions.CommonApi.Commands
+{
+
+    [global::ProtoBuf.ProtoContract()]
+    public partial class Shortcut : global::ProtoBuf.IExtensible
+    {
+        private global::ProtoBuf.IExtension __pbn__extensionData;
+        global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing)
+            => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing);
+
+        [global::ProtoBuf.ProtoMember(1)]
+        public int Key { get; set; }
+
+        [global::ProtoBuf.ProtoMember(2)]
+        public int Modifiers { get; set; }
+
+    }
+
+}
+
+#pragma warning restore CS0612, CS0618, CS1591, CS3021, CS8981, IDE0079, IDE1006, RCS1036, RCS1057, RCS1085, RCS1192
+#endregion

+ 11 - 1
src/PixiEditor.Extensions.CommonApi/Utilities/PrefixedNameUtility.cs

@@ -19,7 +19,7 @@ public static class PrefixedNameUtility
         {
             finalName = name;
             
-            if(splitted[0].Equals("pixieditor", StringComparison.CurrentCultureIgnoreCase)) 
+            if(splitted[0].Equals("pixieditor", StringComparison.CurrentCultureIgnoreCase))
             {
                 finalName = splitted[1];
             }
@@ -49,4 +49,14 @@ public static class PrefixedNameUtility
 
         return preferenceName;
     }
+
+    public static string ToCommandUniqueName(string extensionUniqueName, string metadataUniqueName)
+    {
+        if (metadataUniqueName.StartsWith(extensionUniqueName))
+        {
+            return metadataUniqueName;
+        }
+
+        return $"{extensionUniqueName}:{metadataUniqueName}";
+    }
 }

+ 41 - 0
src/PixiEditor.Extensions.Sdk/Api/Commands/CommandProvider.cs

@@ -0,0 +1,41 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+using PixiEditor.Extensions.CommonApi.Menu;
+using PixiEditor.Extensions.Sdk.Bridge;
+
+namespace PixiEditor.Extensions.Sdk.Api.Commands;
+
+public class CommandProvider : ICommandProvider
+{
+    private Dictionary<string, (Action execute, Func<bool> canExecute)> _commands = new();
+
+    public CommandProvider()
+    {
+        Interop.CommandInvoked += OnCommandInvoked;
+    }
+
+    public void RegisterCommand(CommandMetadata command, Action execute, Func<bool> canExecute = null)
+    {
+        if (string.IsNullOrEmpty(command.UniqueName))
+        {
+            throw new ArgumentException("Command unique name cannot be null or empty.");
+        }
+
+        if (_commands.ContainsKey(command.UniqueName))
+            throw new ArgumentException($"Command with unique name {command.UniqueName} is already registered.");
+
+        _commands.Add(command.UniqueName, (execute, canExecute));
+
+        Interop.RegisterCommand(command);
+    }
+
+    private void OnCommandInvoked(string uniqueName)
+    {
+        if (_commands.TryGetValue(uniqueName, out var command))
+        {
+            if (command.canExecute == null || command.canExecute())
+            {
+                command.execute();
+            }
+        }
+    }
+}

+ 23 - 0
src/PixiEditor.Extensions.Sdk/Bridge/Interop.Commands.cs

@@ -0,0 +1,23 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+using PixiEditor.Extensions.CommonApi.Utilities;
+using PixiEditor.Extensions.Sdk.Utilities;
+using ProtoBuf;
+
+namespace PixiEditor.Extensions.Sdk.Bridge;
+
+internal static partial class Interop
+{
+    public static event Action<string> CommandInvoked;
+    public static void RegisterCommand(CommandMetadata command)
+    {
+        using MemoryStream stream = new();
+        Serializer.Serialize(stream, command);
+        byte[] bytes = stream.ToArray();
+        Native.register_command(InteropUtility.ByteArrayToIntPtr(bytes), bytes.Length);
+    }
+
+    internal static void OnCommandInvoked(string uniqueName)
+    {
+        CommandInvoked?.Invoke(uniqueName);
+    }
+}

+ 0 - 6
src/PixiEditor.Extensions.Sdk/Bridge/Interop.Preferences.cs

@@ -5,12 +5,6 @@ internal static partial class Interop
     private static Dictionary<string, List<Action<string, object>>> _callbacks = new();
     private static string uniqueName;
 
-    static Interop()
-    {
-        uniqueName = Native.get_extension_unique_name();
-        Native.PreferenceUpdated += NativeOnPreferenceUpdated;
-    }
-
     private static void NativeOnPreferenceUpdated(string name, object value)
     {
         if (_callbacks.TryGetValue(name, out var actions))

+ 7 - 0
src/PixiEditor.Extensions.Sdk/Bridge/Interop.cs

@@ -4,6 +4,13 @@ namespace PixiEditor.Extensions.Sdk.Bridge;
 
 internal static partial class Interop
 {
+    static Interop()
+    {
+        uniqueName = Native.get_extension_unique_name();
+        Native.PreferenceUpdated += NativeOnPreferenceUpdated;
+        Native.CommandInvoked += OnCommandInvoked;
+    }
+
     public static void UpdateUserPreference<T>(string name, T value)
     {
         switch (value)

+ 17 - 0
src/PixiEditor.Extensions.Sdk/Bridge/Native.Commands.cs

@@ -0,0 +1,17 @@
+using System.Runtime.CompilerServices;
+
+namespace PixiEditor.Extensions.Sdk.Bridge;
+
+internal static partial class Native
+{
+    public static event Action<string> CommandInvoked;
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    internal static extern void register_command(IntPtr metadataPtr, int length);
+
+    [ApiExport("command_invoked")]
+    internal static void OnCommandInvoked(string uniqueName)
+    {
+        CommandInvoked?.Invoke(uniqueName);
+    }
+}

+ 3 - 0
src/PixiEditor.Extensions.Sdk/PixiEditorApi.cs

@@ -1,5 +1,6 @@
 using PixiEditor.Extensions.CommonApi.Windowing;
 using PixiEditor.Extensions.Sdk.Api;
+using PixiEditor.Extensions.Sdk.Api.Commands;
 using PixiEditor.Extensions.Sdk.Api.Logging;
 using PixiEditor.Extensions.Sdk.Api.Palettes;
 using PixiEditor.Extensions.Sdk.Api.UserPreferences;
@@ -13,6 +14,7 @@ public class PixiEditorApi
     public WindowProvider WindowProvider { get; }
     public Preferences Preferences { get; }
     public PalettesProvider Palettes { get; }
+    public CommandProvider Commands { get; }
 
     public PixiEditorApi()
     {
@@ -20,5 +22,6 @@ public class PixiEditorApi
         WindowProvider = new WindowProvider();
         Preferences = new Preferences();
         Palettes = new PalettesProvider();
+        Commands = new CommandProvider();
     }
 }

BIN
src/PixiEditor.Extensions.Sdk/build/PixiEditor.Api.CGlueMSBuild.dll


BIN
src/PixiEditor.Extensions.Sdk/build/PixiEditor.Extensions.MSPackageBuilder.dll


+ 31 - 0
src/PixiEditor.Extensions.WasmRuntime/Api/CommandApi.cs

@@ -0,0 +1,31 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+using PixiEditor.Extensions.CommonApi.Utilities;
+using PixiEditor.Extensions.WasmRuntime.Api.Modules;
+using ProtoBuf;
+
+namespace PixiEditor.Extensions.WasmRuntime.Api;
+
+internal class CommandApi : ApiGroupHandler
+{
+    [ApiFunction("register_command")]
+    internal void RegisterCommand(Span<byte> commandMetadata)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+
+        using MemoryStream stream = new();
+        stream.Write(commandMetadata);
+        stream.Seek(0, SeekOrigin.Begin);
+        CommandMetadata metadata = Serializer.Deserialize<CommandMetadata>(stream);
+
+        string originalName = metadata.UniqueName;
+
+        void InvokeCommandInvoked()
+        {
+            commandModule.InvokeCommandInvoked(originalName);
+        }
+
+        string prefixed = PrefixedNameUtility.ToCommandUniqueName(Extension.Metadata.UniqueName, metadata.UniqueName);
+        metadata.UniqueName = prefixed;
+        Api.Commands.RegisterCommand(metadata, InvokeCommandInvoked);
+    }
+}

+ 21 - 0
src/PixiEditor.Extensions.WasmRuntime/Api/Modules/CommandModule.cs

@@ -0,0 +1,21 @@
+using PixiEditor.Extensions.CommonApi.Menu;
+
+namespace PixiEditor.Extensions.WasmRuntime.Api.Modules;
+
+internal class CommandModule : ApiModule
+{
+    public ICommandProvider CommandProvider { get; }
+
+    public CommandModule(WasmExtensionInstance extension, ICommandProvider commandProvider) : base(extension)
+    {
+        CommandProvider = commandProvider;
+    }
+
+    internal void InvokeCommandInvoked(string uniqueName)
+    {
+        var action = Extension.Instance.GetAction<int>("command_invoked");
+
+        var pathPtr = Extension.WasmMemoryUtility.WriteString(uniqueName);
+        action?.Invoke(pathPtr);
+    }
+}

+ 1 - 0
src/PixiEditor.Extensions.WasmRuntime/WasmExtensionInstance.cs

@@ -65,6 +65,7 @@ public partial class WasmExtensionInstance : Extension
     protected override void OnInitialized()
     {
         modules.Add(new PreferencesModule(this, Api.Preferences));
+        modules.Add(new CommandModule(this, Api.Commands));
         LayoutBuilder = new LayoutBuilder((ElementMap)Api.Services.GetService(typeof(ElementMap)));
 
         //SetElementMap();

+ 2 - 0
src/PixiEditor.Extensions/ExtensionServices.cs

@@ -1,4 +1,5 @@
 using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Extensions.CommonApi.Menu;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Extensions.CommonApi.Windowing;
@@ -12,6 +13,7 @@ public class ExtensionServices
     public IWindowProvider? Windowing => Services.GetService<IWindowProvider>();
     public IFileSystemProvider? FileSystem => Services.GetService<IFileSystemProvider>();
     public IPreferences? Preferences => Services.GetService<IPreferences>();
+    public ICommandProvider? Commands => Services.GetService<ICommandProvider>();
     
     public IPalettesProvider? Palettes => Services.GetService<IPalettesProvider>();
 

+ 5 - 1
src/PixiEditor/Helpers/ServiceCollectionHelpers.cs

@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
 using PixiEditor.AnimationRenderer.Core;
 using PixiEditor.AnimationRenderer.FFmpeg;
 using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.CommonApi.Menu;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using PixiEditor.Extensions.CommonApi.Palettes.Parsers;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
@@ -120,8 +121,11 @@ internal static class ServiceCollectionHelpers
             // Custom document builders
             .AddSingleton<IDocumentBuilder, SvgDocumentBuilder>()
             .AddSingleton<IDocumentBuilder, FontDocumentBuilder>()
-            // Palette Parsers
             .AddSingleton<IPalettesProvider, PaletteProvider>()
+            .AddSingleton<CommandProvider>()
+            .AddSingleton<ICommandProvider, CommandProvider>(x => x.GetRequiredService<CommandProvider>())
+            .AddSingleton<IIconLookupProvider, DynamicResourceIconLookupProvider>()
+            // Palette Parsers
             .AddSingleton<PaletteFileParser, JascFileParser>()
             .AddSingleton<PaletteFileParser, ClsFileParser>()
             .AddSingleton<PaletteFileParser, DeluxePaintParser>()

+ 3 - 0
src/PixiEditor/Models/Commands/CommandCollection.cs

@@ -25,6 +25,8 @@ internal class CommandCollection : ICollection<Commands.Command>
 
     public List<Command> this[KeyCombination shortcut] => _commandShortcuts[shortcut];
 
+    public event EventHandler<Command>? CommandAdded;
+
     public CommandCollection()
     {
         commandInternalNames = new();
@@ -35,6 +37,7 @@ internal class CommandCollection : ICollection<Commands.Command>
     {
         commandInternalNames.Add(item.InternalName, item);
         _commandShortcuts.Add(item.Shortcut, item);
+        CommandAdded?.Invoke(this, item);
     }
 
     public void Clear()

+ 49 - 33
src/PixiEditor/Models/Commands/CommandController.cs

@@ -49,6 +49,8 @@ internal class CommandController
 
     private static readonly List<Command> objectsToInvokeOn = new();
 
+    private ShortcutsTemplate lastTemplate;
+
     public CommandController()
     {
         Current ??= this;
@@ -120,16 +122,15 @@ internal class CommandController
 
     public void Init(IServiceProvider serviceProvider)
     {
-        ShortcutsTemplate template = new();
         try
         {
-            template = shortcutFile.LoadTemplate();
+            lastTemplate = shortcutFile.LoadTemplate();
         }
         catch (JsonException)
         {
-            File.Move(shortcutFile.Path, $"{shortcutFile.Path}.corrupted", true); // TODO: platform dependent
+            File.Move(shortcutFile.Path, $"{shortcutFile.Path}.corrupted", true);
             shortcutFile = new ShortcutFile(ShortcutsPath, this);
-            template = shortcutFile.LoadTemplate();
+            lastTemplate = shortcutFile.LoadTemplate();
             NoticeDialog.Show("SHORTCUTS_CORRUPTED", "SHORTCUTS_CORRUPTED_TITLE");
         }
 
@@ -140,8 +141,8 @@ internal class CommandController
             commands = new(); // internal name of the corr. group -> command in that group
 
         LoadEvaluators(serviceProvider, compiledCommandList);
-        LoadCommands(serviceProvider, compiledCommandList, commandGroupsData, commands, template);
-        LoadTools(serviceProvider, commandGroupsData, commands, template);
+        LoadCommands(serviceProvider, compiledCommandList, commandGroupsData, commands, lastTemplate);
+        LoadTools(serviceProvider, commandGroupsData, commands, lastTemplate);
 
         var miscList = new List<Command>();
 
@@ -162,6 +163,15 @@ internal class CommandController
         }
 
         CommandGroups.Add(new CommandGroup("MISC", miscList));
+
+        Commands.CommandAdded += CommandsOnCommandAdded;
+    }
+
+    private void CommandsOnCommandAdded(object? sender, Command e)
+    {
+        var group = CommandGroups.Last();
+
+        group.AddCommand(e);
     }
 
     public static void ListenForCanExecuteChanged(Command command)
@@ -399,33 +409,6 @@ internal class CommandController
             void CommandAction(object x) =>
                 CommandMethodInvoker(method, name, instance, x, parameters, attribute.AnalyticsTrack);
         }
-
-        KeyCombination AdjustForOS(KeyCombination combination, CustomOsShortcutAttribute? customOsShortcut)
-        {
-            if (customOsShortcut != null)
-            {
-                return new KeyCombination(customOsShortcut.Key, customOsShortcut.Modifiers);
-            }
-
-            if (IOperatingSystem.Current.IsMacOs)
-            {
-                KeyCombination newCombination = combination;
-                if (combination.Modifiers.HasFlag(KeyModifiers.Control))
-                {
-                    newCombination.Modifiers &= ~KeyModifiers.Control;
-                    newCombination.Modifiers |= KeyModifiers.Meta;
-                }
-
-                if (combination.Key == Key.Delete)
-                {
-                    newCombination.Key = Key.Back;
-                }
-
-                return newCombination;
-            }
-
-            return combination;
-        }
     }
 
     private static void CommandMethodInvoker(MethodInfo method, string name, object? instance, object parameter,
@@ -703,4 +686,37 @@ internal class CommandController
             command.OnCanExecuteChanged();
         }
     }
+
+    public void AddManagedCommand(Command command)
+    {
+        command.Shortcut = GetShortcut(command.InternalName, AdjustForOS(command.DefaultShortcut, null), lastTemplate);
+        Commands.Add(command);
+    }
+
+    private KeyCombination AdjustForOS(KeyCombination combination, CustomOsShortcutAttribute? customOsShortcut)
+    {
+        if (customOsShortcut != null)
+        {
+            return new KeyCombination(customOsShortcut.Key, customOsShortcut.Modifiers);
+        }
+
+        if (IOperatingSystem.Current.IsMacOs)
+        {
+            KeyCombination newCombination = combination;
+            if (combination.Modifiers.HasFlag(KeyModifiers.Control))
+            {
+                newCombination.Modifiers &= ~KeyModifiers.Control;
+                newCombination.Modifiers |= KeyModifiers.Meta;
+            }
+
+            if (combination.Key == Key.Delete)
+            {
+                newCombination.Key = Key.Back;
+            }
+
+            return newCombination;
+        }
+
+        return combination;
+    }
 }

+ 20 - 6
src/PixiEditor/Models/Commands/CommandGroup.cs

@@ -11,8 +11,8 @@ namespace PixiEditor.Models.Commands;
 
 internal class CommandGroup : ObservableObject
 {
-    private readonly Command[] commands;
-    private readonly Command[] visibleCommands;
+    private List<Command> commands;
+    private List<Command> visibleCommands;
 
     private LocalizedString displayName;
 
@@ -26,15 +26,15 @@ internal class CommandGroup : ObservableObject
 
     public string? IsVisibleProperty { get; set; }
 
-    public IEnumerable<Command> Commands => commands;
+    public IReadOnlyList<Command> Commands => commands;
 
-    public IEnumerable<Command> VisibleCommands => visibleCommands;
+    public IReadOnlyList<Command> VisibleCommands => visibleCommands;
 
     public CommandGroup(LocalizedString displayName, IEnumerable<Command> commands)
     {
         DisplayName = displayName;
-        this.commands = commands.ToArray();
-        visibleCommands = commands.Where(x => !string.IsNullOrEmpty(x.DisplayName.Value)).ToArray();
+        this.commands = commands.ToList();
+        visibleCommands = commands.Where(x => !string.IsNullOrEmpty(x.DisplayName.Value)).ToList();
 
         foreach (var command in commands)
         {
@@ -45,6 +45,20 @@ internal class CommandGroup : ObservableObject
         ILocalizationProvider.Current.OnLanguageChanged += OnLanguageChanged;
     }
 
+    public void AddCommand(Command command)
+    {
+        command.ShortcutChanged += Command_ShortcutChanged;
+        HasAssignedShortcuts |= command.Shortcut.Key != Key.None;
+        commands.Add(command);
+        if (!string.IsNullOrEmpty(command.DisplayName.Value))
+        {
+            visibleCommands.Add(command);
+        }
+
+        OnPropertyChanged(nameof(VisibleCommands));
+        OnPropertyChanged(nameof(Commands));
+    }
+
     private void OnLanguageChanged(Language obj)
     {
         DisplayName = new LocalizedString(DisplayName.Key);

+ 67 - 0
src/PixiEditor/Models/ExtensionServices/CommandProvider.cs

@@ -0,0 +1,67 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+using PixiEditor.Extensions.CommonApi.Menu;
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Commands;
+using PixiEditor.Models.Commands.Evaluators;
+using PixiEditor.Models.Input;
+using Key = Avalonia.Input.Key;
+using KeyModifiers = Avalonia.Input.KeyModifiers;
+using Shortcut = PixiEditor.Extensions.CommonApi.Commands.Shortcut;
+
+namespace PixiEditor.Models.ExtensionServices;
+
+public class CommandProvider : ICommandProvider
+{
+    private IIconLookupProvider _iconLookupProvider;
+
+    public CommandProvider(IIconLookupProvider iconLookupProvider)
+    {
+        _iconLookupProvider = iconLookupProvider;
+    }
+    public void RegisterCommand(CommandMetadata command, Action execute, Func<bool>? canExecute = null)
+    {
+        CanExecuteEvaluator evaluator = CanExecuteEvaluator.AlwaysTrue;
+
+        if (canExecute != null)
+        {
+            evaluator = new CanExecuteEvaluator
+            {
+                Evaluate = _ => canExecute(),
+                Name = $"{command.UniqueName}._canExecute"
+            };
+
+            CommandController.Current.CanExecuteEvaluators[evaluator.Name] = evaluator;
+        }
+
+        var shortcut = ToKeyCombination(command.Shortcut);
+        Command.BasicCommand basicCommand = new Command.BasicCommand(_ => execute(), evaluator)
+        {
+            InternalName = command.UniqueName,
+            MenuItemPath = command.MenuItemPath,
+            Icon = LookupIcon(command.Icon),
+            DisplayName = command.DisplayName,
+            Description = command.Description,
+            MenuItemOrder = command.Order,
+            DefaultShortcut = shortcut,
+            IconEvaluator = IconEvaluator.Default
+        };
+
+        CommandController.Current.AddManagedCommand(basicCommand);
+    }
+
+    private static KeyCombination ToKeyCombination(Shortcut shortcut)
+    {
+        if (shortcut is { Key: 0, Modifiers: 0 })
+            return KeyCombination.None;
+
+        return new KeyCombination((Key)shortcut.Key, (KeyModifiers)shortcut.Modifiers);
+    }
+
+    private string LookupIcon(string icon)
+    {
+        if (string.IsNullOrEmpty(icon))
+            return string.Empty;
+
+        return _iconLookupProvider.LookupIcon(icon) ?? icon;
+    }
+}

+ 11 - 0
src/PixiEditor/Models/ExtensionServices/DynamicResourceIconLookupProvider.cs

@@ -0,0 +1,11 @@
+using PixiEditor.Helpers;
+
+namespace PixiEditor.Models.ExtensionServices;
+
+internal class DynamicResourceIconLookupProvider : IIconLookupProvider
+{
+    public string? LookupIcon(string iconName)
+    {
+        return ResourceLoader.GetResource<string>(iconName);
+    }
+}

+ 6 - 0
src/PixiEditor/Models/ExtensionServices/IIconLookupProvider.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.ExtensionServices;
+
+public interface IIconLookupProvider
+{
+    public string? LookupIcon(string iconName);
+}

+ 61 - 15
src/PixiEditor/ViewModels/Menu/MenuBarViewModel.cs

@@ -1,21 +1,18 @@
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Linq;
-using System.Windows.Input;
+using System.Collections.ObjectModel;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Data;
-using Avalonia.Threading;
 using Microsoft.Extensions.DependencyInjection;
-using PixiEditor.Models.Commands.XAML;
 using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.CommonApi.Menu;
 using PixiEditor.Extensions.UI;
 using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Evaluators;
+using PixiEditor.Models.ExtensionServices;
 using PixiEditor.OperatingSystem;
 using PixiEditor.ViewModels.SubViewModels;
 using PixiEditor.ViewModels.SubViewModels.AdditionalContent;
-using PixiEditor.Views;
 using Command = PixiEditor.Models.Commands.Commands.Command;
 using Commands_Command = PixiEditor.Models.Commands.Commands.Command;
 using NativeMenu = Avalonia.Controls.NativeMenu;
@@ -45,6 +42,9 @@ internal class MenuBarViewModel : PixiObservableObject
     private Dictionary<string, MenuTreeItem> menuItems = new();
     private List<NativeMenuItem> nativeMenuItems;
 
+    private MenuItemBuilder[] menuItemBuilders;
+    private CommandController commandController;
+
 
     private readonly Dictionary<string, int> menuOrderMultiplier = new Dictionary<string, int>()
     {
@@ -65,8 +65,14 @@ internal class MenuBarViewModel : PixiObservableObject
 
     public void Init(IServiceProvider serviceProvider, CommandController controller)
     {
-        MenuItemBuilder[] builders = serviceProvider.GetServices<MenuItemBuilder>().ToArray();
+        menuItemBuilders = serviceProvider.GetServices<MenuItemBuilder>().ToArray();
+        commandController = controller;
+        BuildMenu(controller);
+        controller.Commands.CommandAdded += CommandsOnCommandAdded;
+    }
 
+    private void BuildMenu(CommandController controller)
+    {
         var commandsWithMenuItems = controller.Commands
             .Where(x => !string.IsNullOrEmpty(x.MenuItemPath) && IsValid(x.MenuItemPath)).ToArray();
 
@@ -78,7 +84,24 @@ internal class MenuBarViewModel : PixiObservableObject
             BuildMenuEntry(command);
         }
 
-        BuildMenu(controller, builders);
+        BuildMenu(controller, menuItemBuilders);
+
+        OnPropertyChanged(nameof(MenuEntries));
+        OnPropertyChanged(nameof(NativeMenu));
+    }
+
+    private void CommandsOnCommandAdded(object? sender, Command e)
+    {
+        RebuildMenu();
+    }
+
+    private void RebuildMenu()
+    {
+        MenuEntries?.Clear();
+        nativeMenuItems?.Clear();
+        menuItems.Clear();
+
+        BuildMenu(commandController);
     }
 
     private int GetCategoryMultiplier(Commands_Command command)
@@ -92,7 +115,7 @@ internal class MenuBarViewModel : PixiObservableObject
         return argMenuItemPath.Split('/').Length > 1;
     }
 
-    private void BuildMenu(CommandController controller, MenuItemBuilder[] builders)
+    private void BuildMenu(CommandController controller, MenuItemBuilder[]? builders)
     {
         if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
         {
@@ -102,9 +125,12 @@ internal class MenuBarViewModel : PixiObservableObject
         if (IOperatingSystem.Current.IsMacOs)
         {
             BuildBasicNativeMenuItems(controller, menuItems);
-            foreach (var builder in builders)
+            if (builders != null)
             {
-                builder.ModifyMenuTree(nativeMenuItems);
+                foreach (var builder in builders)
+                {
+                    builder.ModifyMenuTree(nativeMenuItems);
+                }
             }
 
             NativeMenu = [];
@@ -116,9 +142,12 @@ internal class MenuBarViewModel : PixiObservableObject
         else
         {
             BuildSimpleItems(controller, menuItems);
-            foreach (var builder in builders)
+            if (builders != null)
             {
-                builder.ModifyMenuTree(MenuEntries);
+                foreach (var builder in builders)
+                {
+                    builder.ModifyMenuTree(MenuEntries);
+                }
             }
         }
     }
@@ -133,7 +162,23 @@ internal class MenuBarViewModel : PixiObservableObject
         {
             MenuItem menuItem = new();
 
-            var headerBinding = new Binding(".") { Source = item.Key, Mode = BindingMode.OneWay, };
+            string targetKey = item.Key;
+            bool keyHasEntry = new LocalizedString(item.Key).Value != item.Key;
+            if (!keyHasEntry)
+            {
+                var prefix = item.Value.Command.InternalName.Split(":").FirstOrDefault();
+                string prefixedKey = (prefix != null ? $"{prefix}:" : "") + item.Key;
+
+                keyHasEntry = new LocalizedString(prefixedKey).Value != prefixedKey;
+
+                if (keyHasEntry)
+                {
+                    targetKey = prefixedKey;
+                }
+            }
+
+            var headerBinding = new Binding(".") { Source = targetKey, Mode = BindingMode.OneWay, };
+
 
             menuItem.Bind(Translator.KeyProperty, headerBinding);
 
@@ -256,6 +301,7 @@ internal class MenuBarViewModel : PixiObservableObject
 
         for (int i = 0; i < path.Length; i++)
         {
+            string headerKey = path[i];
             if (current == null)
             {
                 if (!menuItems.ContainsKey(path[i]))

+ 7 - 6
src/PixiEditor/ViewModels/ViewModelMain.cs

@@ -16,6 +16,7 @@ using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.DocumentModels.Autosave;
+using PixiEditor.Models.ExtensionServices;
 using PixiEditor.Models.Files;
 using PixiEditor.Models.Handlers;
 using PixiEditor.OperatingSystem;
@@ -151,7 +152,6 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
         CommandController.Init(services);
         LayoutSubViewModel.LayoutManager.InitLayout(this);
-        MenuBarViewModel.Init(services, CommandController);
 
         MiscSubViewModel = services.GetService<MiscViewModel>();
 
@@ -174,6 +174,12 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
         LazyDocumentClosed += OnLazyDocumentClosed;
     }
 
+    public void OnStartup()
+    {
+        OnStartupEvent?.Invoke();
+        MenuBarViewModel.Init(Services, CommandController);
+    }
+
     public bool DocumentIsNotNull(object property)
     {
         return DocumentManagerSubViewModel.ActiveDocument is not null;
@@ -348,11 +354,6 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
         return false;
     }
 
-    public void OnStartup()
-    {
-        OnStartupEvent?.Invoke();
-    }
-
     private void OnActiveDocumentChanged(object sender, DocumentChangedEventArgs e)
     {
         NotifyToolActionDisplayChanged();