Browse Source

Merge pull request #941 from PixiEditor/api/command-invoke

Api/command invoke
Krzysztof Krysiński 3 months ago
parent
commit
2a62275b78
46 changed files with 800 additions and 77 deletions
  1. 6 0
      samples/PixiEditorExtensionSamples.sln
  2. 55 0
      samples/Sample8_CommandLibrary/CommandLibraryExtension.cs
  3. 9 0
      samples/Sample8_CommandLibrary/Program.cs
  4. 39 0
      samples/Sample8_CommandLibrary/Sample8_CommandLibrary.csproj
  5. 41 0
      samples/Sample8_CommandLibrary/extension.json
  6. 24 0
      samples/Sample8_Commands/CommandsSampleExtension.cs
  7. 1 1
      samples/Sample8_Commands/Program.cs
  8. 9 0
      src/PixiEditor.Extensions.CommonApi/Commands/ICommandProvider.cs
  9. 26 0
      src/PixiEditor.Extensions.CommonApi/DataContracts/CommandMetadata.proto
  10. 0 8
      src/PixiEditor.Extensions.CommonApi/Menu/ICommandProvider.cs
  11. 16 0
      src/PixiEditor.Extensions.CommonApi/ProtoAutogen/CommandMetadata.cs
  12. 19 9
      src/PixiEditor.Extensions.CommonApi/Utilities/PrefixedNameUtility.cs
  13. 15 1
      src/PixiEditor.Extensions.Sdk/Api/Commands/CommandProvider.cs
  14. 36 0
      src/PixiEditor.Extensions.Sdk/Bridge/Interop.Commands.cs
  15. 27 0
      src/PixiEditor.Extensions.Sdk/Bridge/Native.Commands.cs
  16. 1 0
      src/PixiEditor.Extensions.Sdk/Bridge/Native.cs
  17. 93 3
      src/PixiEditor.Extensions.WasmRuntime/Api/CommandApi.cs
  18. 27 2
      src/PixiEditor.Extensions.WasmRuntime/Api/Modules/CommandModule.cs
  19. 2 1
      src/PixiEditor.Extensions.WasmRuntime/WasmExtensionInstance.cs
  20. 8 0
      src/PixiEditor.Extensions/Commands/ICommandSupervisor.cs
  21. 5 2
      src/PixiEditor.Extensions/ExtensionServices.cs
  22. 5 1
      src/PixiEditor/Helpers/ServiceCollectionHelpers.cs
  23. 2 1
      src/PixiEditor/Models/Commands/CommandContext/CommandExecutionSourceType.cs
  24. 11 0
      src/PixiEditor/Models/Commands/CommandContext/ExtensionSourceInfo.cs
  25. 1 0
      src/PixiEditor/Models/Commands/CommandContext/ICommandExecutionSourceInfo.cs
  26. 3 0
      src/PixiEditor/Models/Commands/CommandController.cs
  27. 3 0
      src/PixiEditor/Models/Commands/Commands/Command.cs
  28. 25 0
      src/PixiEditor/Models/Commands/Commands/CommandPermissions.cs
  29. 7 0
      src/PixiEditor/Models/Commands/XAML/Command.cs
  30. 27 0
      src/PixiEditor/Models/Commands/XAML/CommandExists.cs
  31. 32 1
      src/PixiEditor/Models/ExtensionServices/CommandProvider.cs
  32. 78 0
      src/PixiEditor/Models/ExtensionServices/CommandSupervisor.cs
  33. 21 0
      src/PixiEditor/Models/ExtensionServices/ConsoleLogger.cs
  34. 1 0
      src/PixiEditor/ViewModels/Dock/LayoutManager.cs
  35. 26 1
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  36. 0 1
      src/PixiEditor/ViewModels/Menu/MenuBarViewModel.cs
  37. 2 2
      src/PixiEditor/ViewModels/SubViewModels/ExtensionsViewModel.cs
  38. 29 0
      src/PixiEditor/ViewModels/SubViewModels/LayoutViewModel.cs
  39. 12 6
      src/PixiEditor/ViewModels/SubViewModels/ViewportWindowViewModel.cs
  40. 14 1
      src/PixiEditor/ViewModels/SubViewModels/WindowViewModel.cs
  41. 3 1
      src/PixiEditor/ViewModels/ViewModelMain.cs
  42. 3 2
      src/PixiEditor/Views/Dialogs/Debugging/CommandDebugPopup.axaml
  43. 12 0
      src/PixiEditor/Views/Dialogs/Debugging/CommandDebugPopup.axaml.cs
  44. 2 2
      src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml.cs
  45. 7 23
      src/PixiEditor/Views/Rendering/Scene.cs
  46. 15 8
      src/PixiEditor/Views/Windows/HelloTherePopup.axaml

+ 6 - 0
samples/PixiEditorExtensionSamples.sln

@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample7_FlyUI", "Sample7_Fl
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample8_Commands", "Sample8_Commands\Sample8_Commands.csproj", "{25DA4758-9F82-494E-96A3-B9C48637C0E0}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample8_CommandLibrary", "Sample8_CommandLibrary\Sample8_CommandLibrary.csproj", "{3559A288-DF82-4429-B23C-CFF9E55B372E}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -68,6 +70,10 @@ Global
 		{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
+		{3559A288-DF82-4429-B23C-CFF9E55B372E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{3559A288-DF82-4429-B23C-CFF9E55B372E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{3559A288-DF82-4429-B23C-CFF9E55B372E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{3559A288-DF82-4429-B23C-CFF9E55B372E}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{FD9B4C32-4D2E-410E-BC6B-787779BEB6E2} = {7CC35BC4-829F-4EF4-8EB6-E1D46206E7DC}

+ 55 - 0
samples/Sample8_CommandLibrary/CommandLibraryExtension.cs

@@ -0,0 +1,55 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+using PixiEditor.Extensions.Sdk;
+
+namespace Sample8_CommandLibrary;
+
+public class CommandLibraryExtension : PixiEditorExtension
+{
+    public override void OnInitialized()
+    {
+        CommandMetadata publicCommand = new CommandMetadata("PrintHelloWorld")
+        {
+            // All extensions can invoke this command
+            InvokePermissions = InvokePermissions.Public
+        };
+
+        CommandMetadata internalCommand = new CommandMetadata("PrintHelloWorldFamily")
+        {
+            // All extensions with unique name starting with "yourCompany" can invoke this command
+            InvokePermissions = InvokePermissions.Family
+        };
+
+        CommandMetadata privateCommand = new CommandMetadata("PrintHelloWorldPrivate")
+        {
+            // Only this extension can invoke this command
+            InvokePermissions = InvokePermissions.Owner
+        };
+
+        CommandMetadata explicitCommand = new CommandMetadata("PrintHelloWorldExplicit")
+        {
+            // Only this extension and the ones listed in ExplicitlyAllowedExtensions can invoke this command
+            InvokePermissions = InvokePermissions.Explicit,
+            ExplicitlyAllowedExtensions = "yourCompany.Samples.Commands" // You can put multiple extensions by separating with ;
+        };
+
+        Api.Commands.RegisterCommand(publicCommand, () =>
+        {
+            Api.Logger.Log("Hello World from public command!");
+        });
+
+        Api.Commands.RegisterCommand(internalCommand, () =>
+        {
+            Api.Logger.Log("Hello World from internal command!");
+        });
+
+        Api.Commands.RegisterCommand(privateCommand, () =>
+        {
+            Api.Logger.Log("Hello World from private command!");
+        });
+
+        Api.Commands.RegisterCommand(explicitCommand, () =>
+        {
+            Api.Logger.Log("Hello World from explicit command!");
+        });
+    }
+}

+ 9 - 0
samples/Sample8_CommandLibrary/Program.cs

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

+ 39 - 0
samples/Sample8_CommandLibrary/Sample8_CommandLibrary.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_CommandLibrary</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_CommandLibrary/extension.json

@@ -0,0 +1,41 @@
+{
+  "displayName": "Sample Extension - Command Library",
+  "uniqueName": "yourCompany.Samples.CommandLibrary",
+  "description": "Commands Library that can be invoked by other extensions 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"
+  ]
+}

+ 24 - 0
samples/Sample8_Commands/CommandsSampleExtension.cs

@@ -47,5 +47,29 @@ public class CommandsSampleExtension : PixiEditorExtension
             clickedCount++;
             Api.Logger.Log($"Clicked {clickedCount} times");
         });
+
+
+        Api.Commands.InvokeCommand("PixiEditor.File.New");
+
+        if (Api.Commands.CommandExists("yourCompany.Samples.CommandLibrary:PrintHelloWorld"))
+        {
+            Api.Commands.InvokeCommand("yourCompany.Samples.CommandLibrary:PrintHelloWorld");
+        }
+
+        if (Api.Commands.CommandExists("yourCompany.Samples.CommandLibrary:PrintHelloWorldFamily"))
+        {
+            Api.Commands.InvokeCommand("yourCompany.Samples.CommandLibrary:PrintHelloWorldFamily");
+        }
+
+        if (Api.Commands.CommandExists("yourCompany.Samples.CommandLibrary:PrintHelloWorldPrivate"))
+        {
+            // This will log an error.
+            Api.Commands.InvokeCommand("yourCompany.Samples.CommandLibrary:PrintHelloWorldPrivate");
+        }
+
+        if (Api.Commands.CommandExists("yourCompany.Samples.CommandLibrary:PrintHelloWorldExplicit"))
+        {
+            Api.Commands.InvokeCommand("yourCompany.Samples.CommandLibrary:PrintHelloWorldExplicit");
+        }
     }
 }

+ 1 - 1
samples/Sample8_Commands/Program.cs

@@ -1,4 +1,4 @@
-namespace Sample8_Menu;
+namespace Sample8_Commands;
 
 public static class Program
 {

+ 9 - 0
src/PixiEditor.Extensions.CommonApi/Commands/ICommandProvider.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.Extensions.CommonApi.Commands;
+
+public interface ICommandProvider
+{
+    public void RegisterCommand(CommandMetadata command, Action execute, Func<bool>? canExecute = null);
+    public void InvokeCommand(string commandName);
+    public void InvokeCommand(string commandName, object? parameter);
+    public bool CommandExists(string commandName);
+}

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

@@ -13,5 +13,31 @@ message CommandMetadata
   string Icon = 5;
   string MenuItemPath = 6;
   int32 Order = 7;
+  InvokePermissions InvokePermissions = 8;
+  string ExplicitlyAllowedExtensions = 9;
+}
 
+enum InvokePermissions
+{
+
+  /// <summary>
+  ///     Only the registering extension can use this command.
+  /// </summary>
+    Owner = 0;
+
+  /// <summary>
+  ///     Only extensions explicitly whitelisted by the registering extension can use this command.
+  /// </summary>
+    Explicit = 1;
+
+  /// <summary>
+  ///     Only extensions that are part of the same family can use this command. A family is a group under the same
+  ///     unique name prefix.
+  /// </summary>
+    Family = 2;
+
+  /// <summary>
+  ///     Any extension can use this command.
+  /// </summary>
+    Public = 3;
 }

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

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

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

@@ -42,6 +42,22 @@ namespace PixiEditor.Extensions.CommonApi.Commands
         [global::ProtoBuf.ProtoMember(7)]
         public int Order { get; set; }
 
+        [global::ProtoBuf.ProtoMember(8)]
+        public InvokePermissions InvokePermissions { get; set; }
+
+        [global::ProtoBuf.ProtoMember(9)]
+        [global::System.ComponentModel.DefaultValue("")]
+        public string ExplicitlyAllowedExtensions { get; set; } = "";
+
+    }
+
+    [global::ProtoBuf.ProtoContract()]
+    public enum InvokePermissions
+    {
+        Owner = 0,
+        Explicit = 1,
+        Family = 2,
+        Public = 3,
     }
 
 }

+ 19 - 9
src/PixiEditor.Extensions.CommonApi/Utilities/PrefixedNameUtility.cs

@@ -12,14 +12,14 @@ public static class PrefixedNameUtility
     public static string ToPixiEditorRelativePreferenceName(string uniqueName, string name)
     {
         string[] splitted = name.Split(":");
-        
+
         string finalName = $"{uniqueName}:{name}";
-        
+
         if (splitted.Length == 2)
         {
             finalName = name;
-            
-            if(splitted[0].Equals("pixieditor", StringComparison.CurrentCultureIgnoreCase))
+
+            if (splitted[0].Equals("pixieditor", StringComparison.CurrentCultureIgnoreCase))
             {
                 finalName = splitted[1];
             }
@@ -42,7 +42,7 @@ public static class PrefixedNameUtility
             return preferenceName[(extensionUniqueName.Length + 1)..];
         }
 
-        if(preferenceName.Split(":").Length == 1)
+        if (preferenceName.Split(":").Length == 1)
         {
             return $"pixieditor:{preferenceName}";
         }
@@ -50,13 +50,23 @@ public static class PrefixedNameUtility
         return preferenceName;
     }
 
-    public static string ToCommandUniqueName(string extensionUniqueName, string metadataUniqueName)
+    public static string ToCommandUniqueName(string extensionUniqueName, string commandUniqueName, bool allowAlreadyPrefixed)
     {
-        if (metadataUniqueName.StartsWith(extensionUniqueName))
+        if (commandUniqueName.Contains(':'))
+        {
+            if (allowAlreadyPrefixed)
+            {
+                return commandUniqueName;
+            }
+
+            throw new ArgumentException($"Command name '{commandUniqueName}' already contains a prefix. Which is not allowed.");
+        }
+
+        if (commandUniqueName.StartsWith(extensionUniqueName) || commandUniqueName.StartsWith("PixiEditor."))
         {
-            return metadataUniqueName;
+            return commandUniqueName;
         }
 
-        return $"{extensionUniqueName}:{metadataUniqueName}";
+        return $"{extensionUniqueName}:{commandUniqueName}";
     }
 }

+ 15 - 1
src/PixiEditor.Extensions.Sdk/Api/Commands/CommandProvider.cs

@@ -1,5 +1,4 @@
 using PixiEditor.Extensions.CommonApi.Commands;
-using PixiEditor.Extensions.CommonApi.Menu;
 using PixiEditor.Extensions.Sdk.Bridge;
 
 namespace PixiEditor.Extensions.Sdk.Api.Commands;
@@ -28,6 +27,21 @@ public class CommandProvider : ICommandProvider
         Interop.RegisterCommand(command);
     }
 
+    public void InvokeCommand(string commandName)
+    {
+        Native.invoke_command(commandName);
+    }
+
+    public void InvokeCommand(string commandName, object parameter)
+    {
+        Interop.InvokeCommandGeneric(commandName, parameter);
+    }
+
+    public bool CommandExists(string commandName)
+    {
+        return Native.command_exists(commandName);
+    }
+
     private void OnCommandInvoked(string uniqueName)
     {
         if (_commands.TryGetValue(uniqueName, out var command))

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

@@ -20,4 +20,40 @@ internal static partial class Interop
     {
         CommandInvoked?.Invoke(uniqueName);
     }
+
+    public static void InvokeCommandGeneric(string commandName, object? parameter)
+    {
+        if (parameter == null)
+        {
+            Native.invoke_command_null_param(commandName);
+        }
+        else if (parameter is string str)
+        {
+            Native.invoke_command_string(commandName, str);
+        }
+        else if (parameter is int i)
+        {
+            Native.invoke_command_int(commandName, i);
+        }
+        else if (parameter is bool b)
+        {
+            Native.invoke_command_bool(commandName, b);
+        }
+        else if (parameter is float f)
+        {
+            Native.invoke_command_float(commandName, f);
+        }
+        else if (parameter is double d)
+        {
+            Native.invoke_command_double(commandName, d);
+        }
+        else if (parameter is byte[] bytes)
+        {
+            Native.invoke_command_bytes(commandName, InteropUtility.ByteArrayToIntPtr(bytes), bytes.Length);
+        }
+        else
+        {
+            throw new ArgumentException($"Unsupported parameter type: {parameter.GetType()}");
+        }
+    }
 }

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

@@ -9,9 +9,36 @@ internal static partial class Native
     [MethodImpl(MethodImplOptions.InternalCall)]
     internal static extern void register_command(IntPtr metadataPtr, int length);
 
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command(string commandName);
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern bool command_exists(string commandName);
+
     [ApiExport("command_invoked")]
     internal static void OnCommandInvoked(string uniqueName)
     {
         CommandInvoked?.Invoke(uniqueName);
     }
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command_null_param(string commandName);
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command_string(string commandName, string parameter);
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command_int(string commandName, int parameter);
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command_bool(string commandName, bool parameter);
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command_float(string commandName, float parameter);
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command_double(string commandName, double parameter);
+
+    [MethodImpl(MethodImplOptions.InternalCall)]
+    public static extern void invoke_command_bytes(string commandName, IntPtr parameter, int length);
 }

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

@@ -60,4 +60,5 @@ internal static partial class Native
 
     [MethodImpl(MethodImplOptions.InternalCall)]
     public static extern string to_resources_full_path(string value);
+
 }

+ 93 - 3
src/PixiEditor.Extensions.WasmRuntime/Api/CommandApi.cs

@@ -24,8 +24,98 @@ internal class CommandApi : ApiGroupHandler
             commandModule.InvokeCommandInvoked(originalName);
         }
 
-        string prefixed = PrefixedNameUtility.ToCommandUniqueName(Extension.Metadata.UniqueName, metadata.UniqueName);
-        metadata.UniqueName = prefixed;
-        Api.Commands.RegisterCommand(metadata, InvokeCommandInvoked);
+        try
+        {
+            string prefixed =
+                PrefixedNameUtility.ToCommandUniqueName(Extension.Metadata.UniqueName, metadata.UniqueName, false);
+            metadata.UniqueName = prefixed;
+            Api.Commands.RegisterCommand(metadata, InvokeCommandInvoked);
+        }
+        catch (ArgumentException ex)
+        {
+            Api.Logger.LogError(ex.Message);
+        }
+    }
+
+    [ApiFunction("command_exists")]
+    internal bool CommandExists(string commandName)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+        return commandModule.CommandProvider.CommandExists(commandName);
+    }
+
+    [ApiFunction("invoke_command")]
+    internal void InvokeCommand(string commandName)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+
+        string prefixedName = PrefixedNameUtility.ToCommandUniqueName(Extension.Metadata.UniqueName, commandName, true);
+
+        if (!commandModule.CommandProvider.CommandExists(prefixedName))
+        {
+            return;
+        }
+
+        if (commandModule.CommandSupervisor.ValidateCommandPermissions(prefixedName, Extension))
+        {
+            Api.Commands.InvokeCommand(prefixedName);
+        }
+        else
+        {
+            Api.Logger.LogError($"Command {prefixedName} is not accessible from {Metadata.UniqueName} extension.");
+        }
+    }
+
+    [ApiFunction("invoke_command_null_param")]
+    internal void InvokeCommandNullParam(string commandName)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+        commandModule!.InvokeCommandGeneric(commandName, null);
+    }
+
+    [ApiFunction("invoke_command_string")]
+    internal void InvokeCommandString(string commandName, string parameter)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+        commandModule!.InvokeCommandGeneric(commandName, parameter);
+    }
+
+    [ApiFunction("invoke_command_int")]
+    internal void InvokeCommandInt(string commandName, int parameter)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+        commandModule!.InvokeCommandGeneric(commandName, parameter);
+    }
+
+    [ApiFunction("invoke_command_bool")]
+    internal void InvokeCommandBool(string commandName, bool parameter)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+        commandModule!.InvokeCommandGeneric(commandName, parameter);
+    }
+
+    [ApiFunction("invoke_command_float")]
+    internal void InvokeCommandFloat(string commandName, float parameter)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+        commandModule!.InvokeCommandGeneric(commandName, parameter);
+    }
+
+    [ApiFunction("invoke_command_double")]
+    internal void InvokeCommandDouble(string commandName, double parameter)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+        commandModule!.InvokeCommandGeneric(commandName, parameter);
+    }
+
+    [ApiFunction("invoke_command_bytes")]
+    internal void InvokeCommandBytes(string commandName, Span<byte> parameter)
+    {
+        CommandModule commandModule = Extension.GetModule<CommandModule>();
+
+        byte[] bytes = new byte[parameter.Length];
+        parameter.CopyTo(bytes);
+
+        commandModule!.InvokeCommandGeneric(commandName, bytes);
     }
 }

+ 27 - 2
src/PixiEditor.Extensions.WasmRuntime/Api/Modules/CommandModule.cs

@@ -1,16 +1,22 @@
-using PixiEditor.Extensions.CommonApi.Menu;
+using PixiEditor.Extensions.Commands;
+using PixiEditor.Extensions.CommonApi.Commands;
+using PixiEditor.Extensions.CommonApi.Utilities;
 
 namespace PixiEditor.Extensions.WasmRuntime.Api.Modules;
 
 internal class CommandModule : ApiModule
 {
     public ICommandProvider CommandProvider { get; }
+    public ICommandSupervisor CommandSupervisor { get; }
 
-    public CommandModule(WasmExtensionInstance extension, ICommandProvider commandProvider) : base(extension)
+    public CommandModule(WasmExtensionInstance extension, ICommandProvider commandProvider,
+        ICommandSupervisor supervisor) : base(extension)
     {
         CommandProvider = commandProvider;
+        CommandSupervisor = supervisor;
     }
 
+
     internal void InvokeCommandInvoked(string uniqueName)
     {
         var action = Extension.Instance.GetAction<int>("command_invoked");
@@ -18,4 +24,23 @@ internal class CommandModule : ApiModule
         var pathPtr = Extension.WasmMemoryUtility.WriteString(uniqueName);
         action?.Invoke(pathPtr);
     }
+
+    public void InvokeCommandGeneric(string commandName, object? parameter)
+    {
+        string prefixedName = PrefixedNameUtility.ToCommandUniqueName(Extension.Metadata.UniqueName, commandName, true);
+
+        if (!CommandProvider.CommandExists(prefixedName))
+        {
+            return;
+        }
+
+        if (CommandSupervisor.ValidateCommandPermissions(prefixedName, Extension))
+        {
+            CommandProvider.InvokeCommand(commandName, parameter);
+        }
+        else
+        {
+            Extension.Api.Logger.LogError($"Command {prefixedName} is not accessible from {Extension.Metadata.UniqueName} extension.");
+        }
+    }
 }

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

@@ -2,6 +2,7 @@ using System.Runtime.InteropServices;
 using System.Text;
 using Avalonia.Controls;
 using Avalonia.Threading;
+using PixiEditor.Extensions.Commands;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using PixiEditor.Extensions.FlyUI;
 using PixiEditor.Extensions.FlyUI.Elements;
@@ -65,7 +66,7 @@ public partial class WasmExtensionInstance : Extension
     protected override void OnInitialized()
     {
         modules.Add(new PreferencesModule(this, Api.Preferences));
-        modules.Add(new CommandModule(this, Api.Commands));
+        modules.Add(new CommandModule(this, Api.Commands, (ICommandSupervisor)Api.Services.GetService(typeof(ICommandSupervisor))));
         LayoutBuilder = new LayoutBuilder((ElementMap)Api.Services.GetService(typeof(ElementMap)));
 
         //SetElementMap();

+ 8 - 0
src/PixiEditor.Extensions/Commands/ICommandSupervisor.cs

@@ -0,0 +1,8 @@
+using PixiEditor.Extensions.CommonApi.Commands;
+
+namespace PixiEditor.Extensions.Commands;
+
+public interface ICommandSupervisor
+{
+    public bool ValidateCommandPermissions(string commandName, Extension invoker);
+}

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

@@ -1,6 +1,8 @@
 using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Extensions.Commands;
+using PixiEditor.Extensions.CommonApi.Commands;
 using PixiEditor.Extensions.CommonApi.IO;
-using PixiEditor.Extensions.CommonApi.Menu;
+using PixiEditor.Extensions.CommonApi.Logging;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Extensions.CommonApi.Windowing;
@@ -15,9 +17,10 @@ public class ExtensionServices
     public IFileSystemProvider? FileSystem => Services.GetService<IFileSystemProvider>();
     public IPreferences? Preferences => Services.GetService<IPreferences>();
     public ICommandProvider? Commands => Services.GetService<ICommandProvider>();
-    
     public IPalettesProvider? Palettes => Services.GetService<IPalettesProvider>();
     public IDocumentProvider Documents => Services.GetService<IDocumentProvider>();
+    public ICommandSupervisor CommandSupervisor => Services.GetService<ICommandSupervisor>();
+    public ILogger Logger => Services.GetService<ILogger>();
 
     public ExtensionServices(IServiceProvider services)
     {

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

@@ -4,9 +4,11 @@ using System.Reflection;
 using Microsoft.Extensions.DependencyInjection;
 using PixiEditor.AnimationRenderer.Core;
 using PixiEditor.AnimationRenderer.FFmpeg;
+using PixiEditor.Extensions.Commands;
 using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.CommonApi.Commands;
 using PixiEditor.Extensions.CommonApi.IO;
-using PixiEditor.Extensions.CommonApi.Menu;
+using PixiEditor.Extensions.CommonApi.Logging;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using PixiEditor.Extensions.CommonApi.Palettes.Parsers;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
@@ -209,5 +211,7 @@ internal static class ServiceCollectionHelpers
 
                 return elementMap;
             })
+            .AddSingleton<ICommandSupervisor, CommandSupervisor>()
+            .AddSingleton<ILogger, ConsoleLogger>()
             .AddSingleton<IFileSystemProvider, FileSystemProvider>();
 }

+ 2 - 1
src/PixiEditor/Models/Commands/CommandContext/CommandExecutionSourceType.cs

@@ -6,5 +6,6 @@ public enum CommandExecutionSourceType
     Shortcut,
     Menu,
     CommandBinding,
-    Search
+    Search,
+    Extension,
 }

+ 11 - 0
src/PixiEditor/Models/Commands/CommandContext/ExtensionSourceInfo.cs

@@ -0,0 +1,11 @@
+namespace PixiEditor.Models.Commands.CommandContext;
+
+public class ExtensionSourceInfo : ICommandExecutionSourceInfo
+{
+    public CommandExecutionSourceType SourceType { get; } = CommandExecutionSourceType.Extension;
+
+
+    public ExtensionSourceInfo()
+    {
+    }
+}

+ 1 - 0
src/PixiEditor/Models/Commands/CommandContext/ICommandExecutionSourceInfo.cs

@@ -6,6 +6,7 @@ namespace PixiEditor.Models.Commands.CommandContext;
 [JsonDerivedType(typeof(MenuSourceInfo))]
 [JsonDerivedType(typeof(CommandBindingSourceInfo))]
 [JsonDerivedType(typeof(SearchSourceInfo))]
+[JsonDerivedType(typeof(ExtensionSourceInfo))]
 public interface ICommandExecutionSourceInfo
 {
     public CommandExecutionSourceType SourceType { get; }

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

@@ -14,6 +14,7 @@ using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Commands.Attributes.Evaluators;
 using PixiEditor.Models.Commands.CommandContext;
+using PixiEditor.Models.Commands.Commands;
 using PixiEditor.Models.Commands.Evaluators;
 using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.Handlers;
@@ -288,6 +289,7 @@ internal class CommandController
                                 Description = attribute.Description,
                                 Icon = attribute.Icon,
                                 IconEvaluator = xIcon,
+                                InvokePermissions = CommandPermissions.Public,
                                 DefaultShortcut = AdjustForOS(attribute.GetShortcut(), validCustomShortcut),
                                 Shortcut =
                                     GetShortcut(name, AdjustForOS(attribute.GetShortcut(), validCustomShortcut),
@@ -339,6 +341,7 @@ internal class CommandController
                                 InternalName = menu.InternalName,
                                 DisplayName = menu.DisplayName,
                                 Description = menu.DisplayName,
+                                InvokePermissions = CommandPermissions.Public,
                                 IconEvaluator = IconEvaluator.Default,
                                 DefaultShortcut = AdjustForOS(menu.GetShortcut(), validCustomShortcut),
                                 Shortcut = GetShortcut(name, AdjustForOS(attribute.GetShortcut(), validCustomShortcut),

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

@@ -48,6 +48,9 @@ internal abstract partial class Command : PixiObservableObject
 
     public int MenuItemOrder { get; init; } = 100;
 
+    public CommandPermissions InvokePermissions { get; init; } = CommandPermissions.Owner;
+    public string[]? ExplicitPermissions { get; init; }
+
     public event ShortcutChangedEventHandler ShortcutChanged;
     public event Action CanExecuteChanged;
 

+ 25 - 0
src/PixiEditor/Models/Commands/Commands/CommandPermissions.cs

@@ -0,0 +1,25 @@
+namespace PixiEditor.Models.Commands.Commands;
+
+public enum CommandPermissions
+{
+    /// <summary>
+    ///     Only the registering extension can use this command.
+    /// </summary>
+    Owner,
+
+    /// <summary>
+    ///     Only extensions explicitly whitelisted by the registering extension can use this command.
+    /// </summary>
+    Explicit,
+
+    /// <summary>
+    ///     Only extensions that are part of the same family can use this command. A family is a group under the same
+    ///     unique name prefix.
+    /// </summary>
+    Family,
+
+    /// <summary>
+    ///     Any extension can use this command.
+    /// </summary>
+    Public,
+}

+ 7 - 0
src/PixiEditor/Models/Commands/XAML/Command.cs

@@ -44,6 +44,13 @@ internal class Command : MarkupExtension
             commandController = CommandController.Current; // TODO: Find a better way to get the current CommandController
         }
 
+        bool contains = commandController.Commands.ContainsKey(Name);
+
+        if (!contains)
+        {
+            return null;
+        }
+
         Commands.Command command = commandController.Commands[Name];
         return GetPixiCommand ? command : GetICommand(command, new CommandBindingSourceInfo(SourceInfoTag), UseProvided);
     }

+ 27 - 0
src/PixiEditor/Models/Commands/XAML/CommandExists.cs

@@ -0,0 +1,27 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.Models.Commands.XAML;
+
+internal class CommandExists : MarkupExtension
+{
+    public string Name { get; set; }
+
+    public CommandExists() { }
+    public CommandExists(string name) => Name = name;
+
+    public override object ProvideValue(IServiceProvider serviceProvider)
+    {
+        if (Design.IsDesignMode)
+        {
+            return true;
+        }
+
+        if (CommandController.Current.Commands.ContainsKey(Name))
+        {
+            return true;
+        }
+
+        return false;
+    }
+}

+ 32 - 1
src/PixiEditor/Models/ExtensionServices/CommandProvider.cs

@@ -1,6 +1,6 @@
 using PixiEditor.Extensions.CommonApi.Commands;
-using PixiEditor.Extensions.CommonApi.Menu;
 using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.CommandContext;
 using PixiEditor.Models.Commands.Commands;
 using PixiEditor.Models.Commands.Evaluators;
 using PixiEditor.Models.Input;
@@ -43,12 +43,43 @@ public class CommandProvider : ICommandProvider
             Description = command.Description,
             MenuItemOrder = command.Order,
             DefaultShortcut = shortcut,
+            InvokePermissions = (CommandPermissions)command.InvokePermissions,
+            ExplicitPermissions = command.ExplicitlyAllowedExtensions?.Split(';'),
             IconEvaluator = IconEvaluator.Default
         };
 
         CommandController.Current.AddManagedCommand(basicCommand);
     }
 
+    public void InvokeCommand(string commandName)
+    {
+        if (CommandController.Current.Commands.ContainsKey(commandName))
+        {
+            var command = CommandController.Current.Commands[commandName];
+            if (command.CanExecute())
+            {
+                command.Execute();
+            }
+        }
+    }
+
+    public void InvokeCommand(string commandName, object? parameter)
+    {
+        if (CommandController.Current.Commands.ContainsKey(commandName))
+        {
+            var command = CommandController.Current.Commands[commandName];
+            if (command.CanExecute())
+            {
+                command.Execute(new CommandExecutionContext(parameter, new ExtensionSourceInfo()), true);
+            }
+        }
+    }
+
+    public bool CommandExists(string commandName)
+    {
+        return CommandController.Current.Commands.ContainsKey(commandName);
+    }
+
     private static KeyCombination ToKeyCombination(Shortcut? shortcut)
     {
         if (shortcut is null or { Key: 0, Modifiers: 0 })

+ 78 - 0
src/PixiEditor/Models/ExtensionServices/CommandSupervisor.cs

@@ -0,0 +1,78 @@
+using PixiEditor.Extensions;
+using PixiEditor.Extensions.Commands;
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Commands;
+
+namespace PixiEditor.Models.ExtensionServices;
+
+internal class CommandSupervisor : ICommandSupervisor
+{
+    public bool ValidateCommandPermissions(string commandName, Extension invoker)
+    {
+        if (CommandController.Current.Commands.ContainsKey(commandName))
+        {
+            var command = CommandController.Current.Commands[commandName];
+
+            if (IsOwnedByExtension(command, invoker))
+            {
+                return true;
+            }
+
+            if (command.InvokePermissions == CommandPermissions.Public)
+            {
+                return true;
+            }
+
+            if (command.InvokePermissions == CommandPermissions.Explicit)
+            {
+                if (command.ExplicitPermissions == null)
+                {
+                    return false;
+                }
+
+                foreach (var extension in command.ExplicitPermissions)
+                {
+                    if (invoker.Metadata.UniqueName == extension)
+                    {
+                        return true;
+                    }
+                }
+            }
+            else if (command.InvokePermissions == CommandPermissions.Family)
+            {
+                string[] split = command.InternalName.Split('.');
+                if (split.Length < 2)
+                {
+                    return false;
+                }
+
+                string family = split[0];
+
+                if (invoker.Metadata.UniqueName.StartsWith(family))
+                {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    private bool IsOwnedByExtension(Command command, Extension invoker)
+    {
+        string[] split = command.InternalName.Split(':');
+        if (split.Length < 2)
+        {
+            return false;
+        }
+
+        string family = split[0];
+
+        if (invoker.Metadata.UniqueName == family)
+        {
+            return true;
+        }
+
+        return false;
+    }
+}

+ 21 - 0
src/PixiEditor/Models/ExtensionServices/ConsoleLogger.cs

@@ -0,0 +1,21 @@
+using PixiEditor.Extensions.CommonApi.Logging;
+
+namespace PixiEditor.Models.ExtensionServices;
+
+internal class ConsoleLogger : ILogger
+{
+    public void Log(string message)
+    {
+        Console.WriteLine(message);
+    }
+
+    public void LogError(string message)
+    {
+        Console.WriteLine($"Error: {message}");
+    }
+
+    public void LogWarning(string message)
+    {
+        Console.WriteLine($"Warning: {message}");
+    }
+}

+ 1 - 0
src/PixiEditor/ViewModels/Dock/LayoutManager.cs

@@ -166,6 +166,7 @@ internal class LayoutManager
                 if (dockable != null)
                 {
                     dockableHost.ActiveDockable = dockable;
+                    dockableHost.Context.FocusedTarget = dockableHost;
                     return;
                 }
             }

+ 26 - 1
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -697,6 +697,11 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
                 renderOutputName = name.ComputedValue?.ToString();
             }
 
+            if (finalSize.ShortestAxis <= 0)
+            {
+                finalSize = SizeBindable;
+            }
+
             return finalSize;
         }
 
@@ -1253,7 +1258,8 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         }
     }
 
-    public bool RenderFrames(List<Image> frames, Func<Surface, Surface> processFrameAction = null, string? renderOutput = null)
+    public bool RenderFrames(List<Image> frames, Func<Surface, Surface> processFrameAction = null,
+        string? renderOutput = null)
     {
         var firstFrame = AnimationDataViewModel.GetFirstVisibleFrame();
         var lastFrame = AnimationDataViewModel.GetLastVisibleFrame();
@@ -1300,4 +1306,23 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         Internals.Tracker.Dispose();
         Internals.Tracker.Document.Dispose();
     }
+
+    public VecI GetRenderOutputSize(string renderOutputName)
+    {
+        var exportOutputs = GetAvailableExportOutputs();
+        var exportOutput = exportOutputs.FirstOrDefault(x => x.name == renderOutputName);
+
+        VecI size = SizeBindable;
+        if (exportOutput != default)
+        {
+            size = exportOutput.originalSize;
+
+            if (size.ShortestAxis <= 0)
+            {
+                size = SizeBindable;
+            }
+        }
+
+        return size;
+    }
 }

+ 0 - 1
src/PixiEditor/ViewModels/Menu/MenuBarViewModel.cs

@@ -5,7 +5,6 @@ using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Data;
 using Microsoft.Extensions.DependencyInjection;
 using PixiEditor.Extensions.Common.Localization;
-using PixiEditor.Extensions.CommonApi.Menu;
 using PixiEditor.Extensions.UI;
 using PixiEditor.Models.Commands;
 using PixiEditor.Models.Commands.Evaluators;

+ 2 - 2
src/PixiEditor/ViewModels/SubViewModels/ExtensionsViewModel.cs

@@ -16,7 +16,7 @@ internal class ExtensionsViewModel : SubViewModel<ViewModelMain>
         WindowProvider windowProvider = (WindowProvider)Owner.Services.GetService<IWindowProvider>();
 
         RegisterCoreWindows(windowProvider);
-        Owner.OnStartupEvent += Owner_OnStartupEvent;
+        Owner.OnEarlyStartupEvent += Owner_OnEarlyStartupEvent;
     }
 
     private void RegisterCoreWindows(WindowProvider? windowProvider)
@@ -24,7 +24,7 @@ internal class ExtensionsViewModel : SubViewModel<ViewModelMain>
         windowProvider?.RegisterWindow<PalettesBrowser>();
     }
 
-    private void Owner_OnStartupEvent()
+    private void Owner_OnEarlyStartupEvent()
     {
         ExtensionLoader.InitializeExtensions(new ExtensionServices(Owner.Services));
     }

+ 29 - 0
src/PixiEditor/ViewModels/SubViewModels/LayoutViewModel.cs

@@ -1,5 +1,6 @@
 using System.Collections.ObjectModel;
 using Avalonia.Input;
+using Drawie.Numerics;
 using PixiDocks.Core.Docking;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.ViewModels.Dock;
@@ -24,6 +25,34 @@ internal class LayoutViewModel : SubViewModel<ViewModelMain>
         owner.WindowSubViewModel.LazyViewportRemoved += WindowSubViewModel_LazyViewportRemoved;
     }
 
+    [Command.Internal("PixiEditor.Layout.SplitActiveDockable")]
+    [Command.Internal("PixiEditor.Layout.SplitActiveDockableLeft", Parameter = DockingDirection.Left)]
+    [Command.Internal("PixiEditor.Layout.SplitActiveDockableRight", Parameter = DockingDirection.Right)]
+    [Command.Internal("PixiEditor.Layout.SplitActiveDockableUp", Parameter = DockingDirection.Top)]
+    [Command.Internal("PixiEditor.Layout.SplitActiveDockableDown", Parameter = DockingDirection.Bottom)]
+    public void SplitActiveDockable(DockingDirection direction)
+    {
+        if (LayoutManager.DockContext.FocusedTarget is IDockableHost host)
+        {
+            if (direction == DockingDirection.Bottom)
+            {
+                host.SplitDown(host.ActiveDockable);
+            }
+            else if (direction == DockingDirection.Top)
+            {
+                host.SplitUp(host.ActiveDockable);
+            }
+            else if (direction == DockingDirection.Left)
+            {
+                host.SplitLeft(host.ActiveDockable);
+            }
+            else if (direction == DockingDirection.Right)
+            {
+                host.SplitRight(host.ActiveDockable);
+            }
+        }
+    }
+
     private void WindowSubViewModel_ViewportAdded(ViewportWindowViewModel obj)
     {
         LayoutManager.AddViewport(obj);

+ 12 - 6
src/PixiEditor/ViewModels/SubViewModels/ViewportWindowViewModel.cs

@@ -11,6 +11,7 @@ using PixiEditor.Models.DocumentModels;
 using Drawie.Numerics;
 using PixiEditor.Extensions.CommonApi.UserPreferences.Settings;
 using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
+using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Dock;
 using PixiEditor.ViewModels.Document;
@@ -98,6 +99,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     }
 
     private bool autoScaleBackground = true;
+
     public bool AutoScaleBackground
     {
         get => autoScaleBackground;
@@ -109,6 +111,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     }
 
     private double customBackgroundScaleX = 16;
+
     public double CustomBackgroundScaleX
     {
         get => customBackgroundScaleX;
@@ -120,6 +123,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     }
 
     private double customBackgroundScaleY = 16;
+
     public double CustomBackgroundScaleY
     {
         get => customBackgroundScaleY;
@@ -131,6 +135,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     }
 
     private Bitmap backgroundBitmap;
+
     public Bitmap BackgroundBitmap
     {
         get => backgroundBitmap;
@@ -175,6 +180,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         TabCustomizationSettings.Icon = previewPainterControl;
     }
 
+
     private void DocumentOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
     {
         if (e.PropertyName == nameof(DocumentViewModel.FileName))
@@ -245,6 +251,11 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         CustomBackgroundScaleY = newValue;
     }
 
+    public VecI GetRenderOutputSize()
+    {
+       return Document.GetRenderOutputSize(RenderOutputName);
+    }
+
     private void UpdateBackgroundBitmap(Setting<string> setting, string newValue)
     {
         BackgroundBitmap?.Dispose();
@@ -260,11 +271,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
 
         Surface surface = Surface.ForDisplay(new VecI(2, 2));
         surface.DrawingSurface.Canvas.Clear(primary);
-        using Paint secondaryPaint = new Paint
-        {
-            Color = secondary,
-            Style = PaintStyle.Fill
-        };
+        using Paint secondaryPaint = new Paint { Color = secondary, Style = PaintStyle.Fill };
         surface.DrawingSurface.Canvas.DrawRect(1, 0, 1, 1, secondaryPaint);
         surface.DrawingSurface.Canvas.DrawRect(0, 1, 1, 1, secondaryPaint);
 
@@ -297,5 +304,4 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     {
         Owner.Owner.ShortcutController.ClearContext(GetType());
     }
-
 }

+ 14 - 1
src/PixiEditor/ViewModels/SubViewModels/WindowViewModel.cs

@@ -4,6 +4,7 @@ using System.Linq;
 using System.Threading.Tasks;
 using Avalonia.Input;
 using CommunityToolkit.Mvvm.Input;
+using Drawie.Numerics;
 using PixiDocks.Core.Docking;
 using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.Commands;
@@ -79,7 +80,9 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>, IWindowHandler
     public void CenterCurrentViewport()
     {
         if (ActiveWindow is ViewportWindowViewModel viewport)
-            viewport.CenterViewportTrigger.Execute(this, viewport.Document.SizeBindable);
+        {
+            viewport.CenterViewportTrigger.Execute(this, viewport.GetRenderOutputSize());
+        }
     }
 
 
@@ -93,6 +96,16 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>, IWindowHandler
         }
     }
 
+
+    [Command.Internal("PixiEditor.Viewport.SetRenderOutput")]
+    public void SetRenderOutputOfCurrentViewport(string renderOutput)
+    {
+        if (ActiveWindow is ViewportWindowViewModel viewport)
+        {
+            viewport.RenderOutputName = renderOutput;
+        }
+    }
+
     [Commands_Command.Basic("PixiEditor.Window.FlipHorizontally", "FLIP_VIEWPORT_HORIZONTALLY",
         "FLIP_VIEWPORT_HORIZONTALLY", CanExecute = "PixiEditor.HasDocument",
         Icon = PixiPerfectIcons.YFlip, AnalyticsTrack = true)]

+ 3 - 1
src/PixiEditor/ViewModels/ViewModelMain.cs

@@ -35,6 +35,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
     public IServiceProvider Services { get; private set; }
 
     public event Action OnClose;
+    public event Action OnEarlyStartupEvent;
     public event Action OnStartupEvent;
     public FileViewModel FileSubViewModel { get; set; }
     public UpdateViewModel UpdateSubViewModel { get; set; }
@@ -176,6 +177,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
 
     public void OnStartup()
     {
+        OnEarlyStartupEvent?.Invoke();
         OnStartupEvent?.Invoke();
         MenuBarViewModel.Init(Services, CommandController);
     }
@@ -367,7 +369,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
     {
         foreach (var viewport in WindowSubViewModel.Viewports.Where(viewport => viewport.Document == e.Document))
         {
-            viewport.CenterViewportTrigger.Execute(this, e.NewSize);
+            viewport.CenterViewportTrigger.Execute(this, viewport.GetRenderOutputSize());
         }
     }
 }

+ 3 - 2
src/PixiEditor/Views/Dialogs/Debugging/CommandDebugPopup.axaml

@@ -16,11 +16,12 @@
             <RowDefinition />
         </Grid.RowDefinitions>
 
-        <StackPanel Orientation="Horizontal" Margin="5">
+        <StackPanel Orientation="Horizontal" Margin="5" Spacing="10">
             <Button Content="Export list"
                     Command="{xaml:Command PixiEditor.Debug.DumpAllCommands}" Width="100" />
-            <CheckBox Content="Show only with issues" IsCheckedChanged="ShowOnlyWithIssues_OnIsCheckedChanged" Margin="10 0"/>
+            <CheckBox Content="Show only with issues" IsCheckedChanged="ShowOnlyWithIssues_OnIsCheckedChanged"/>
             <CheckBox Content="Show only without icons" IsCheckedChanged="ShowOnlyWithoutIcons_OnIsCheckedChanged"/>
+            <TextBox Width="150" Watermark="Search" TextChanged="TextBox_OnTextChanged"/>
         </StackPanel>
 
         <ScrollViewer VerticalScrollBarVisibility="Auto" Grid.Row="1">

+ 12 - 0
src/PixiEditor/Views/Dialogs/Debugging/CommandDebugPopup.axaml.cs

@@ -168,4 +168,16 @@ public partial class CommandDebugPopup : PixiEditorPopup
             }
         }
     }
+
+    private void TextBox_OnTextChanged(object? sender, TextChangedEventArgs e)
+    {
+        if (sender is TextBox textBox)
+        {
+            string filter = textBox.Text.ToLower();
+
+            Commands = new ObservableCollection<CommandDebug>(allCommands
+                .Where(x => x.Command.InternalName.ToLower().Contains(filter) ||
+                            x.Command.DisplayName.ToString().ToLower().Contains(filter)));
+        }
+    }
 }

+ 2 - 2
src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml.cs

@@ -485,7 +485,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
     private void OnDocumentSizeChanged(object? sender, DocumentSizeChangedEventArgs documentSizeChangedEventArgs)
     {
-        scene.CenterContent(documentSizeChangedEventArgs.NewSize);
+        scene.CenterContent(Document.GetRenderOutputSize(ViewportRenderOutput));
     }
 
     private ChunkResolution CalculateResolution()
@@ -599,7 +599,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
     private void ResetViewportClicked(object sender, RoutedEventArgs e)
     {
         scene.AngleRadians = 0;
-        scene.CenterContent(Document.SizeBindable);
+        scene.CenterContent(Document.GetRenderOutputSize(ViewportRenderOutput));
     }
 
     private static void CenterViewportTriggerChanged(AvaloniaPropertyChangedEventArgs<ExecutionTrigger<VecI>> e)

+ 7 - 23
src/PixiEditor/Views/Rendering/Scene.cs

@@ -402,7 +402,10 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
                 if (prop != null)
                 {
                     VecI size = Document.NodeGraph.GetComputedPropertyValue<VecI>(prop);
-                    outputSize = size;
+                    if (size.ShortestAxis > 0)
+                    {
+                        outputSize = size;
+                    }
                 }
             }
         }
@@ -850,40 +853,21 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         if (e.NewValue is DocumentViewModel documentViewModel)
         {
             documentViewModel.SizeChanged += scene.DocumentViewModelOnSizeChanged;
-            scene.ContentDimensions = scene.GetRenderOutputSize();
+            scene.ContentDimensions = scene.Document.GetRenderOutputSize(scene.RenderOutput);
         }
     }
 
     private void DocumentViewModelOnSizeChanged(object? sender, DocumentSizeChangedEventArgs e)
     {
-        ContentDimensions = GetRenderOutputSize();
+        ContentDimensions = Document.GetRenderOutputSize(RenderOutput);
     }
 
-    private VecI GetRenderOutputSize()
-    {
-        VecI outputSize = Document.SizeBindable;
-
-        if (!string.IsNullOrEmpty(RenderOutput))
-        {
-            if (Document.NodeGraph.CustomRenderOutputs.TryGetValue(RenderOutput, out var node))
-            {
-                var prop = node?.Inputs.FirstOrDefault(x => x.PropertyName == CustomOutputNode.SizePropertyName);
-                if (prop != null)
-                {
-                    VecI size = Document.NodeGraph.GetComputedPropertyValue<VecI>(prop);
-                    outputSize = size;
-                }
-            }
-        }
-
-        return outputSize;
-    }
 
     private static void UpdateRenderOutput(Scene scene, AvaloniaPropertyChangedEventArgs e)
     {
         if (e.NewValue is string newValue)
         {
-            scene.ContentDimensions = scene.GetRenderOutputSize();
+            scene.ContentDimensions = scene.Document.GetRenderOutputSize(newValue);
             scene.CenterContent();
         }
     }

+ 15 - 8
src/PixiEditor/Views/Windows/HelloTherePopup.axaml

@@ -70,14 +70,21 @@
                                    Text="{Binding VersionText}" />
                     </StackPanel>
 
-                    <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Center">
-                        <Button Command="{Binding OpenFileCommand}" MinWidth="150" Margin="10"
-                                ui:Translator.Key="OPEN_FILE" />
-                        <Button Command="{Binding OpenNewFileCommand}" MinWidth="150" Margin="10"
-                                ui:Translator.Key="NEW_FILE" />
-                        <Button Classes="pixi-icon" Content="{DynamicResource icon-paste-as-new-layer}"
-                                Command="{Binding NewFromClipboardCommand}"
-                                ui:Translator.TooltipKey="NEW_FROM_CLIPBOARD" />
+                    <StackPanel Grid.Row="1" Orientation="Vertical" HorizontalAlignment="Center">
+                        <StackPanel Orientation="Horizontal">
+                            <Button Command="{Binding OpenFileCommand}" MinWidth="150" Margin="10"
+                                    ui:Translator.Key="OPEN_FILE" />
+                            <Button Command="{Binding OpenNewFileCommand}" MinWidth="150" Margin="10"
+                                    ui:Translator.Key="NEW_FILE" />
+                            <Button Classes="pixi-icon" Content="{DynamicResource icon-paste-as-new-layer}"
+                                    Command="{Binding NewFromClipboardCommand}"
+                                    ui:Translator.TooltipKey="NEW_FROM_CLIPBOARD" />
+                        </StackPanel>
+                        <Button Command="{xaml:Command Name=PixiEditor.FoundersPack:Workspaces.Browse}"
+                                IsVisible="{xaml:CommandExists Name=PixiEditor.FoundersPack:Workspaces.Browse}"
+                                Name="workspacesButton"
+                                Margin="10, 0, 34, 0"
+                                ui:Translator.Key="PixiEditor.FoundersPack:BROWSE_TEMPLATES"/>
                     </StackPanel>
 
                     <StackPanel Grid.Row="2" HorizontalAlignment="Center" Margin="0,30,0,0">