2
0
Эх сурвалжийг харах

Merge branch 'avalonia-rewrite' into gpu-backend

flabbet 1 жил өмнө
parent
commit
04dbabd862
100 өөрчлөгдсөн 2414 нэмэгдсэн , 268 устгасан
  1. 20 5
      src/ChunkyImageLib/Chunk.cs
  2. 47 8
      src/ChunkyImageLib/ChunkyImage.cs
  3. 2 2
      src/ChunkyImageLib/Operations/OperationHelper.cs
  4. 33 0
      src/ChunkyImageLib/Operations/PaintOperation.cs
  5. 6 1
      src/ChunkyImageLib/Surface.cs
  6. 1 1
      src/Directory.Build.props
  7. 18 17
      src/PixiEditor.AvaloniaUI.Browser/PixiEditor.AvaloniaUI.Browser.csproj
  8. 2 2
      src/PixiEditor.AvaloniaUI.Browser/Program.cs
  9. 0 0
      src/PixiEditor.AvaloniaUI.Browser/wwwroot/Logo.svg
  10. 0 0
      src/PixiEditor.AvaloniaUI.Browser/wwwroot/app.css
  11. 0 0
      src/PixiEditor.AvaloniaUI.Browser/wwwroot/favicon.ico
  12. 3 3
      src/PixiEditor.AvaloniaUI.Browser/wwwroot/index.html
  13. 2 2
      src/PixiEditor.AvaloniaUI.Browser/wwwroot/main.js
  14. 3 3
      src/PixiEditor.AvaloniaUI.Desktop/Program.cs
  15. 47 1
      src/PixiEditor.AvaloniaUI/Data/Localization/Languages/en.json
  16. 8 3
      src/PixiEditor.AvaloniaUI/Helpers/Converters/BoolToValueConverter.cs
  17. 26 0
      src/PixiEditor.AvaloniaUI/Helpers/Converters/NodeInternalNameToStyleConverter.cs
  18. 20 0
      src/PixiEditor.AvaloniaUI/Helpers/Converters/UnsetSkipMultiConverter.cs
  19. 2 0
      src/PixiEditor.AvaloniaUI/Models/Commands/Attributes/Commands/CommandAttribute.cs
  20. 1 0
      src/PixiEditor.AvaloniaUI/Models/Commands/CommandController.cs
  21. 2 0
      src/PixiEditor.AvaloniaUI/Models/Commands/Commands/Command.cs
  22. 17 2
      src/PixiEditor.AvaloniaUI/Models/Controllers/ShortcutController.cs
  23. 9 1
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/ActionAccumulator.cs
  24. 10 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/ColorChannel.cs
  25. 14 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/ColorChannelMode.cs
  26. 3 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentStructureHelper.cs
  27. 54 5
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs
  28. 25 7
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentOperationsModule.cs
  29. 8 7
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentStructureModule.cs
  30. 154 0
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/ViewportColorChannels.cs
  31. 4 1
      src/PixiEditor.AvaloniaUI/Models/Handlers/IAnimationHandler.cs
  32. 2 0
      src/PixiEditor.AvaloniaUI/Models/Handlers/IDocument.cs
  33. 1 0
      src/PixiEditor.AvaloniaUI/Models/Handlers/IKeyFrameHandler.cs
  34. 4 0
      src/PixiEditor.AvaloniaUI/Models/Handlers/INodeGraphHandler.cs
  35. 3 1
      src/PixiEditor.AvaloniaUI/Models/Handlers/INodeHandler.cs
  36. 17 10
      src/PixiEditor.AvaloniaUI/Models/Rendering/AffectedAreasGatherer.cs
  37. 4 1
      src/PixiEditor.AvaloniaUI/Models/Rendering/CanvasUpdater.cs
  38. 65 46
      src/PixiEditor.AvaloniaUI/Models/Rendering/MemberPreviewUpdater.cs
  39. 3 0
      src/PixiEditor.AvaloniaUI/Models/Rendering/RenderInfos/NodePreviewDirty_RenderInfo.cs
  40. 1 1
      src/PixiEditor.AvaloniaUI/PixiEditor.AvaloniaUI.csproj
  41. 1 0
      src/PixiEditor.AvaloniaUI/Styles/PixiEditor.Controls.axaml
  42. 28 12
      src/PixiEditor.AvaloniaUI/Styles/PortingWipStyles.axaml
  43. 13 2
      src/PixiEditor.AvaloniaUI/Styles/Templates/ConnectionView.axaml
  44. 21 0
      src/PixiEditor.AvaloniaUI/Styles/Templates/NodeFrameView.axaml
  45. 52 1
      src/PixiEditor.AvaloniaUI/Styles/Templates/NodeGraphView.axaml
  46. 11 11
      src/PixiEditor.AvaloniaUI/Styles/Templates/NodePropertyViewTemplate.axaml
  47. 11 6
      src/PixiEditor.AvaloniaUI/Styles/Templates/NodeSocket.axaml
  48. 8 12
      src/PixiEditor.AvaloniaUI/Styles/Templates/NodeView.axaml
  49. 1 1
      src/PixiEditor.AvaloniaUI/Styles/Templates/Timeline.axaml
  50. 143 0
      src/PixiEditor.AvaloniaUI/ViewModels/Dock/ChannelsDockViewModel.cs
  51. 12 1
      src/PixiEditor.AvaloniaUI/ViewModels/Dock/LayersDockViewModel.cs
  52. 4 1
      src/PixiEditor.AvaloniaUI/ViewModels/Dock/LayoutManager.cs
  53. 15 2
      src/PixiEditor.AvaloniaUI/ViewModels/Dock/NodeGraphDockViewModel.cs
  54. 15 2
      src/PixiEditor.AvaloniaUI/ViewModels/Dock/TimelineDockViewModel.cs
  55. 23 2
      src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs
  56. 3 1
      src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentManagerViewModel.cs
  57. 4 4
      src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.Serialization.cs
  58. 50 30
      src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.cs
  59. 80 6
      src/PixiEditor.AvaloniaUI/ViewModels/Document/NodeGraphViewModel.cs
  60. 42 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeFrameViewModel.cs
  61. 93 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeFrameViewModelBase.cs
  62. 68 14
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodePropertyViewModel.cs
  63. 2 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeViewModel.cs
  64. 74 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeZoneViewModel.cs
  65. 8 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/BooleanPropertyViewModel.cs
  66. 170 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/ColorMatrixPropertyViewModel.cs
  67. 17 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/ColorPropertyViewModel.cs
  68. 8 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/DoublePropertyViewModel.cs
  69. 13 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/GenericEnumPropertyViewModel.cs
  70. 8 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/Int32PropertyViewModel.cs
  71. 68 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/KernelPropertyViewModel.cs
  72. 170 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/Matrix4x5FPropertyViewModel.cs
  73. 35 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/VecDPropertyViewModel.cs
  74. 35 0
      src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/VecIPropertyViewModel.cs
  75. 11 6
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs
  76. 14 9
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/LayersViewModel.cs
  77. 31 2
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/NodeGraphManagerViewModel.cs
  78. 11 1
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/ViewportWindowViewModel.cs
  79. 1 1
      src/PixiEditor.AvaloniaUI/ViewModels/ViewModelMain.cs
  80. 11 2
      src/PixiEditor.AvaloniaUI/Views/Animations/Timeline.cs
  81. 2 2
      src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml.cs
  82. 48 0
      src/PixiEditor.AvaloniaUI/Views/Dock/ChannelsDockView.axaml
  83. 14 0
      src/PixiEditor.AvaloniaUI/Views/Dock/ChannelsDockView.axaml.cs
  84. 1 0
      src/PixiEditor.AvaloniaUI/Views/Dock/DocumentTemplate.axaml
  85. 1 0
      src/PixiEditor.AvaloniaUI/Views/Dock/TimelineDockView.axaml
  86. 82 0
      src/PixiEditor.AvaloniaUI/Views/Input/NumberInput.cs
  87. 20 3
      src/PixiEditor.AvaloniaUI/Views/Input/SizeInput.axaml.cs
  88. 3 0
      src/PixiEditor.AvaloniaUI/Views/Main/Navigation.axaml.cs
  89. 1 0
      src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/Viewport.axaml
  90. 9 0
      src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/Viewport.axaml.cs
  91. 12 4
      src/PixiEditor.AvaloniaUI/Views/Nodes/ConnectionLine.cs
  92. 33 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/NodeFrameView.cs
  93. 14 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/BooleanPropertyView.axaml
  94. 14 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/BooleanPropertyView.axaml.cs
  95. 49 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorMatrixPropertyView.axaml
  96. 14 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorMatrixPropertyView.axaml.cs
  97. 20 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorPropertyView.axaml
  98. 20 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorPropertyView.axaml.cs
  99. 17 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/DoublePropertyView.axaml
  100. 14 0
      src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/DoublePropertyView.axaml.cs

+ 20 - 5
src/ChunkyImageLib/Chunk.cs

@@ -21,7 +21,18 @@ public class Chunk : IDisposable
     /// <summary>
     /// The surface of the chunk
     /// </summary>
-    public Surface Surface { get; }
+    public Surface Surface
+    {
+        get
+        {
+            if (returned)
+            {
+                throw new ObjectDisposedException("Chunk has been disposed");
+            }
+            
+            return internalSurface;
+        }
+    }
 
     /// <summary>
     /// The size of the chunk
@@ -32,13 +43,17 @@ public class Chunk : IDisposable
     /// The resolution of the chunk
     /// </summary>
     public ChunkResolution Resolution { get; }
+    
+    public bool Disposed => returned;
+
+    private Surface internalSurface;
     private Chunk(ChunkResolution resolution)
     {
         int size = resolution.PixelSize();
 
         Resolution = resolution;
         PixelSize = new(size, size);
-        Surface = new Surface(PixelSize);
+        internalSurface = new Surface(PixelSize);
     }
 
     /// <summary>
@@ -57,7 +72,7 @@ public class Chunk : IDisposable
     /// </summary>
     /// <param name="pos">The destination for the <paramref name="surface"/></param>
     /// <param name="paint">The paint to use while drawing</param>
-    public void DrawOnSurface(DrawingSurface surface, VecI pos, Paint? paint = null)
+    public void DrawChunkOn(DrawingSurface surface, VecI pos, Paint? paint = null)
     {
         surface.Canvas.DrawSurface(Surface.DrawingSurface, pos.X, pos.Y, paint);
     }
@@ -99,9 +114,9 @@ public class Chunk : IDisposable
     {
         if (returned)
             return;
-        returned = true;
         Interlocked.Decrement(ref chunkCounter);
-        Surface.DrawingSurface.Canvas.RestoreToCount(-1);
+        Surface.DrawingSurface.Canvas.Clear();
         ChunkPool.Instance.Push(this);
+        returned = true;
     }
 }

+ 47 - 8
src/ChunkyImageLib/ChunkyImage.cs

@@ -69,6 +69,8 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
     private static Paint AddingPaint { get; } = new Paint() { BlendMode = BlendMode.Plus };
     private readonly Paint blendModePaint = new Paint() { BlendMode = BlendMode.Src };
 
+    public int CommitCounter => commitCounter;
+
     public VecI CommittedSize { get; private set; }
     public VecI LatestSize { get; private set; }
 
@@ -88,6 +90,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
     private VectorPath? clippingPath;
     private double? horizontalSymmetryAxis = null;
     private double? verticalSymmetryAxis = null;
+    private float opacity = 1;
 
     private readonly Dictionary<ChunkResolution, Dictionary<VecI, Chunk>> committedChunks;
     private readonly Dictionary<ChunkResolution, Dictionary<VecI, Chunk>> latestChunks;
@@ -339,7 +342,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
             {
                 if (committedChunk is null)
                     return false;
-                committedChunk.DrawOnSurface(surface, pos, paint);
+                committedChunk.DrawChunkOn(surface, pos, paint);
                 return true;
             }
 
@@ -348,7 +351,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
             {
                 if (latestChunk.IsT2)
                 {
-                    latestChunk.AsT2.DrawOnSurface(surface, pos, paint);
+                    latestChunk.AsT2.DrawChunkOn(surface, pos, paint);
                     return true;
                 }
 
@@ -364,7 +367,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
                 blendModePaint);
             if (lockTransparency)
                 OperationHelper.ClampAlpha(tempChunk.Surface.DrawingSurface, committedChunk.Surface.DrawingSurface);
-            tempChunk.DrawOnSurface(surface, pos, paint);
+            tempChunk.DrawChunkOn(surface, pos, paint);
 
             return true;
         }
@@ -389,6 +392,22 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
         }
     }
 
+    public bool LatestOrCommittedChunkExists()
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            var chunks = FindAllChunks();
+            foreach (var chunk in chunks)
+            {
+                if (LatestOrCommittedChunkExists(chunk))
+                    return true;
+            }
+        }
+
+        return false;
+    }
+
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public bool DrawCommittedChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecI pos,
         Paint? paint = null)
@@ -399,7 +418,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
             var chunk = GetCommittedChunk(chunkPos, resolution);
             if (chunk is null)
                 return false;
-            chunk.DrawOnSurface(surface, pos, paint);
+            chunk.DrawChunkOn(surface, pos, paint);
             return true;
         }
     }
@@ -757,6 +776,17 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
             EnqueueOperation(operation, new(FindAllChunksOutsideBounds(newSize)));
         }
     }
+    
+    
+    public void EnqueueDrawPaint(Paint paint)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            PaintOperation operation = new(paint);
+            EnqueueOperation(operation);
+        }
+    }
 
     private void EnqueueOperation(IDrawOperation operation)
     {
@@ -1025,6 +1055,15 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
         }
     }
 
+    public void SetCommitedChunk(Chunk chunk, VecI pos, ChunkResolution resolution)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            committedChunks[resolution][pos] = chunk;
+        }
+    }
+
     /// <summary>
     /// Applies all operations queued for a specific (latest) chunk. If the latest chunk doesn't exist yet, creates it. If none of the existing operations affect the chunk does nothing.
     /// </summary>
@@ -1144,14 +1183,14 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable
             var clip = combinedRasterClips.AsT2;
 
             using var tempChunk = Chunk.Create(targetChunk.Resolution);
-            targetChunk.DrawOnSurface(tempChunk.Surface.DrawingSurface, VecI.Zero, ReplacingPaint);
+            targetChunk.DrawChunkOn(tempChunk.Surface.DrawingSurface, VecI.Zero, ReplacingPaint);
 
             CallDrawWithClip(chunkOperation, operationAffectedArea.GlobalArea, tempChunk, resolution, chunkPos);
 
-            clip.DrawOnSurface(tempChunk.Surface.DrawingSurface, VecI.Zero, ClippingPaint);
-            clip.DrawOnSurface(targetChunk.Surface.DrawingSurface, VecI.Zero, InverseClippingPaint);
+            clip.DrawChunkOn(tempChunk.Surface.DrawingSurface, VecI.Zero, ClippingPaint);
+            clip.DrawChunkOn(targetChunk.Surface.DrawingSurface, VecI.Zero, InverseClippingPaint);
 
-            tempChunk.DrawOnSurface(targetChunk.Surface.DrawingSurface, VecI.Zero, AddingPaint);
+            tempChunk.DrawChunkOn(targetChunk.Surface.DrawingSurface, VecI.Zero, AddingPaint);
             return false;
         }
 

+ 2 - 2
src/ChunkyImageLib/Operations/OperationHelper.cs

@@ -22,7 +22,7 @@ public static class OperationHelper
     /// <summary>
     /// toModify[x,y].Alpha = Math.Min(toModify[x,y].Alpha, toGetAlphaFrom[x,y].Alpha)
     /// </summary>
-    public static unsafe void ClampAlpha(DrawingSurface toModify, DrawingSurface toGetAlphaFrom, RectI? clippingRect = null)
+    public static unsafe void ClampAlpha(IPixelsMap toModify, IPixelsMap toGetAlphaFrom, RectI? clippingRect = null)
     {
         if (clippingRect is not null)
         {
@@ -59,7 +59,7 @@ public static class OperationHelper
         }
     }
 
-    private static unsafe void ClampAlphaWithClippingRect(DrawingSurface toModify, DrawingSurface toGetAlphaFrom, RectI clippingRect)
+    private static unsafe void ClampAlphaWithClippingRect(IPixelsMap toModify, IPixelsMap toGetAlphaFrom, RectI clippingRect)
     {
         using Pixmap map = toModify.PeekPixels();
         using Pixmap refMap = toGetAlphaFrom.PeekPixels();

+ 33 - 0
src/ChunkyImageLib/Operations/PaintOperation.cs

@@ -0,0 +1,33 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Surface.PaintImpl;
+using PixiEditor.Numerics;
+
+namespace ChunkyImageLib.Operations;
+
+public class PaintOperation : IDrawOperation
+{
+    private Paint paint;
+
+    public PaintOperation(Paint paint)
+    {
+        this.paint = paint;
+    }
+    
+    public void Dispose()
+    {
+        
+    }
+
+    public bool IgnoreEmptyChunks => false;
+    public void DrawOnChunk(Chunk targetChunk, VecI chunkPos)
+    {
+        targetChunk.Surface.DrawingSurface.Canvas.DrawPaint(paint);
+    }
+
+    public AffectedArea FindAffectedArea(VecI imageSize)
+    {
+        return new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
+            new RectI(0, 0, imageSize.X, imageSize.Y), 
+            ChunkyImage.FullChunkSize));
+    }
+}

+ 6 - 1
src/ChunkyImageLib/Surface.cs

@@ -10,7 +10,7 @@ using PixiEditor.Numerics;
 
 namespace ChunkyImageLib;
 
-public class Surface : IDisposable, ICloneable
+public class Surface : IDisposable, ICloneable, IPixelsMap
 {
     private bool disposed;
     public IntPtr PixelBuffer { get; }
@@ -233,6 +233,11 @@ public class Surface : IDisposable, ICloneable
         Marshal.FreeHGlobal(PixelBuffer);
     }
 
+    public Pixmap PeekPixels()
+    {
+        return DrawingSurface.PeekPixels(); 
+    }
+
     public object Clone()
     {
         return new Surface(this);

+ 1 - 1
src/Directory.Build.props

@@ -1,7 +1,7 @@
 <Project>
     <PropertyGroup>
         <CodeAnalysisRuleSet>../Custom.ruleset</CodeAnalysisRuleSet>
-		    <AvaloniaVersion>11.0.11</AvaloniaVersion>
+		    <AvaloniaVersion>11.1.0</AvaloniaVersion>
     </PropertyGroup>
     <ItemGroup>
         <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" />

+ 18 - 17
src/PixiEditor.AvaloniaUI.Browser/PixiEditor.AvaloniaUI.Browser.csproj

@@ -1,22 +1,23 @@
 <Project Sdk="Microsoft.NET.Sdk">
-    <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
-        <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
-        <WasmMainJSPath>AppBundle\main.js</WasmMainJSPath>
-        <OutputType>Exe</OutputType>
-        <RootNamespace>PixiEditor.Avalonia.Browser</RootNamespace>
-        <Nullable>enable</Nullable>
-    </PropertyGroup>
+  <PropertyGroup>
+    <TargetFramework>net8.0-browser</TargetFramework>
+    <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
+    <WasmMainJSPath>wwwroot\main.js</WasmMainJSPath>
+    <OutputType>Exe</OutputType>
+    <RootNamespace>PixiEditor.Avalonia.Browser</RootNamespace>
+    <Nullable>enable</Nullable>
+    <WasmRuntimeAssetsLocation>./_framework</WasmRuntimeAssetsLocation>
+  </PropertyGroup>
 
-    <ItemGroup>
-        <WasmExtraFilesToDeploy Include="AppBundle\**"/>
-    </ItemGroup>
+  <ItemGroup>
+    <WasmExtraFilesToDeploy Include="wwwroot\**"/>
+  </ItemGroup>
 
-    <ItemGroup>
-        <PackageReference Include="Avalonia.Browser" Version="$(AvaloniaVersion)"/>
-    </ItemGroup>
+  <ItemGroup>
+    <PackageReference Include="Avalonia.Browser" Version="$(AvaloniaVersion)"/>
+  </ItemGroup>
 
-    <ItemGroup>
-        <ProjectReference Include="..\PixiEditor.AvaloniaUI\PixiEditor.AvaloniaUI.csproj" />
-    </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\PixiEditor.AvaloniaUI\PixiEditor.AvaloniaUI.csproj"/>
+  </ItemGroup>
 </Project>

+ 2 - 2
src/PixiEditor.AvaloniaUI.Browser/Program.cs

@@ -6,9 +6,9 @@ using PixiEditor.AvaloniaUI;
 
 [assembly: SupportedOSPlatform("browser")]
 
-internal partial class Program
+internal sealed partial class Program
 {
-    private static async Task Main(string[] args) => await BuildAvaloniaApp()
+    private static Task Main(string[] args) => BuildAvaloniaApp()
         .StartBrowserAppAsync("out");
 
     public static AppBuilder BuildAvaloniaApp()

+ 0 - 0
src/PixiEditor.AvaloniaUI.Browser/AppBundle/Logo.svg → src/PixiEditor.AvaloniaUI.Browser/wwwroot/Logo.svg


+ 0 - 0
src/PixiEditor.AvaloniaUI.Browser/AppBundle/app.css → src/PixiEditor.AvaloniaUI.Browser/wwwroot/app.css


+ 0 - 0
src/PixiEditor.AvaloniaUI.Browser/AppBundle/favicon.ico → src/PixiEditor.AvaloniaUI.Browser/wwwroot/favicon.ico


+ 3 - 3
src/PixiEditor.AvaloniaUI.Browser/AppBundle/index.html → src/PixiEditor.AvaloniaUI.Browser/wwwroot/index.html

@@ -2,13 +2,13 @@
 <html>
 
 <head>
-    <title>PixiEditor.Avalonia.Browser</title>
+    <title>AvaloniaApplication1.Browser</title>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <base href="/" />
     <link rel="modulepreload" href="./main.js" />
-    <link rel="modulepreload" href="./dotnet.js" />
-    <link rel="modulepreload" href="./avalonia.js" />
+    <link rel="modulepreload" href="./_framework/dotnet.js" />
+    <link rel="modulepreload" href="./_framework/avalonia.js" />
     <link rel="stylesheet" href="./app.css" />
 </head>
 

+ 2 - 2
src/PixiEditor.AvaloniaUI.Browser/AppBundle/main.js → src/PixiEditor.AvaloniaUI.Browser/wwwroot/main.js

@@ -1,4 +1,4 @@
-import { dotnet } from './dotnet.js'
+import { dotnet } from './_framework/dotnet.js'
 
 const is_browser = typeof window != "undefined";
 if (!is_browser) throw new Error(`Expected to be running in a browser`);
@@ -10,4 +10,4 @@ const dotnetRuntime = await dotnet
 
 const config = dotnetRuntime.getConfig();
 
-await dotnetRuntime.runMainAndExit(config.mainAssemblyName, [window.location.search]);
+await dotnetRuntime.runMain(config.mainAssemblyName, [window.location.search]);

+ 3 - 3
src/PixiEditor.AvaloniaUI.Desktop/Program.cs

@@ -19,7 +19,7 @@ public class Program
             .UsePlatformDetect()
             .With(new Win32PlatformOptions()
             {
-                RenderingMode = new [] { Win32RenderingMode.AngleEgl },
-                OverlayPopups = true
-            }).LogToTrace();
+                RenderingMode = new Win32RenderingMode[] { Win32RenderingMode.Vulkan, Win32RenderingMode.AngleEgl }
+            })
+            .LogToTrace();
 }

+ 47 - 1
src/PixiEditor.AvaloniaUI/Data/Localization/Languages/en.json

@@ -616,5 +616,51 @@
   "STROKE_WIDTH": "Stroke width",
   "FILL_COLOR": "Fill color",
   "TOP": "Top",
-  "BOTTOM": "Bottom"
+  "BOTTOM": "Bottom",
+  "CHANNELS_DOCK_TITLE": "Channels",
+  "RED": "Red",
+  "GREEN": "Green",
+  "BLUE": "Blue",
+  "ALPHA": "Alpha",
+  "COLOR": "Color",
+  "COORDINATE": "Coordinate",
+  "VECTOR": "Vector",
+  "MATRIX": "Matrix",
+  "TRANSFORMED": "Transformed",
+  "GRAYSCALE": "Grayscale",
+  "CLAMP": "Clamp",
+  "SIZE": "Size",
+  "EMPTY_IMAGE": "Empty image",
+  "NOISE": "Noise",
+  "SCALE": "Scale",
+  "SEED": "Seed",
+  "KERNEL": "Kernel",
+  "KERNEL_VIEW_SUM": "Sum:",
+  "KERNEL_VIEW_SUM_TOOLTIP": "The sum of all values. You likely want to aim for a value of 1 or 0",
+  "GAIN": "Gain",
+  "BIAS": "Bias",
+  "TILE_MODE": "Tile Mode",
+  "ON_ALPHA": "On Alpha",
+  "PIXEL_COORDINATE": "Pixel Coordinate",
+  "OUTPUT_NODE": "Output",
+  "NOISE_NODE": "Noise",
+  "ELLIPSE_NODE": "Ellipse",
+  "CREATE_IMAGE_NODE": "Create Image",
+  "FOLDER_NODE": "Folder",
+  "IMAGE_LAYER_NODE": "Image Layer",
+  "IMAGE_SPACE_NODE": "Image Space",
+  "KERNEL_FILTER_NODE": "Kernel Filter",
+  "MATH_NODE": "Math",
+  "MATRIX_TRANSFORM_NODE": "Matrix Transform",
+  "MERGE_NODE": "Merge",
+  "MODIFY_IMAGE_LEFT_NODE": "Begin Modify Image",
+  "MODIFY_IMAGE_RIGHT_NODE": "End Modify Image",
+  "COMBINE_CHANNELS_NODE": "Combine Channels",
+  "COMBINE_COLOR_NODE": "Combine Color",
+  "COMBINE_VECD_NODE": "Combine Vector (float)",
+  "COMBINE_VECI_NODE": "Combine Vector (int)",
+  "SEPARATE_CHANNELS_NODE": "Separate Channels",
+  "SEPARATE_VECD_NODE": "Separate Vector (float)",
+  "SEPARATE_VECI_NODE": "Separate Vector (int)",
+  "TIME_NODE": "Time"
 }

+ 8 - 3
src/PixiEditor.AvaloniaUI/Helpers/Converters/BoolToValueConverter.cs

@@ -14,19 +14,24 @@ internal class BoolToValueConverter : MarkupConverter
     {
         if (value is bool and true)
         {
-            return GetValue(TrueValue);
+            return GetValue(TrueValue, targetType);
         }
 
-        return GetValue(FalseValue);
+        return GetValue(FalseValue, targetType);
     }
 
-    private object GetValue(object value)
+    private object GetValue(object value, Type targetType)
     {
         if (value is string s && s.StartsWith("localized:"))
         {
             return new LocalizedString(s.Split("localized:")[1]);
         }
 
+        if (value is string enumString && targetType.IsEnum)
+        {
+            return Enum.Parse(targetType, enumString);
+        }
+
         return value;
     }
 

+ 26 - 0
src/PixiEditor.AvaloniaUI/Helpers/Converters/NodeInternalNameToStyleConverter.cs

@@ -0,0 +1,26 @@
+using System.Globalization;
+using System.Net.Mime;
+using Avalonia;
+using Avalonia.Styling;
+using PixiEditor.UI.Common.Converters;
+
+namespace PixiEditor.AvaloniaUI.Helpers.Converters;
+
+internal class NodeInternalNameToStyleConverter : SingleInstanceConverter<NodeInternalNameToStyleConverter>
+{
+    public override object Convert(object? value, Type targetType, object parameter, CultureInfo culture)
+    {
+        if (value == null)
+            return AvaloniaProperty.UnsetValue;
+        
+        string s = (string)value;
+        s = s.Replace(".", string.Empty);
+        
+        if (Application.Current.Styles.TryGetResource($"{s}{parameter}", Application.Current.ActualThemeVariant, out var output))
+        {
+            return output;
+        }
+
+        return AvaloniaProperty.UnsetValue;
+    }
+}

+ 20 - 0
src/PixiEditor.AvaloniaUI/Helpers/Converters/UnsetSkipMultiConverter.cs

@@ -0,0 +1,20 @@
+using System.Globalization;
+using Avalonia;
+
+namespace PixiEditor.AvaloniaUI.Helpers.Converters;
+
+internal class UnsetSkipMultiConverter : SingleInstanceMultiValueConverter<UnsetSkipMultiConverter>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value;
+
+    public override object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
+    {
+        foreach (var value in values)
+        {
+            if (value is not UnsetValueType)
+                return value;
+        }
+
+        return AvaloniaProperty.UnsetValue;
+    }
+}

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

@@ -16,6 +16,8 @@ internal partial class Command
         public LocalizedString Description { get; }
 
         public string CanExecute { get; set; }
+        
+        public Type? ShortcutContext { get; set; }
 
         /// <summary>
         /// Gets or sets the default shortcut key for this command

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

@@ -257,6 +257,7 @@ internal class CommandController
                                 Parameter = basic.Parameter,
                                 MenuItemPath = basic.MenuItemPath,
                                 MenuItemOrder = basic.MenuItemOrder,
+                                ShortcutContext = basic.ShortcutContext
                             });
                     }
                     else if (attribute is CommandAttribute.FilterAttribute menu)

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

@@ -40,6 +40,8 @@ internal abstract partial class Command : PixiObservableObject
             }
         }
     }
+    
+    public Type? ShortcutContext { get; init; }
 
     public string? MenuItemPath { get; init; }
 

+ 17 - 2
src/PixiEditor.AvaloniaUI/Models/Controllers/ShortcutController.cs

@@ -17,6 +17,8 @@ internal class ShortcutController
     public IEnumerable<Command> LastCommands { get; private set; }
 
     public Dictionary<KeyCombination, ToolViewModel> TransientShortcuts { get; set; } = new();
+    
+    public Type? ActiveContext { get; private set; }
 
     public static void BlockShortcutExecution(string blocker)
     {
@@ -51,7 +53,7 @@ internal class ShortcutController
 
         if (!ShortcutExecutionBlocked)
         {
-            var commands = CommandController.Current.Commands[shortcut];
+            var commands = CommandController.Current.Commands[shortcut].Where(x => x.ShortcutContext is null || x.ShortcutContext == ActiveContext).ToList();
 
             if (!commands.Any())
             {
@@ -60,10 +62,23 @@ internal class ShortcutController
 
             LastCommands = commands;
 
-            foreach (var command in CommandController.Current.Commands[shortcut])
+            foreach (var command in commands)
             {
                 command.Execute();
             }
         }
     }
+
+    public void OverwriteContext(Type getType)
+    {
+        ActiveContext = getType;
+    }
+    
+    public void ClearContext(Type clearFrom)
+    {
+        if (ActiveContext == clearFrom)
+        {
+            ActiveContext = null;
+        }
+    }
 }

+ 9 - 1
src/PixiEditor.AvaloniaUI/Models/DocumentModels/ActionAccumulator.cs

@@ -90,7 +90,7 @@ internal class ActionAccumulator
                 internals.Updater.AfterUndoBoundaryPassed();
 
             // update the contents of the bitmaps
-            var affectedAreas = new AffectedAreasGatherer(document.AnimationHandler.ActiveFrameBindable, internals.Tracker, optimizedChanges);
+            var affectedAreas = new AffectedAreasGatherer(document.AnimationHandler.ActiveFrameTime, internals.Tracker, optimizedChanges);
             List<IRenderInfo> renderResult = new();
             renderResult.AddRange(await canvasUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed || viewportRefreshRequest));
             renderResult.AddRange(await previewUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed));
@@ -163,6 +163,14 @@ internal class ActionAccumulator
                         document.PreviewSurface.AddDirtyRect(new RectI(0, 0, document.PreviewSurface.Size.X, document.PreviewSurface.Size.Y));
                     }
                     break;
+                case NodePreviewDirty_RenderInfo info:
+                    {
+                        var node = document.StructureHelper.Find(info.NodeId);
+                        if (node is null || node.PreviewSurface is null)
+                            continue;
+                        node.PreviewSurface.AddDirtyRect(new RectI(0, 0, node.PreviewSurface.Size.X, node.PreviewSurface.Size.Y));
+                    }
+                    break;
             }
         }
     }

+ 10 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/ColorChannel.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.AvaloniaUI.Models.DocumentModels;
+
+public enum ColorChannel
+{
+    None = -1,
+    Red,
+    Green,
+    Blue,
+    Alpha
+}

+ 14 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/ColorChannelMode.cs

@@ -0,0 +1,14 @@
+using System.Diagnostics.Contracts;
+
+namespace PixiEditor.AvaloniaUI.Models.DocumentModels;
+
+internal record struct ColorChannelMode(bool IsVisible, bool IsSolo)
+{
+    [Pure]
+    public ColorChannelMode WithVisible(bool visible) => this with { IsVisible = visible };
+    
+    [Pure]
+    public ColorChannelMode WithSolo(bool solo) => this with { IsSolo = solo };
+
+    public static ColorChannelMode Default => new(true, false);
+}

+ 3 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentStructureHelper.cs

@@ -77,6 +77,9 @@ internal class DocumentStructureHelper
             Guid guid = Guid.NewGuid();
             //put member above the layer
             INodeHandler parent = doc.StructureHelper.GetFirstForwardNode(layer);
+            if(parent is null)
+                parent = doc.NodeGraphHandler.OutputNode;
+            
             internals.ActionAccumulator.AddActions(new CreateStructureMember_Action(parent.Id, guid, type));
             name ??= GetUniqueName(
                 type == StructureMemberType.Layer

+ 54 - 5
src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs

@@ -7,6 +7,7 @@ using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.Models.Layers;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.AvaloniaUI.ViewModels.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Exceptions;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.ChangeInfos;
 using PixiEditor.ChangeableDocument.ChangeInfos.Animation;
@@ -65,8 +66,8 @@ internal class DocumentUpdater
                 ProcessCreateStructureMember(info);
                 break;
             case DeleteStructureMember_ChangeInfo info:
-                ProcessDeleteNode(info);
                 ProcessDeleteStructureMember(info);
+                ProcessDeleteNode(info);
                 break;
             case StructureMemberName_ChangeInfo info:
                 ProcessUpdateStructureMemberName(info);
@@ -170,12 +171,24 @@ internal class DocumentUpdater
             case DeleteNode_ChangeInfo info:
                 ProcessDeleteNode(info);
                 break;
+            case CreateNodeFrame_ChangeInfo info:
+                ProcessCreateNodeFrame(info);
+                break;
+            case CreateNodeZone_ChangeInfo info:
+                ProcessCreateNodeZone(info);
+                break;
+            case DeleteNodeFrame_ChangeInfo info:
+                ProcessDeleteNodeFrame(info);
+                break;
             case ConnectProperty_ChangeInfo info:
                 ProcessConnectProperty(info);
                 break;
             case NodePosition_ChangeInfo info:
                 ProcessNodePosition(info);
                 break;
+            case PropertyValueUpdated_ChangeInfo info:
+                ProcessNodePropertyValueUpdated(info);
+                break;
         }
     }
 
@@ -477,9 +490,14 @@ internal class DocumentUpdater
     
     private void ProcessCreateNode<T>(CreateNode_ChangeInfo info) where T : NodeViewModel, new()
     {
-        T node = new T() { 
-            NodeName = info.NodeName, Id = info.Id, 
-            Document = (DocumentViewModel)doc, Internals = helper };
+        T node = new T()
+        {
+            NodeName = info.NodeName,
+            InternalName = info.InternalName,
+            Id = info.Id,
+            Document = (DocumentViewModel)doc,
+            Internals = helper
+        };
 
         node.SetPosition(info.Position);
         
@@ -499,6 +517,8 @@ internal class DocumentUpdater
             prop.DisplayName = input.DisplayName;
             prop.PropertyName = input.PropertyName;
             prop.IsInput = isInput;
+            prop.IsFunc = input.ValueType.IsAssignableTo(typeof(Delegate));
+            prop.InternalSetValue(input.InputValue);
             inputs.Add(prop);
         }
         
@@ -511,6 +531,21 @@ internal class DocumentUpdater
         doc.NodeGraphHandler.RemoveNode(info.Id);
     }
     
+    private void ProcessCreateNodeFrame(CreateNodeFrame_ChangeInfo info)
+    {
+        doc.NodeGraphHandler.AddFrame(info.Id, info.NodeIds);
+    }
+
+    private void ProcessCreateNodeZone(CreateNodeZone_ChangeInfo info)
+    {
+        doc.NodeGraphHandler.AddZone(info.Id, info.internalName, info.StartId, info.EndId);
+    }
+
+    private void ProcessDeleteNodeFrame(DeleteNodeFrame_ChangeInfo info)
+    {
+        doc.NodeGraphHandler.RemoveFrame(info.Id);
+    }
+
     private void ProcessConnectProperty(ConnectProperty_ChangeInfo info)
     {
         NodeViewModel outputNode = info.OutputNodeId.HasValue ? doc.StructureHelper.FindNode<NodeViewModel>(info.OutputNodeId.Value) : null;
@@ -528,10 +563,16 @@ internal class DocumentUpdater
             
             doc.NodeGraphHandler.SetConnection(connection);
         }
-        else
+        else if(info.OutputProperty == null)
         {
             doc.NodeGraphHandler.RemoveConnection(info.InputNodeId, info.InputProperty);
         }
+        else
+        {
+#if DEBUG
+            throw new MissingNodeException("Connection requested for a node that doesn't exist");
+#endif
+        }
     }
     
     private void ProcessNodePosition(NodePosition_ChangeInfo info)
@@ -539,4 +580,12 @@ internal class DocumentUpdater
         NodeViewModel node = doc.StructureHelper.FindNode<NodeViewModel>(info.NodeId);
         node.SetPosition(info.NewPosition);
     }
+    
+    private void ProcessNodePropertyValueUpdated(PropertyValueUpdated_ChangeInfo info)
+    {
+        NodeViewModel node = doc.StructureHelper.FindNode<NodeViewModel>(info.NodeId);
+        var property = node.FindInputProperty(info.Property);
+        
+        property.InternalSetValue(info.Value);
+    }
 }

+ 25 - 7
src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -381,17 +381,35 @@ internal class DocumentOperationsModule : IDocumentOperations
     {
         if (Internals.ChangeController.IsChangeActive || members.Count < 2)
             return;
-        var (child, parent) = Document.StructureHelper.FindChildAndParent(members[0]);
-        if (child is null || parent is null)
+
+        IStructureMemberHandler? node = Document.StructureHelper.FindNode<IStructureMemberHandler>(members[0]);
+        
+        if (node is null)
+            return;
+
+        INodeHandler? parent = null;
+
+        node.TraverseForwards(traversedNode =>
+        {
+            if (!members.Contains(traversedNode.Id))
+            {
+                parent = traversedNode;
+                return false;
+            }
+            
+            return true;
+        });
+        
+        if (parent is null)
             return;
-        //int index = parent.Children.IndexOf(child);
+        
         Guid newGuid = Guid.NewGuid();
 
         //make a new layer, put combined image onto it, delete layers that were merged
-        /*Internals.ActionAccumulator.AddActions(
-            new CreateStructureMember_Action(parent.Id, newGuid, index, StructureMemberType.Layer),
-            new StructureMemberName_Action(newGuid, child.NameBindable),
-            new CombineStructureMembersOnto_Action(members.ToHashSet(), newGuid, Document.AnimationHandler.ActiveFrameBindable));*/
+        Internals.ActionAccumulator.AddActions(
+            new CreateStructureMember_Action(parent.Id, newGuid, StructureMemberType.Layer),
+            new StructureMemberName_Action(newGuid, node.NameBindable),
+            new CombineStructureMembersOnto_Action(members.ToHashSet(), newGuid, Document.AnimationHandler.ActiveFrameBindable));
         foreach (var member in members)
             Internals.ActionAccumulator.AddActions(new DeleteStructureMember_Action(member));
         Internals.ActionAccumulator.AddActions(new ChangeBoundary_Action());

+ 8 - 7
src/PixiEditor.AvaloniaUI/Models/DocumentModels/Public/DocumentStructureModule.cs

@@ -26,20 +26,21 @@ internal class DocumentStructureModule
         return doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == guid && x is T) as T;
     }
 
-    public IStructureMemberHandler? FindFirstWhere(Predicate<IStructureMemberHandler> predicate)
+    public INodeHandler? FindFirstWhere(Predicate<INodeHandler> predicate)
     {
         return FindFirstWhere(predicate, doc.NodeGraphHandler);
     }
 
-    private IStructureMemberHandler? FindFirstWhere(Predicate<IStructureMemberHandler> predicate,
+    private INodeHandler? FindFirstWhere(
+        Predicate<INodeHandler> predicate,
         INodeGraphHandler graphVM)
     {
-        IStructureMemberHandler? result = null;
+        INodeHandler? result = null;
         graphVM.TryTraverse(node =>
         {
-            if (node is IStructureMemberHandler structureMemberNode && predicate(structureMemberNode))
+            if (predicate(node))
             {
-                result = structureMemberNode;
+                result = node;
                 return false;
             }
 
@@ -49,14 +50,14 @@ internal class DocumentStructureModule
         return result;
     }
 
-    public (IStructureMemberHandler?, IFolderHandler?) FindChildAndParent(Guid childGuid)
+    public (IStructureMemberHandler?, INodeHandler?) FindChildAndParent(Guid childGuid)
     {
         List<IStructureMemberHandler>? path = FindPath(childGuid);
         return path.Count switch
         {
             0 => (null, null),
             1 => (path[0], null),
-            >= 2 => (path[0], (IFolderHandler)path[1]),
+            >= 2 => (path[0], path[1]),
             _ => (null, null),
         };
     }

+ 154 - 0
src/PixiEditor.AvaloniaUI/Models/DocumentModels/ViewportColorChannels.cs

@@ -0,0 +1,154 @@
+using System.ComponentModel;
+using System.Diagnostics.Contracts;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.Models.DocumentModels;
+
+internal record struct ViewportColorChannels
+{
+    public ColorChannelMode Red { get; }
+
+    public ColorChannelMode Green { get; }
+
+    public ColorChannelMode Blue { get; }
+
+    public ColorChannelMode Alpha { get; }
+
+    public ViewportColorChannels(ColorChannelMode red, ColorChannelMode green, ColorChannelMode blue, ColorChannelMode alpha)
+    {
+        ReadOnlySpan<ColorChannelMode> modes = [red, green, blue, alpha];
+        int solos = 0;
+
+        for (int i = 0; i < modes.Length; i++)
+        {
+            if (!modes[i].IsSolo)
+            {
+                continue;
+            }
+
+            solos++;
+
+            if (solos > 1)
+            {
+                throw new ArgumentException("Can't have more than one channel solo");
+            }
+        }
+
+        Red = red;
+        Green = green;
+        Blue = blue;
+        Alpha = alpha;
+    }
+
+    public static ViewportColorChannels Default => new(ColorChannelMode.Default, ColorChannelMode.Default, ColorChannelMode.Default, ColorChannelMode.Default);
+
+    public override string ToString() => $"Red: {Red}; Green: {Green}; Blue: {Blue}; Alpha: {Alpha}";
+
+    public bool IsVisiblyVisible(ColorChannel channel) =>
+        GetModeForChannel(channel).IsVisible || GetModeForChannel(channel).IsSolo;
+
+    public bool IsSolo(ColorChannel channel) => GetModeForChannel(channel).IsSolo;
+
+    [Pure]
+    public ViewportColorChannels WithModeForChannel(ColorChannel channel, Func<ColorChannelMode, ColorChannelMode> mode, bool otherNonSolo)
+    {
+        switch (channel)
+        {
+            case ColorChannel.Red:
+                return new ViewportColorChannels(mode(Red), MON(Green), MON(Blue), MON(Alpha));
+            case ColorChannel.Green:
+                return new ViewportColorChannels(MON(Red), mode(Green), MON(Blue), MON(Alpha));
+            case ColorChannel.Blue:
+                return new ViewportColorChannels(MON(Red), MON(Green), mode(Blue), MON(Alpha));
+            case ColorChannel.Alpha:
+                return new ViewportColorChannels(MON(Red), MON(Green), MON(Blue), mode(Alpha));
+            case ColorChannel.None:
+                throw new InvalidEnumArgumentException(nameof(channel), (int)channel, typeof(ColorChannel));
+            default:
+                throw new ArgumentOutOfRangeException(nameof(channel), channel, null);
+        }
+
+        // Modify Other Node
+        ColorChannelMode MON(ColorChannelMode otherMode)
+        {
+            if (otherNonSolo && otherMode.IsSolo)
+            {
+                return otherMode.WithSolo(false);
+            }
+
+            return otherMode;
+        }
+    }
+    
+    public ColorChannelMode GetModeForChannel(ColorChannel channel) => channel switch
+    {
+        ColorChannel.Red => Red,
+        ColorChannel.Green => Green,
+        ColorChannel.Blue => Blue,
+        ColorChannel.Alpha => Alpha
+    };
+
+    public ColorMatrix GetColorMatrix()
+    {
+        var solo = GetSoloChannel();
+
+        var (otherToRed, redToRed) = GetTarget(Red, solo, ColorChannel.Red);
+        var (otherToGreen, greenToGreen) = GetTarget(Green, solo, ColorChannel.Green);
+        var (otherToBlue, blueToBlue) = GetTarget(Blue, solo, ColorChannel.Blue);
+        
+        var opaque = solo is not ColorChannel.None || !Alpha.IsVisible;
+
+        var alphaToOther = Alpha.IsSolo;
+        var alphaToAlpha = !alphaToOther && !opaque;
+
+        var o2r = otherToRed ? 1 : 0;
+        var r2r = redToRed ? 1 : 0;
+
+        var o2g = otherToGreen ? 1 : 0;
+        var g2g = greenToGreen ? 1 : 0;
+
+        var o2b = otherToBlue ? 1 : 0;
+        var b2b = blueToBlue ? 1 : 0;
+
+        var a2o = alphaToOther ? 1 : 0;
+        var a2a = alphaToAlpha ? 1 : 0;
+
+        var o = opaque ? 1 : 0;
+
+        return new ColorMatrix(
+            (r2r, o2g, o2b, a2o, 0),
+            (o2r, g2g, o2b, a2o, 0),
+            (o2r, o2g, b2b, a2o, 0),
+            (0, 0, 0, a2a, o)
+        );
+    }
+
+    private static (bool otherToRed, bool targetToTarget) GetTarget(ColorChannelMode mode, ColorChannel solo, ColorChannel target)
+    {
+        var otherToTarget = solo == target;
+        var targetToTarget = solo == target || (mode.IsVisible && solo == ColorChannel.None);
+
+        return (otherToTarget, targetToTarget);
+    }
+
+    public ColorChannel GetSoloChannel()
+    {
+        ReadOnlySpan<(ColorChannel channel, ColorChannelMode mode)> modes = [
+            (ColorChannel.Red, Red),
+            (ColorChannel.Green, Green),
+            (ColorChannel.Blue, Blue),
+            (ColorChannel.Alpha, Alpha)
+        ];
+    
+        for (int i = 0; i < modes.Length; i++)
+        {
+            var mode = modes[i];
+            if (modes[i].mode.IsSolo)
+            {
+                return mode.channel;
+            }
+        }
+    
+        return ColorChannel.None;
+    }
+}

+ 4 - 1
src/PixiEditor.AvaloniaUI/Models/Handlers/IAnimationHandler.cs

@@ -1,9 +1,12 @@
-namespace PixiEditor.AvaloniaUI.Models.Handlers;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
+
+namespace PixiEditor.AvaloniaUI.Models.Handlers;
 
 internal interface IAnimationHandler
 {
     public IReadOnlyCollection<IKeyFrameHandler> KeyFrames { get; }
     public int ActiveFrameBindable { get; set; }
+    public KeyFrameTime ActiveFrameTime { get; }
     public void CreateRasterKeyFrame(Guid targetLayerGuid, int frame, Guid? toCloneFrom = null, int? frameToCopyFrom = null);
     public void SetActiveFrame(int newFrame);
     public void SetFrameLength(Guid keyFrameId, int newStartFrame, int newDuration);

+ 2 - 0
src/PixiEditor.AvaloniaUI/Models/Handlers/IDocument.cs

@@ -6,6 +6,7 @@ using PixiEditor.AvaloniaUI.Helpers;
 using PixiEditor.AvaloniaUI.Models.DocumentModels.Public;
 using PixiEditor.AvaloniaUI.Models.Structures;
 using PixiEditor.AvaloniaUI.Models.Tools;
+using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.DrawingApi.Core.ColorsImpl;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.DrawingApi.Core.Surface;
@@ -40,6 +41,7 @@ internal interface IDocument : IHandler
     public double HorizontalSymmetryAxisYBindable { get; }
     public double VerticalSymmetryAxisXBindable { get; }
     public IDocumentOperations Operations { get; }
+    public DocumentRenderer Renderer { get; }
     public void RemoveSoftSelectedMember(IStructureMemberHandler member);
     public void ClearSoftSelectedMembers();
     public void AddSoftSelectedMember(IStructureMemberHandler member);

+ 1 - 0
src/PixiEditor.AvaloniaUI/Models/Handlers/IKeyFrameHandler.cs

@@ -7,6 +7,7 @@ internal interface IKeyFrameHandler
     public Surface? PreviewSurface { get; set; }
     public int StartFrameBindable { get; }
     public int DurationBindable { get; }
+    public bool IsSelected { get; set; }
     public Guid LayerGuid { get; }
     public Guid Id { get; }
     public bool IsVisible { get; }

+ 4 - 0
src/PixiEditor.AvaloniaUI/Models/Handlers/INodeGraphHandler.cs

@@ -8,11 +8,15 @@ internal interface INodeGraphHandler
 {
    public ObservableCollection<INodeHandler> AllNodes { get; }
    public ObservableCollection<NodeConnectionViewModel> Connections { get; }
+   public ObservableCollection<NodeFrameViewModelBase> Frames { get; }
    public INodeHandler OutputNode { get; }
    public StructureTree StructureTree { get; }
    public bool TryTraverse(Func<INodeHandler, bool> func);
    public void AddNode(INodeHandler node);
    public void RemoveNode(Guid nodeId);
+   public void AddFrame(Guid frameId, IEnumerable<Guid> nodeIds);
+   public void AddZone(Guid frameId, string internalName, Guid startId, Guid endId);
+   public void RemoveFrame(Guid frameId);
    public void SetConnection(NodeConnectionViewModel connection);
    public void RemoveConnection(Guid nodeId, string property);
    public void RemoveConnections(Guid nodeId);

+ 3 - 1
src/PixiEditor.AvaloniaUI/Models/Handlers/INodeHandler.cs

@@ -1,4 +1,5 @@
 using System.Collections.ObjectModel;
+using System.ComponentModel;
 using ChunkyImageLib;
 using PixiEditor.AvaloniaUI.Models.Structures;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
@@ -6,10 +7,11 @@ using PixiEditor.Numerics;
 
 namespace PixiEditor.AvaloniaUI.Models.Handlers;
 
-public interface INodeHandler
+public interface INodeHandler : INotifyPropertyChanged
 {
     public Guid Id { get; }
     public string NodeName { get; set; }
+    public string InternalName { get; }
     public ObservableRangeCollection<INodePropertyHandler> Inputs { get; }
     public ObservableRangeCollection<INodePropertyHandler> Outputs { get; }
     public Surface ResultPreview { get; set; }

+ 17 - 10
src/PixiEditor.AvaloniaUI/Models/Rendering/AffectedAreasGatherer.cs

@@ -3,6 +3,8 @@ using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using PixiEditor.AvaloniaUI.Models.DocumentPassthroughActions;
 using PixiEditor.ChangeableDocument;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.ChangeInfos;
@@ -25,9 +27,9 @@ internal class AffectedAreasGatherer
     public Dictionary<Guid, AffectedArea> ImagePreviewAreas { get; private set; } = new();
     public Dictionary<Guid, AffectedArea> MaskPreviewAreas { get; private set; } = new();
     
-    private int ActiveFrame { get; set; }
+    private KeyFrameTime ActiveFrame { get; set; }
 
-    public AffectedAreasGatherer(int activeFrame, DocumentChangeTracker tracker,
+    public AffectedAreasGatherer(KeyFrameTime activeFrame, DocumentChangeTracker tracker,
         IReadOnlyList<IChangeInfo> changes)
     {
         this.tracker = tracker;
@@ -131,21 +133,26 @@ internal class AffectedAreasGatherer
                     AddWholeCanvasToMainImage();
                     AddWholeCanvasToEveryImagePreview();
                     break;
+                case PropertyValueUpdated_ChangeInfo:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToEveryImagePreview();
+                    break;
             }
         }
     }
 
-    private void AddAllToImagePreviews(Guid memberGuid, int frame, bool ignoreSelf = false)
+    private void AddAllToImagePreviews(Guid memberGuid, KeyFrameTime frame, bool ignoreSelf = false)
     {
         var member = tracker.Document.FindMember(memberGuid);
-        if (member is IReadOnlyLayerNode layer)
+        if (member is IReadOnlyImageNode layer)
         {
-            var result = layer.Execute(frame);
+            var result = layer.GetLayerImageAtFrame(frame.Frame);
             if (result == null)
             {
                 AddWholeCanvasToImagePreviews(memberGuid, ignoreSelf);
                 return;
             }
+            
             var chunks = result.FindAllChunks();
             AddToImagePreviews(memberGuid, new AffectedArea(chunks), ignoreSelf);
         }
@@ -157,12 +164,12 @@ internal class AffectedAreasGatherer
         }
     }
 
-    private void AddAllToMainImage(Guid memberGuid, int frame, bool useMask = true)
+    private void AddAllToMainImage(Guid memberGuid, KeyFrameTime frame, bool useMask = true)
     {
         var member = tracker.Document.FindMember(memberGuid);
-        if (member is IReadOnlyLayerNode layer)
+        if (member is IReadOnlyImageNode layer)
         {
-            var result = layer.Execute(frame);
+            var result = layer.GetLayerImageAtFrame(frame.Frame);
             if (result == null)
             {
                 AddWholeCanvasToMainImage();
@@ -249,10 +256,10 @@ internal class AffectedAreasGatherer
     private void AddWholeCanvasToImagePreviews(Guid memberGuid, bool ignoreSelf = false)
     {
         var path = tracker.Document.FindMemberPath(memberGuid);
-        if (path.Count < 2)
+        if (path.Count < 1 || path.Count == 1 && ignoreSelf)
             return;
         // skip root folder
-        for (int i = ignoreSelf ? 1 : 0; i < path.Count - 1; i++)
+        for (int i = ignoreSelf ? 1 : 0; i < path.Count; i++)
         {
             var member = path[i];
             if (!ImagePreviewAreas.ContainsKey(member.Id))

+ 4 - 1
src/PixiEditor.AvaloniaUI/Models/Rendering/CanvasUpdater.cs

@@ -189,13 +189,16 @@ internal class CanvasUpdater
 
     private void RenderChunk(VecI chunkPos, Surface screenSurface, ChunkResolution resolution, RectI? globalClippingRectangle, RectI? globalScaledClippingRectangle)
     {
+        if(screenSurface is null || screenSurface.IsDisposed)
+            return;
+        
         if (globalScaledClippingRectangle is not null)
         {
             screenSurface.DrawingSurface.Canvas.Save();
             screenSurface.DrawingSurface.Canvas.ClipRect((RectD)globalScaledClippingRectangle);
         }
 
-        DocumentEvaluator.RenderChunk(chunkPos, resolution, internals.Tracker.Document.NodeGraph, doc.AnimationHandler.ActiveFrameBindable, globalClippingRectangle).Switch(
+        doc.Renderer.RenderChunk(chunkPos, resolution, doc.AnimationHandler.ActiveFrameTime, globalClippingRectangle).Switch(
             (Chunk chunk) =>
             {
                 screenSurface.DrawingSurface.Canvas.DrawSurface(chunk.Surface.DrawingSurface, chunkPos.Multiply(chunk.PixelSize), ReplacingPaint);

+ 65 - 46
src/PixiEditor.AvaloniaUI/Models/Rendering/MemberPreviewUpdater.cs

@@ -73,7 +73,7 @@ internal class MemberPreviewUpdater
         }).ConfigureAwait(true);
 
         RecreatePreviewBitmaps(changedMainPreviewBounds!, changedMaskPreviewBounds!);
-        
+
         var renderInfos = await Task.Run(() => Render(changedMainPreviewBounds!, changedMaskPreviewBounds))
             .ConfigureAwait(true);
 
@@ -190,7 +190,7 @@ internal class MemberPreviewUpdater
             if (member is null)
                 continue;
 
-            if (forMasks && member.Mask is null)
+            if (forMasks && member.Mask.Value is null)
             {
                 newPreviewBitmapSizes.Add(guid, null);
                 continue;
@@ -277,7 +277,7 @@ internal class MemberPreviewUpdater
     private RectI? GetOrFindMemberTightBounds(IReadOnlyStructureNode member, int atFrame,
         AffectedArea currentlyAffectedArea, bool forMask)
     {
-        if (forMask && member.Mask is null)
+        if (forMask && member.Mask.Value is null)
             throw new InvalidOperationException();
 
         RectI? prevTightBounds = null;
@@ -307,10 +307,10 @@ internal class MemberPreviewUpdater
     /// </summary>
     private RectI? FindLayerTightBounds(IReadOnlyLayerNode layer, int frame, bool forMask)
     {
-        if (layer.Mask is null && forMask)
+        if (layer.Mask.Value is null && forMask)
             throw new InvalidOperationException();
 
-        if (layer.Mask is not null && forMask)
+        if (layer.Mask.Value is not null && forMask)
             return FindImageTightBoundsFast(layer.Mask.Value);
 
         if (layer is IReadOnlyImageNode raster)
@@ -328,7 +328,7 @@ internal class MemberPreviewUpdater
     {
         if (forMask)
         {
-            if (folder.Mask is null)
+            if (folder.Mask.Value is null)
                 throw new InvalidOperationException();
             return FindImageTightBoundsFast(folder.Mask.Value);
         }
@@ -388,7 +388,7 @@ internal class MemberPreviewUpdater
         RenderWholeCanvasPreview(mainPreviewChunksToRerender, maskPreviewChunksToRerender, infos);
         RenderMainPreviews(mainPreviewChunksToRerender, recreatedMainPreviewSizes, infos);
         RenderMaskPreviews(maskPreviewChunksToRerender, recreatedMaskPreviewSizes, infos);
-        RenderNodePreviews();
+        RenderNodePreviews(infos);
 
         return infos;
 
@@ -438,8 +438,7 @@ internal class MemberPreviewUpdater
                 _ => ChunkResolution.Eighth,
             };
             var pos = chunkPos * resolution.PixelSize();
-            var rendered = DocumentEvaluator.RenderChunk(chunkPos, resolution,
-                internals.Tracker.Document.NodeGraph, doc.AnimationHandler.ActiveFrameBindable);
+            var rendered = doc.Renderer.RenderChunk(chunkPos, resolution, doc.AnimationHandler.ActiveFrameTime);
             doc.PreviewSurface.DrawingSurface.Canvas.Save();
             doc.PreviewSurface.DrawingSurface.Canvas.Scale(scaling);
             doc.PreviewSurface.DrawingSurface.Canvas.ClipRect((RectD)cumulative.GlobalArea);
@@ -452,7 +451,7 @@ internal class MemberPreviewUpdater
             else if (rendered.IsT0)
             {
                 using var renderedChunk = rendered.AsT0;
-                renderedChunk.DrawOnSurface(doc.PreviewSurface.DrawingSurface, pos, SmoothReplacingPaint);
+                renderedChunk.DrawChunkOn(doc.PreviewSurface.DrawingSurface, pos, SmoothReplacingPaint);
             }
 
             doc.PreviewSurface.DrawingSurface.Canvas.Restore();
@@ -514,7 +513,7 @@ internal class MemberPreviewUpdater
                     {
                         foreach (var child in group.Children)
                         {
-                            if (member is IReadOnlyImageNode rasterLayer) 
+                            if (member is IReadOnlyImageNode rasterLayer)
                             {
                                 RenderAnimationFramePreview(rasterLayer, child, affArea.Value);
                             }
@@ -539,7 +538,8 @@ internal class MemberPreviewUpdater
     /// <summary>
     /// Re-render the <paramref name="area"/> of the main preview of the <paramref name="memberVM"/> folder
     /// </summary>
-    private void RenderFolderMainPreview(IReadOnlyFolderNode folder, IStructureMemberHandler memberVM, AffectedArea area,
+    private void RenderFolderMainPreview(IReadOnlyFolderNode folder, IStructureMemberHandler memberVM,
+        AffectedArea area,
         VecI position, float scaling)
     {
         memberVM.PreviewSurface.DrawingSurface.Canvas.Save();
@@ -551,8 +551,19 @@ internal class MemberPreviewUpdater
             var pos = chunk * ChunkResolution.Full.PixelSize();
             // drawing in full res here is kinda slow
             // we could switch to a lower resolution based on (canvas size / preview size) to make it run faster
-            OneOf<Chunk, EmptyChunk> rendered = DocumentEvaluator.RenderChunk(chunk, ChunkResolution.Full, folder,
-                doc.AnimationHandler.ActiveFrameBindable);
+            var contentNode = folder.Content.Connection?.Node;
+
+            OneOf<Chunk, EmptyChunk> rendered;
+
+            if (contentNode is null)
+            {
+                rendered = new EmptyChunk();
+            }
+            else
+            {
+                rendered = doc.Renderer.RenderChunk(chunk, ChunkResolution.Full, contentNode, doc.AnimationHandler.ActiveFrameBindable);
+            }
+            
             if (rendered.IsT0)
             {
                 memberVM.PreviewSurface.DrawingSurface.Canvas.DrawSurface(rendered.AsT0.Surface.DrawingSurface, pos,
@@ -583,10 +594,9 @@ internal class MemberPreviewUpdater
         foreach (var chunk in area.Chunks)
         {
             var pos = chunk * ChunkResolution.Full.PixelSize();
-            IReadOnlyChunkyImage? result = layer is IReadOnlyImageNode raster
-                ? raster.GetLayerImageAtFrame(doc.AnimationHandler.ActiveFrameBindable)
-                : layer.Execute(doc.AnimationHandler.ActiveFrameBindable);
-            
+            if (layer is not IReadOnlyImageNode raster) return;
+            IReadOnlyChunkyImage? result = raster.GetLayerImageAtFrame(doc.AnimationHandler.ActiveFrameBindable);
+
             if (!result.DrawCommittedChunkOn(
                     chunk,
                     ChunkResolution.Full, memberVM.PreviewSurface.DrawingSurface, pos,
@@ -604,9 +614,10 @@ internal class MemberPreviewUpdater
     {
         if (keyFrameVM.PreviewSurface is null)
         {
-            keyFrameVM.PreviewSurface = new Surface(StructureHelpers.CalculatePreviewSize(internals.Tracker.Document.Size));
+            keyFrameVM.PreviewSurface =
+                new Surface(StructureHelpers.CalculatePreviewSize(internals.Tracker.Document.Size));
         }
-        
+
         keyFrameVM.PreviewSurface!.DrawingSurface.Canvas.Save();
         float scaling = (float)keyFrameVM.PreviewSurface.Size.X / internals.Tracker.Document.Size.X;
         keyFrameVM.PreviewSurface.DrawingSurface.Canvas.Scale(scaling);
@@ -683,35 +694,43 @@ internal class MemberPreviewUpdater
             infos.Add(new MaskPreviewDirty_RenderInfo(guid));
         }
     }
-    
-    private void RenderNodePreviews()
+
+    private void RenderNodePreviews(List<IRenderInfo> infos)
     {
-        // TODO: recreate only changed previews
-        internals.Tracker.Document.NodeGraph.TryTraverse(node =>
+        foreach(var node in internals.Tracker.Document.NodeGraph.AllNodes)
         {
-            if (node.CachedResult != null)
+            if (node is null)
+                return;
+
+            if (node.CachedResult == null)
             {
-               var nodeVm = doc.StructureHelper.FindNode<INodeHandler>(node.Id);
-
-               // TODO: do it in recreate preview bitmaps
-               if (nodeVm.ResultPreview == null)
-               {
-                     nodeVm.ResultPreview = new Surface(StructureHelpers.CalculatePreviewSize(internals.Tracker.Document.Size));
-               }
-               
-               float scalingX = (float)nodeVm.ResultPreview.Size.X / node.CachedResult.CommittedSize.X;
-               float scalingY = (float)nodeVm.ResultPreview.Size.Y / node.CachedResult.CommittedSize.Y;
-               
-               nodeVm.ResultPreview.DrawingSurface.Canvas.Save();
-               nodeVm.ResultPreview.DrawingSurface.Canvas.Scale(scalingX, scalingY);
-               
-               nodeVm.ResultPreview.DrawingSurface.Canvas.Clear();
-               node.CachedResult.DrawCommittedRegionOn(
-                   new RectI(0, 0, node.CachedResult.CommittedSize.X, node.CachedResult.CommittedSize.Y), ChunkResolution.Full,
-                   nodeVm.ResultPreview.DrawingSurface, new VecI(0, 0), ReplacingPaint);
-               
-               nodeVm.ResultPreview.DrawingSurface.Canvas.Restore();
+                return;
             }
-        });
+
+            var nodeVm = doc.StructureHelper.FindNode<INodeHandler>(node.Id);
+            if (nodeVm == null)
+            {
+                return;
+            }
+            
+            if (nodeVm.ResultPreview == null)
+            {
+                nodeVm.ResultPreview =
+                    new Surface(StructureHelpers.CalculatePreviewSize(internals.Tracker.Document.Size));
+            }
+
+            float scalingX = (float)nodeVm.ResultPreview.Size.X / node.CachedResult.Size.X;
+            float scalingY = (float)nodeVm.ResultPreview.Size.Y / node.CachedResult.Size.Y;
+
+            nodeVm.ResultPreview.DrawingSurface.Canvas.Save();
+            nodeVm.ResultPreview.DrawingSurface.Canvas.Scale(scalingX, scalingY);
+
+            RectI region = new RectI(0, 0, node.CachedResult.Size.X, node.CachedResult.Size.Y);
+           
+            nodeVm.ResultPreview.DrawingSurface.Canvas.DrawSurface(node.CachedResult.DrawingSurface, 0, 0, ReplacingPaint);
+
+            nodeVm.ResultPreview.DrawingSurface.Canvas.Restore();
+            infos.Add(new NodePreviewDirty_RenderInfo(node.Id));
+        }
     }
 }

+ 3 - 0
src/PixiEditor.AvaloniaUI/Models/Rendering/RenderInfos/NodePreviewDirty_RenderInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.AvaloniaUI.Models.Rendering.RenderInfos;
+
+public record NodePreviewDirty_RenderInfo(Guid NodeId) : IRenderInfo;

+ 1 - 1
src/PixiEditor.AvaloniaUI/PixiEditor.AvaloniaUI.csproj

@@ -60,7 +60,7 @@
     <PackageReference Include="Avalonia.Labs.Lottie" Version="11.0.10.1"/>
     <PackageReference Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersion)"/>
     <PackageReference Include="Avalonia.Skia" Version="$(AvaloniaVersion)"/>
-    <PackageReference Include="Avalonia.Svg.Skia" Version="11.0.0.18"/>
+    <PackageReference Include="Avalonia.Svg.Skia" Version="$(AvaloniaVersion)"/>
     <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
     <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)"/>
     <PackageReference Include="ByteSize" Version="2.1.1"/>

+ 1 - 0
src/PixiEditor.AvaloniaUI/Styles/PixiEditor.Controls.axaml

@@ -13,6 +13,7 @@
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/KeyFrame.axaml"/>
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/TimelineSlider.axaml"/>
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/TimelineGroupHeader.axaml"/>
+                <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/NodeFrameView.axaml"/>
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/NodeGraphView.axaml"/>
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/NodeView.axaml"/>
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/NodeSocket.axaml"/>

+ 28 - 12
src/PixiEditor.AvaloniaUI/Styles/PortingWipStyles.axaml

@@ -30,12 +30,12 @@
     </Style>
 
     <Style Selector="CheckBox.ImageCheckBox">
-        <Setter Property="Content" Value="{DynamicResource icon-eye-off}"/>
+        <Setter Property="Content" Value="{DynamicResource icon-eye-off}" />
         <Setter Property="Template">
             <Setter.Value>
                 <ControlTemplate>
                     <Border Cursor="Hand" Background="{TemplateBinding Background}">
-                        <TextBlock Text="{TemplateBinding Content}" FontSize="16" Classes="pixi-icon"/>
+                        <TextBlock Text="{TemplateBinding Content}" FontSize="16" Classes="pixi-icon" />
                     </Border>
                 </ControlTemplate>
             </Setter.Value>
@@ -43,9 +43,25 @@
     </Style>
 
     <Style Selector="CheckBox.ImageCheckBox:checked">
-        <Setter Property="Content" Value="{DynamicResource icon-eye}"/>
+        <Setter Property="Content" Value="{DynamicResource icon-eye}" />
+    </Style>
+    
+    <Style Selector="ToggleButton.PlayButton">
+        <Setter Property="Content" Value="{DynamicResource icon-play}" />
+        <Setter Property="Template">
+            <Setter.Value>
+                <ControlTemplate>
+                    <Border Cursor="Hand" Background="{TemplateBinding Background}">
+                        <TextBlock Text="{TemplateBinding Content}" FontSize="{TemplateBinding Width}" Classes="pixi-icon" />
+                    </Border>
+                </ControlTemplate>
+            </Setter.Value>
+        </Setter>
     </Style>
 
+    <Style Selector="ToggleButton.PlayButton:checked">
+        <Setter Property="Content" Value="{DynamicResource icon-pause}" />
+    </Style>
     <Style Selector="ToggleButton.ExpandCollapseToggleStyle">
 
     </Style>
@@ -94,7 +110,7 @@
                             BorderBrush="{TemplateBinding BorderBrush}"
                             BorderThickness="{TemplateBinding BorderThickness}">
                         <Border.Background>
-                            <ImageBrush Source="/Images/AnchorDot.png"/>
+                            <ImageBrush Source="/Images/AnchorDot.png" />
                         </Border.Background>
                         <Border.Transitions>
                             <Transitions>
@@ -117,17 +133,17 @@
     <Style Selector="ToggleButton.AnchorPointToggleButtonStyle:checked">
         <Setter Property="BorderBrush" Value="{DynamicResource ThemeHighlightForegroundBrush}" />
     </Style>
-    
+
     <Style Selector="Border.KeyBorder">
-        <Setter Property="BorderThickness" Value="1"/>
-        <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderMidBrush}"/>
-        <Setter Property="Background" Value="{DynamicResource ThemeControlMidBrush}"/>
-        <Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}"/>
-        <Setter Property="Padding" Value="7, 0"/>
-        <Setter Property="Margin" Value="0,3,5,3"/>
+        <Setter Property="BorderThickness" Value="1" />
+        <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderMidBrush}" />
+        <Setter Property="Background" Value="{DynamicResource ThemeControlMidBrush}" />
+        <Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
+        <Setter Property="Padding" Value="7, 0" />
+        <Setter Property="Margin" Value="0,3,5,3" />
     </Style>
 
     <Style Selector="Border.KeyBorderLast">
-        <Setter Property="Margin" Value="0, 3"/>
+        <Setter Property="Margin" Value="0, 3" />
     </Style>
 </Styles>

+ 13 - 2
src/PixiEditor.AvaloniaUI/Styles/Templates/ConnectionView.axaml

@@ -7,9 +7,20 @@
         <Setter Property="IsHitTestVisible" Value="False"/>
         <Setter Property="Template">
             <ControlTemplate>
-                    <nodes:ConnectionLine Color="{DynamicResource ThemeForegroundBrush}" Thickness="2"
+                    <nodes:ConnectionLine Thickness="2"
                           StartPoint="{Binding StartPoint, RelativeSource={RelativeSource TemplatedParent}}"
-                          EndPoint="{Binding EndPoint, RelativeSource={RelativeSource TemplatedParent}}" />
+                          EndPoint="{Binding EndPoint, RelativeSource={RelativeSource TemplatedParent}}">
+                        <nodes:ConnectionLine.LineBrush>
+                            <LinearGradientBrush>
+                                <LinearGradientBrush.GradientStops>
+                                    <GradientStop Offset="0" Color="#555" />
+                                    <GradientStop Offset=".05" Color="{Binding InputProperty.SocketBrush.Color, RelativeSource={RelativeSource TemplatedParent}}" />
+                                    <GradientStop Offset="0.95" Color="{Binding OutputProperty.SocketBrush.Color, RelativeSource={RelativeSource TemplatedParent}}" />
+                                    <GradientStop Offset="1" Color="#555" />
+                                </LinearGradientBrush.GradientStops>
+                            </LinearGradientBrush>
+                        </nodes:ConnectionLine.LineBrush>
+                    </nodes:ConnectionLine>
             </ControlTemplate>
         </Setter>
     </ControlTheme>

+ 21 - 0
src/PixiEditor.AvaloniaUI/Styles/Templates/NodeFrameView.axaml

@@ -0,0 +1,21 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                    xmlns:nodes="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes"
+                    xmlns:visuals="clr-namespace:PixiEditor.AvaloniaUI.Views.Visuals"
+                    xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input">
+    <ControlTheme TargetType="nodes:NodeFrameView" x:Key="{x:Type nodes:NodeFrameView}">
+        <Setter Property="Template">
+            <Setter.Value>
+                <ControlTemplate>
+                    <Grid Width="{Binding Size.X, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeFrameView}}">
+                        <Rectangle Fill="{TemplateBinding Background}"
+                                   Stroke="{TemplateBinding BorderBrush}"
+                                   StrokeThickness="2" RadiusX="10" RadiusY="10"
+                                   Width="{Binding Size.X, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeFrameView}}"
+                                   Height="{Binding Size.Y, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeFrameView}}" />
+                    </Grid>
+                </ControlTemplate>
+            </Setter.Value>
+        </Setter>
+    </ControlTheme>
+</ResourceDictionary>

+ 52 - 1
src/PixiEditor.AvaloniaUI/Styles/Templates/NodeGraphView.axaml

@@ -1,6 +1,7 @@
 <ResourceDictionary xmlns="https://github.com/avaloniaui"
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-                    xmlns:nodes="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes">
+                    xmlns:nodes="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes"
+                    xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters">
     <ControlTheme TargetType="nodes:NodeGraphView" x:Key="{x:Type nodes:NodeGraphView}">
         <Setter Property="ZoomMode" Value="Move" />
         <Setter Property="Template">
@@ -39,6 +40,8 @@
                                         Node="{Binding}"
                                         DisplayName="{Binding NodeName}"
                                         Inputs="{Binding Inputs}"
+                                        BorderBrush="{Binding InternalName, Converter={converters:NodeInternalNameToStyleConverter}, ConverterParameter='BorderBrush'}"
+                                        BorderThickness="2"
                                         Outputs="{Binding Outputs}"
                                         IsSelected="{Binding IsSelected}"
                                         SelectNodeCommand="{Binding SelectNodeCommand,
@@ -87,6 +90,54 @@
                                 </DataTemplate>
                             </ItemsControl.ItemTemplate>
                         </ItemsControl>
+                    <ItemsControl
+                        ZIndex="-1"
+                        Name="PART_Frames"
+                        ItemsSource="{Binding NodeGraph.Frames, RelativeSource={RelativeSource TemplatedParent}}">
+                            <ItemsControl.ItemsPanel>
+                                <ItemsPanelTemplate>
+                                    <Canvas RenderTransformOrigin="0, 0">
+                                        <Canvas.RenderTransform>
+                                            <TransformGroup>
+                                                <ScaleTransform
+                                                    ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                    ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                                <TranslateTransform
+                                                    X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                    Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                            </TransformGroup>
+                                        </Canvas.RenderTransform>
+                                    </Canvas>
+                                </ItemsPanelTemplate>
+                            </ItemsControl.ItemsPanel>
+                            <ItemsControl.ItemTemplate>
+                                <DataTemplate>
+                                    <nodes:NodeFrameView
+                                        TopLeft="{Binding TopLeft}"
+                                        BottomRight="{Binding BottomRight}"
+                                        Size="{Binding Size}">
+                                        <nodes:NodeFrameView.Background>
+                                            <MultiBinding Converter="{converters:UnsetSkipMultiConverter}">
+                                                 <Binding Path="InternalName" Converter="{converters:NodeInternalNameToStyleConverter}" ConverterParameter="BackgroundBrush" />
+                                                 <DynamicResource ResourceKey="NodeFrameBackgroundBrush"/>
+                                            </MultiBinding>
+                                        </nodes:NodeFrameView.Background>
+                                        <nodes:NodeFrameView.BorderBrush>
+                                            <MultiBinding Converter="{converters:UnsetSkipMultiConverter}">
+                                                <Binding Path="InternalName" Converter="{converters:NodeInternalNameToStyleConverter}" ConverterParameter="BorderBrush" />
+                                                <DynamicResource ResourceKey="NodeFrameBorderBrush"/>
+                                            </MultiBinding>
+                                        </nodes:NodeFrameView.BorderBrush>
+                                    </nodes:NodeFrameView>
+                                </DataTemplate>
+                            </ItemsControl.ItemTemplate>
+                            <ItemsControl.ItemContainerTheme>
+                                <ControlTheme TargetType="ContentPresenter">
+                                    <Setter Property="Canvas.Left" Value="{Binding TopLeft.X}" />
+                                    <Setter Property="Canvas.Top" Value="{Binding TopLeft.Y}" />
+                                </ControlTheme>
+                            </ItemsControl.ItemContainerTheme>
+                        </ItemsControl>
                 </Grid>
             </ControlTemplate>
         </Setter>

+ 11 - 11
src/PixiEditor.AvaloniaUI/Styles/Templates/NodePropertyViewTemplate.axaml

@@ -6,24 +6,24 @@
         <Setter Property="ClipToBounds" Value="False" />
         <Setter Property="Template">
             <ControlTemplate>
-                <Grid Margin="-5, 2">
-                    <Grid.ColumnDefinitions>10*, *, 10*</Grid.ColumnDefinitions>
+                <Grid Margin="-5, 2" ColumnDefinitions="15, *, 15" MinHeight="18">
                     <properties:NodeSocket Name="PART_InputSocket"
+                                           ClipToBounds="False"
                                            Node="{Binding DataContext.Node, RelativeSource={RelativeSource TemplatedParent}}"
-                                           Label="{Binding DataContext.DisplayName, RelativeSource={RelativeSource TemplatedParent}}"
                                            SocketBrush="{Binding DataContext.SocketBrush, RelativeSource={RelativeSource TemplatedParent}}"
-                                           IsVisible="{Binding DataContext.IsInput, 
-                    RelativeSource={RelativeSource TemplatedParent}}">
+                                           IsVisible="{Binding DataContext.IsInput, RelativeSource={RelativeSource TemplatedParent}}"
+                                           IsFunc="{Binding DataContext.IsFunc, RelativeSource={RelativeSource TemplatedParent}}">
                         <properties:NodeSocket.IsInput>
                             <x:Boolean>True</x:Boolean>
                         </properties:NodeSocket.IsInput>
                     </properties:NodeSocket>
-                    <ContentPresenter Grid.Column="1" Content="{TemplateBinding Content}" />
-                    <properties:NodeSocket Grid.Column="2" Name="PART_OutputSocket"
-                                           Label="{Binding DataContext.DisplayName, RelativeSource={RelativeSource TemplatedParent}}"
-                                           IsVisible="{Binding !DataContext.IsInput,
-                    RelativeSource={RelativeSource TemplatedParent}}"
-                                           SocketBrush="{Binding DataContext.SocketBrush, RelativeSource={RelativeSource TemplatedParent}}">
+                    <ContentPresenter Grid.Column="1" VerticalAlignment="Top" Content="{TemplateBinding Content}" />
+                    <properties:NodeSocket Name="PART_OutputSocket"
+                                           ClipToBounds="False" HorizontalAlignment="Right"  Grid.Column="2"
+                                           Node="{Binding DataContext.Node, RelativeSource={RelativeSource TemplatedParent}}"
+                                           SocketBrush="{Binding DataContext.SocketBrush, RelativeSource={RelativeSource TemplatedParent}}"
+                                           IsVisible="{Binding !DataContext.IsInput,RelativeSource={RelativeSource TemplatedParent}}"
+                                           IsFunc="{Binding DataContext.IsFunc, RelativeSource={RelativeSource TemplatedParent}}">
                         <properties:NodeSocket.IsInput>
                             <x:Boolean>False</x:Boolean>
                         </properties:NodeSocket.IsInput>

+ 11 - 6
src/PixiEditor.AvaloniaUI/Styles/Templates/NodeSocket.axaml

@@ -1,15 +1,20 @@
 <ResourceDictionary xmlns="https://github.com/avaloniaui"
                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-                    xmlns:nodes="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes"
-                    xmlns:properties="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes.Properties"
-                    xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions">
+                    xmlns:properties="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes.Properties">
     <ControlTheme TargetType="properties:NodeSocket" x:Key="{x:Type properties:NodeSocket}">
         <Setter Property="Template">
             <ControlTemplate>
                 <StackPanel Orientation="Horizontal" VerticalAlignment="Center">
-                    <TextBlock VerticalAlignment="Center" Margin="0, 0, 2, 0" ui:Translator.Key="{TemplateBinding Label}" IsVisible="{Binding !IsInput, RelativeSource={RelativeSource TemplatedParent}}"/>
-                    <Ellipse Width="10" Height="10" Fill="{TemplateBinding SocketBrush}" Name="PART_ConnectPort"/>
-                    <TextBlock VerticalAlignment="Center" Margin="2, 0, 0, 0" ui:Translator.Key="{TemplateBinding Label}" IsVisible="{Binding IsInput, RelativeSource={RelativeSource TemplatedParent}}"/>
+                    <Grid Name="PART_ConnectPort">
+                        <Ellipse Width="10" Height="10" 
+                                 Fill="{TemplateBinding SocketBrush}" 
+                                 IsVisible="{Binding !IsFunc, RelativeSource={RelativeSource TemplatedParent}}"/>
+                        <Rectangle Width="10" Height="10"
+                                   RadiusX="2" RadiusY="2"
+                                   Fill="{TemplateBinding SocketBrush}"
+                                   RenderTransform="rotate(45deg) scale(0.89)"
+                                   IsVisible="{Binding IsFunc, RelativeSource={RelativeSource TemplatedParent}}"/>
+                    </Grid>
                 </StackPanel>
             </ControlTemplate>
         </Setter>

+ 8 - 12
src/PixiEditor.AvaloniaUI/Styles/Templates/NodeView.axaml

@@ -16,6 +16,7 @@
                         BorderBrush="{TemplateBinding BorderBrush}"
                         BorderThickness="{TemplateBinding BorderThickness}"
                         CornerRadius="{TemplateBinding CornerRadius}"
+                        MinWidth="200"
                         Margin="{TemplateBinding Margin}"
                         Name="RootBorder">
                     <Border.Effect>
@@ -34,30 +35,26 @@
                                            FontWeight="Bold" />
                             </Border>
                             <Border Grid.Row="1" Background="{DynamicResource ThemeControlMidBrush}">
-                                <Grid>
-                                    <Grid.ColumnDefinitions>
-                                        <ColumnDefinition Width="0.5*" />
-                                        <ColumnDefinition Width="0.5*" />
-                                    </Grid.ColumnDefinitions>
-
-                                    <ItemsControl ItemsSource="{TemplateBinding Inputs}" ClipToBounds="False">
+                                <StackPanel>
+                                    <ItemsControl ItemsSource="{TemplateBinding Outputs}"
+                                                  ClipToBounds="False">
                                         <ItemsControl.ItemContainerTheme>
                                             <ControlTheme TargetType="ContentPresenter">
                                                 <Setter Property="DataContext" Value="." />
                                             </ControlTheme>
                                         </ItemsControl.ItemContainerTheme>
                                     </ItemsControl>
-                                    <ItemsControl Grid.Column="1" ItemsSource="{TemplateBinding Outputs}"
-                                                  ClipToBounds="False">
+                                    <ItemsControl ItemsSource="{TemplateBinding Inputs}" ClipToBounds="False">
                                         <ItemsControl.ItemContainerTheme>
                                             <ControlTheme TargetType="ContentPresenter">
                                                 <Setter Property="DataContext" Value="." />
                                             </ControlTheme>
                                         </ItemsControl.ItemContainerTheme>
                                     </ItemsControl>
-                                </Grid>
+                                </StackPanel>
                             </Border>
-                            <Border CornerRadius="0, 0, 4.5, 4.5" Grid.Row="2" ClipToBounds="True">
+                            <Border IsVisible="{Binding !!ResultPreview, RelativeSource={RelativeSource TemplatedParent}}"
+                                CornerRadius="0, 0, 4.5, 4.5" Grid.Row="2" ClipToBounds="True">
                                 <visuals:SurfaceControl Width="200" Height="200"
                                                         Surface="{TemplateBinding ResultPreview}"
                                                         RenderOptions.BitmapInterpolationMode="None">
@@ -74,7 +71,6 @@
 
         <Style Selector="^:selected /template/ Border#RootBorder">
             <Setter Property="BorderBrush" Value="{DynamicResource ThemeAccent2Brush}" />
-            <Setter Property="BorderThickness" Value="1" />
         </Style>
     </ControlTheme>
 </ResourceDictionary>

+ 1 - 1
src/PixiEditor.AvaloniaUI/Styles/Templates/Timeline.axaml

@@ -36,7 +36,7 @@
                         <input:NumberInput Min="1"
                                            Value="{Binding Fps, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
                         <Panel>
-                            <ToggleButton HorizontalAlignment="Center" Content="Play" Name="PART_PlayToggle" />
+                            <ToggleButton Margin="0, 5" Width="24" HorizontalAlignment="Center" Classes="PlayButton" Name="PART_PlayToggle" />
                         </Panel>
                     </DockPanel>
 

+ 143 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Dock/ChannelsDockViewModel.cs

@@ -0,0 +1,143 @@
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PixiEditor.AvaloniaUI.Models.DocumentModels;
+using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Dock;
+
+internal class ChannelsDockViewModel : DockableViewModel
+{
+    public const string TabId = "ChannelsDock";
+
+    public override string Id => TabId;
+    public override string Title => new LocalizedString("CHANNELS_DOCK_TITLE");
+    public override bool CanFloat => true;
+    public override bool CanClose => true;
+
+    public WindowViewModel WindowViewModel { get; }
+
+    private ViewportWindowViewModel? _activeViewport;
+
+    public ViewportWindowViewModel? ActiveViewport
+    {
+        get => _activeViewport;
+        set => SetProperty(ref _activeViewport, value);
+    }
+
+    private ViewportColorChannels Channels
+    {
+        get => ActiveViewport?.Channels ?? ViewportColorChannels.Default;
+        set
+        {
+            if (ActiveViewport != null)
+            {
+                ActiveViewport.Channels = value;
+            }
+        }
+    }
+
+    public ChannelsDockViewModel(WindowViewModel windowViewModel)
+    {
+        WindowViewModel = windowViewModel;
+        windowViewModel.ActiveViewportChanged += WindowViewModelOnActiveViewportChanged;
+    }
+
+    private void WindowViewModelOnActiveViewportChanged(object? sender, ViewportWindowViewModel e)
+    {
+        if (ActiveViewport != null)
+        {
+            ActiveViewport.PropertyChanged -= ActiveViewportOnPropertyChanged;
+        }
+
+        ActiveViewport = e;
+        ActiveViewport.PropertyChanged += ActiveViewportOnPropertyChanged;
+    }
+
+    private void ActiveViewportOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName != nameof(ViewportWindowViewModel.Channels))
+        {
+            return;
+        }
+
+        OnPropertyChanged(nameof(IsRedVisible));
+        OnPropertyChanged(nameof(IsGreenVisible));
+        OnPropertyChanged(nameof(IsBlueVisible));
+        OnPropertyChanged(nameof(IsAlphaVisible));
+        
+        OnPropertyChanged(nameof(IsRedSolo));
+        OnPropertyChanged(nameof(IsGreenSolo));
+        OnPropertyChanged(nameof(IsBlueSolo));
+        OnPropertyChanged(nameof(IsAlphaSolo));
+    }
+
+    public bool IsRedVisible
+    {
+        get => Channels.IsVisiblyVisible(ColorChannel.Red);
+        set => SetVisible(ColorChannel.Red, value);
+    }
+
+    public bool IsRedSolo
+    {
+        get => Channels.IsSolo(ColorChannel.Red);
+        set => SetSolo(ColorChannel.Red, value);
+    }
+
+    public bool IsGreenVisible
+    {
+        get => Channels.IsVisiblyVisible(ColorChannel.Green);
+        set => SetVisible(ColorChannel.Green, value);
+    }
+
+    public bool IsGreenSolo
+    {
+        get => Channels.IsSolo(ColorChannel.Green);
+        set => SetSolo(ColorChannel.Green, value);
+    }
+
+    public bool IsBlueVisible
+    {
+        get => Channels.IsVisiblyVisible(ColorChannel.Blue);
+        set => SetVisible(ColorChannel.Blue, value);
+    }
+
+    public bool IsBlueSolo
+    {
+        get => Channels.IsSolo(ColorChannel.Blue);
+        set => SetSolo(ColorChannel.Blue, value);
+    }
+
+    public bool IsAlphaVisible
+    {
+        get => Channels.IsVisiblyVisible(ColorChannel.Alpha);
+        set => SetVisible(ColorChannel.Alpha, value);
+    }
+
+    public bool IsAlphaSolo
+    {
+        get => Channels.IsSolo(ColorChannel.Alpha);
+        set => SetSolo(ColorChannel.Alpha, value);
+    }
+
+    private void SetVisible(ColorChannel channel, bool value)
+    {
+        var mode = Channels.GetModeForChannel(channel);
+
+        if (mode.IsSolo && !value)
+        {
+            Channels = Channels.WithModeForChannel(channel, _ => new ColorChannelMode(), false);
+        }
+        else
+        {
+            Channels = Channels.WithModeForChannel(channel, x => x.WithVisible(value), value);
+        }
+    }
+
+    private void SetSolo(ColorChannel channel, bool value)
+    {
+        var mode = Channels.GetModeForChannel(channel);
+
+        Channels = Channels.WithModeForChannel(channel, x => x.WithSolo(value), value);
+    }
+}

+ 12 - 1
src/PixiEditor.AvaloniaUI/ViewModels/Dock/LayersDockViewModel.cs

@@ -1,5 +1,6 @@
 using Avalonia.Media;
 using PixiDocks.Core.Docking;
+using PixiDocks.Core.Docking.Events;
 using PixiEditor.AvaloniaUI.Helpers.Converters;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.Extensions.Common.Localization;
@@ -7,7 +8,7 @@ using PixiEditor.UI.Common.Fonts;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Dock;
 
-internal class LayersDockViewModel : DockableViewModel
+internal class LayersDockViewModel : DockableViewModel, IDockableSelectionEvents
 {
     public const string TabId = "Layers";
     public override string Id => TabId;
@@ -41,4 +42,14 @@ internal class LayersDockViewModel : DockableViewModel
     {
         ActiveDocument = e.NewDocument;
     }
+
+    void IDockableSelectionEvents.OnSelected()
+    {
+        documentManager.Owner.ShortcutController.OverwriteContext(GetType());
+    }
+
+    void IDockableSelectionEvents.OnDeselected()
+    {
+        documentManager.Owner.ShortcutController.ClearContext(GetType());
+    }
 }

+ 4 - 1
src/PixiEditor.AvaloniaUI/ViewModels/Dock/LayoutManager.cs

@@ -40,6 +40,7 @@ internal class LayoutManager
         TimelineDockViewModel timelineDockViewModel = new(mainViewModel.DocumentManagerSubViewModel);
         
         NodeGraphDockViewModel nodeGraphDockViewModel = new(mainViewModel.DocumentManagerSubViewModel);
+        ChannelsDockViewModel channelsDockDockViewModel = new(mainViewModel.WindowSubViewModel);
 
         RegisterDockable(layersDockViewModel);
         RegisterDockable(colorPickerDockViewModel);
@@ -49,6 +50,7 @@ internal class LayoutManager
         RegisterDockable(paletteViewerDockViewModel);
         RegisterDockable(timelineDockViewModel);
         RegisterDockable(nodeGraphDockViewModel);
+        RegisterDockable(channelsDockDockViewModel);
         
         DefaultLayout = new LayoutTree
         {
@@ -91,7 +93,8 @@ internal class LayoutManager
                         SplitDirection = DockingDirection.Bottom,
                         Second = new DockableArea
                         {
-                            Id = "LayersArea", ActiveDockable = DockContext.CreateDockable(layersDockViewModel)
+                            Id = "LayersArea",
+                            Dockables = [ DockContext.CreateDockable(layersDockViewModel), DockContext.CreateDockable(channelsDockDockViewModel) ]
                         },
                     },
                     FirstSize = 0.66,

+ 15 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Dock/NodeGraphDockViewModel.cs

@@ -1,9 +1,12 @@
-using PixiEditor.AvaloniaUI.ViewModels.Document;
+using Avalonia.Input;
+using PixiDocks.Core.Docking.Events;
+using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.Extensions.Common.Localization;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Dock;
 
-internal class NodeGraphDockViewModel(DocumentManagerViewModel document) : DockableViewModel
+internal class NodeGraphDockViewModel(DocumentManagerViewModel document) : DockableViewModel, IDockableSelectionEvents
 {
     public const string TabId = "NodeGraph";
 
@@ -17,4 +20,14 @@ internal class NodeGraphDockViewModel(DocumentManagerViewModel document) : Docka
         get => document;
         set => SetProperty(ref document, value);
     }
+    
+    void IDockableSelectionEvents.OnSelected()
+    {
+        DocumentManagerSubViewModel.Owner.ShortcutController.OverwriteContext(this.GetType());
+    }
+
+    void IDockableSelectionEvents.OnDeselected()
+    {
+        DocumentManagerSubViewModel.Owner.ShortcutController.ClearContext(this.GetType());
+    }
 }

+ 15 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Dock/TimelineDockViewModel.cs

@@ -1,9 +1,11 @@
-using PixiEditor.AvaloniaUI.ViewModels.Document;
+using PixiDocks.Core.Docking.Events;
+using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.UI.Common.Fonts;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Dock;
 
-internal class TimelineDockViewModel : DockableViewModel
+internal class TimelineDockViewModel : DockableViewModel, IDockableSelectionEvents
 {
     public const string TabId = "Timeline";
 
@@ -23,5 +25,16 @@ internal class TimelineDockViewModel : DockableViewModel
     public TimelineDockViewModel(DocumentManagerViewModel documentManagerViewModel)
     {
         DocumentManagerSubViewModel = documentManagerViewModel;
+        TabCustomizationSettings.Icon = PixiPerfectIcons.ToIcon(PixiPerfectIcons.Timeline);
+    }
+
+    void IDockableSelectionEvents.OnSelected()
+    {
+        documentManagerSubViewModel.Owner.ShortcutController?.OverwriteContext(GetType());
+    }
+
+    void IDockableSelectionEvents.OnDeselected()
+    {
+        documentManagerSubViewModel.Owner.ShortcutController?.ClearContext(GetType());
     }
 }

+ 23 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Document/AnimationDataViewModel.cs

@@ -6,6 +6,7 @@ using PixiEditor.AvaloniaUI.Models.DocumentModels;
 using PixiEditor.AvaloniaUI.Models.DocumentPassthroughActions;
 using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Document;
@@ -17,8 +18,11 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
     public DocumentViewModel Document { get; }
     protected DocumentInternalParts Internals { get; }
     public IReadOnlyCollection<IKeyFrameHandler> KeyFrames => keyFrames;
+    
+    public IReadOnlyCollection<IKeyFrameHandler> AllKeyFrames => allKeyFrames;
 
     private KeyFrameCollection keyFrames = new KeyFrameCollection();
+    private List<IKeyFrameHandler> allKeyFrames = new List<IKeyFrameHandler>();
 
     public int ActiveFrameBindable
     {
@@ -41,12 +45,18 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         {
             _frameRate = value;
             OnPropertyChanged(nameof(FrameRate));
+            OnPropertyChanged(nameof(DefaultEndFrame));
         }
     }
 
     public int FirstFrame => keyFrames.Count > 0 ? keyFrames.Min(x => x.StartFrameBindable) : 0;
-    public int LastFrame => keyFrames.Count > 0 ? keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable) : 0;
-    public int FramesCount => LastFrame - FirstFrame + 1; 
+    public int LastFrame => keyFrames.Count > 0 ? keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable) 
+        : DefaultEndFrame;
+    public int FramesCount => LastFrame - FirstFrame + 1;
+    
+    private double ActiveNormalizedTime => (double)(ActiveFrameBindable - FirstFrame) / FramesCount;
+
+    private int DefaultEndFrame => FrameRate; // 1 second
 
     public AnimationDataViewModel(DocumentViewModel document, DocumentInternalParts internals)
     {
@@ -54,6 +64,8 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         Internals = internals;
     }
 
+    public KeyFrameTime ActiveFrameTime => new KeyFrameTime(ActiveFrameBindable, ActiveNormalizedTime);
+
     public void CreateRasterKeyFrame(Guid targetLayerGuid, int frame, Guid? toCloneFrom = null, int? frameToCopyFrom = null)
     {
         if (!Document.UpdateableChangeActive)
@@ -139,6 +151,11 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
         }
 
         keyFrames.NotifyCollectionChanged(NotifyCollectionChangedAction.Add, (KeyFrameViewModel)keyFrame);
+
+        if (!allKeyFrames.Contains(keyFrame))
+        {
+            allKeyFrames.Add(keyFrame);
+        }
     }
 
     public void RemoveKeyFrame(Guid keyFrameId)
@@ -148,6 +165,8 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
             parent.Children.Remove(frame);
             keyFrames.NotifyCollectionChanged(NotifyCollectionChangedAction.Remove, (KeyFrameViewModel)frame);
         });
+        
+        allKeyFrames.RemoveAll(x => x.Id == keyFrameId);
     }
 
     public void AddSelectedKeyFrame(Guid keyFrameId)
@@ -185,6 +204,8 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
                 parent.Children.Remove(frame);
                 framesToRemove.Add((KeyFrameViewModel)frame);
             });
+            
+            allKeyFrames.RemoveAll(x => x.Id == keyFrame);
         }
         
         keyFrames.NotifyCollectionChanged(NotifyCollectionChangedAction.Remove, framesToRemove);

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

@@ -155,7 +155,9 @@ internal class DocumentManagerViewModel : SubViewModel<ViewModelMain>, IDocument
         ActiveDocument.EventInlet.OnSymmetryDragEnded(dir);
     }
 
-    [Command.Basic("PixiEditor.Document.DeletePixels", "DELETE_PIXELS", "DELETE_PIXELS_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.Delete, 
+    [Command.Basic("PixiEditor.Document.DeletePixels", "DELETE_PIXELS", "DELETE_PIXELS_DESCRIPTIVE", 
+        CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.Delete,
+        ShortcutContext = typeof(ViewportWindowViewModel),
         Icon = PixiPerfectIcons.Eraser,
         MenuItemPath = "EDIT/DELETE_SELECTED_PIXELS", MenuItemOrder = 6)]
     public void DeletePixels()

+ 4 - 4
src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.Serialization.cs

@@ -112,7 +112,7 @@ internal partial class DocumentViewModel
             BlendMode = (BlendMode)(int)folder.BlendMode.Value,
             Enabled = folder.IsVisible.Value,
             Opacity = folder.Opacity.Value,
-            ClipToMemberBelow = folder.ClipToMemberBelow.Value,
+            ClipToMemberBelow = folder.ClipToPreviousMember.Value,
             Mask = GetMask(folder.Mask.Value, folder.MaskIsVisible.Value)
         };
     }
@@ -122,13 +122,13 @@ internal partial class DocumentViewModel
         var result = document.GetLayerRasterizedImage(layer.Id, 0);
 
         var tightBounds = document.GetChunkAlignedLayerBounds(layer.Id, 0);
-        using var data = result?.DrawingSurface.Snapshot().Encode();
+        using var data = result?.Encode();
         byte[] bytes = data?.AsSpan().ToArray();
         var serializable = new ImageLayer
         {
-            Width = result?.Size.X ?? 0, Height = result?.Size.Y ?? 0, OffsetX = tightBounds?.X ?? 0, OffsetY = tightBounds?.Y ?? 0,
+            Width = result?.Width ?? 0, Height = result?.Height ?? 0, OffsetX = tightBounds?.X ?? 0, OffsetY = tightBounds?.Y ?? 0,
             Enabled = layer.IsVisible.Value, BlendMode = (BlendMode)(int)layer.BlendMode.Value, ImageBytes = bytes,
-            ClipToMemberBelow = layer.ClipToMemberBelow.Value, Name = layer.MemberName,
+            ClipToMemberBelow = layer.ClipToPreviousMember.Value, Name = layer.MemberName,
             Guid = layer.Id,
             LockAlpha = layer is ITransparencyLockable { LockTransparency: true },
             Opacity = layer.Opacity.Value, Mask = GetMask(layer.Mask.Value, layer.MaskIsVisible.Value)

+ 50 - 30
src/PixiEditor.AvaloniaUI/ViewModels/Document/DocumentViewModel.cs

@@ -26,6 +26,7 @@ using PixiEditor.AvaloniaUI.Views.Overlays.SymmetryOverlay;
 using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Actions.Generated;
 using PixiEditor.ChangeableDocument.Actions.Undo;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
@@ -151,6 +152,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     public DocumentStructureModule StructureHelper { get; }
     public DocumentToolsModule Tools { get; }
     public DocumentOperationsModule Operations { get; }
+    public DocumentRenderer Renderer { get; }
     public DocumentEventsModule EventInlet { get; }
 
     public ActionDisplayList ActionDisplays { get; } =
@@ -231,6 +233,8 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         PreviewSurface = new Surface(new VecI(previewSize.X, previewSize.Y));
 
         ReferenceLayerViewModel = new(this, Internals);
+
+        Renderer = new DocumentRenderer(Internals.Tracker.Document);
     }
 
     /// <summary>
@@ -261,16 +265,16 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         if (builderInstance.ReferenceLayer is { } refLayer)
         {
             acc.AddActions(new SetReferenceLayer_Action(refLayer.Shape, refLayer.ImageBgra8888Bytes.ToImmutableArray(),
-                    refLayer.ImageSize));
+                refLayer.ImageSize));
         }
 
         viewModel.Swatches = new ObservableCollection<PaletteColor>(builderInstance.Swatches);
         viewModel.Palette = new ObservableRangeCollection<PaletteColor>(builderInstance.Palette);
-        
+
         Guid outputNodeGuid = Guid.NewGuid();
-        
+
         acc.AddActions(new CreateNode_Action(typeof(OutputNode), outputNodeGuid));
-        
+
         AddMembers(outputNodeGuid, builderInstance.Children);
         AddAnimationData(builderInstance.AnimationData);
 
@@ -363,21 +367,24 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
                     {
                         rasterKeyFrameBuilder.Id = Guid.NewGuid();
                     }
-                    
+
                     acc.AddActions(
                         new CreateRasterKeyFrame_Action(
                             rasterKeyFrameBuilder.LayerGuid,
                             rasterKeyFrameBuilder.Id,
                             rasterKeyFrameBuilder.StartFrame, -1, default),
-                        new KeyFrameLength_Action(rasterKeyFrameBuilder.Id, rasterKeyFrameBuilder.StartFrame, rasterKeyFrameBuilder.Duration),
+                        new KeyFrameLength_Action(rasterKeyFrameBuilder.Id, rasterKeyFrameBuilder.StartFrame,
+                            rasterKeyFrameBuilder.Duration),
                         new EndKeyFrameLength_Action());
-                    
-                    PasteImage(rasterKeyFrameBuilder.LayerGuid, rasterKeyFrameBuilder.Surface, rasterKeyFrameBuilder.Surface.Surface.Size.X,
-                        rasterKeyFrameBuilder.Surface.Surface.Size.Y, 0, 0, false, rasterKeyFrameBuilder.StartFrame, rasterKeyFrameBuilder.Id);
-                    
+
+                    PasteImage(rasterKeyFrameBuilder.LayerGuid, rasterKeyFrameBuilder.Surface,
+                        rasterKeyFrameBuilder.Surface.Surface.Size.X,
+                        rasterKeyFrameBuilder.Surface.Surface.Size.Y, 0, 0, false, rasterKeyFrameBuilder.StartFrame,
+                        rasterKeyFrameBuilder.Id);
+
                     acc.AddFinishedActions();
                 }
-                else if(keyFrame is GroupKeyFrameBuilder groupKeyFrameBuilder)
+                else if (keyFrame is GroupKeyFrameBuilder groupKeyFrameBuilder)
                 {
                     AddAnimationData(groupKeyFrameBuilder.Children);
                 }
@@ -402,7 +409,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     /// Tries rendering the whole document
     /// </summary>
     /// <returns><see cref="Error"/> if the ChunkyImage was disposed, otherwise a <see cref="Surface"/> of the rendered document</returns>
-    public OneOf<Error, Surface> TryRenderWholeImage(int frame)
+    public OneOf<Error, Surface> TryRenderWholeImage(KeyFrameTime frameTime)
     {
         try
         {
@@ -413,13 +420,13 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
                 for (int j = 0; j < sizeInChunks.Y; j++)
                 {
                     // TODO: Implement this
-                    /*var maybeChunk = ChunkRenderer.MergeWholeStructure(new(i, j), ChunkResolution.Full,
-                        Internals.Tracker.Document.StructureRoot, frame);
+                    var maybeChunk = Renderer.RenderChunk(new(i, j), ChunkResolution.Full, frameTime);
                     if (maybeChunk.IsT1)
                         continue;
                     using Chunk chunk = maybeChunk.AsT0;
-                    finalSurface.DrawingSurface.Canvas.DrawSurface(chunk.Surface.DrawingSurface,
-                        i * ChunkyImage.FullChunkSize, j * ChunkyImage.FullChunkSize);*/
+                    finalSurface.DrawingSurface.Canvas.DrawSurface(
+                        chunk.Surface.DrawingSurface,
+                        i * ChunkyImage.FullChunkSize, j * ChunkyImage.FullChunkSize);
                 }
             }
 
@@ -454,7 +461,8 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         try
         {
             // TODO: Make sure it must be GetLayerImageAtFrame rather than Rasterize()
-            memberImageBounds = layer.GetLayerImageAtFrame(AnimationDataViewModel.ActiveFrameBindable).FindChunkAlignedMostUpToDateBounds();
+            memberImageBounds = layer.GetLayerImageAtFrame(AnimationDataViewModel.ActiveFrameBindable)
+                .FindChunkAlignedMostUpToDateBounds();
         }
         catch (ObjectDisposedException)
         {
@@ -476,7 +484,8 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         output.DrawingSurface.Canvas.ClipPath(clipPath);
         try
         {
-            layer.GetLayerImageAtFrame(AnimationDataViewModel.ActiveFrameBindable).DrawMostUpToDateRegionOn(bounds, ChunkResolution.Full, output.DrawingSurface, VecI.Zero);
+            layer.GetLayerImageAtFrame(AnimationDataViewModel.ActiveFrameBindable)
+                .DrawMostUpToDateRegionOn(bounds, ChunkResolution.Full, output.DrawingSurface, VecI.Zero);
         }
         catch (ObjectDisposedException)
         {
@@ -544,7 +553,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         return bitmap.GetSRGBPixel(new VecI((int)transformed.X, (int)transformed.Y));
     }
 
-    public Color PickColorFromCanvas(VecI pos, DocumentScope scope, int frame)
+    public Color PickColorFromCanvas(VecI pos, DocumentScope scope, KeyFrameTime frameTime)
     {
         // there is a tiny chance that the image might get disposed by another thread
         try
@@ -554,28 +563,25 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
             if (scope == DocumentScope.AllLayers)
             {
                 VecI chunkPos = OperationHelper.GetChunkPos(pos, ChunkyImage.FullChunkSize);
-                /*return ChunkRenderer.MergeWholeStructure(chunkPos, ChunkResolution.Full,
-                        Internals.Tracker.Document.StructureRoot, frame, new RectI(pos, VecI.One))
-                    .Match<Color>(
-                        (Chunk chunk) =>
+                return Renderer.RenderChunk(chunkPos, ChunkResolution.Full,
+                        frameTime)
+                    .Match(
+                        chunk =>
                         {
                             VecI posOnChunk = pos - chunkPos * ChunkyImage.FullChunkSize;
                             var color = chunk.Surface.GetSRGBPixel(posOnChunk);
                             chunk.Dispose();
                             return color;
                         },
-                        _ => Colors.Transparent
-                    );*/
-                // TODO: Implement this
-                return Colors.Transparent;
+                        _ => Colors.Transparent);
             }
 
             if (SelectedStructureMember is not LayerViewModel layerVm)
                 return Colors.Transparent;
             IReadOnlyStructureNode? maybeMember = Internals.Tracker.Document.FindMember(layerVm.Id);
-            if (maybeMember is not IReadOnlyLayerNode layer)
+            if (maybeMember is not IReadOnlyImageNode layer)
                 return Colors.Transparent;
-            return layer.Execute(frame).GetMostUpToDatePixel(pos);
+            return layer.GetLayerImageAtFrame(frameTime.Frame).GetMostUpToDatePixel(pos);
         }
         catch (ObjectDisposedException)
         {
@@ -746,7 +752,9 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         Image[] images = new Image[framesCount];
         for (int i = firstFrame; i < lastFrame; i++)
         {
-            var surface = TryRenderWholeImage(i);
+            double normalizedTime = (double)(i - firstFrame) / framesCount;
+            KeyFrameTime frameTime = new KeyFrameTime(i, normalizedTime);
+            var surface = TryRenderWholeImage(frameTime);
             if (surface.IsT0)
             {
                 continue;
@@ -840,4 +848,16 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
             File.Delete(file);
         }
     }
+
+    public void Dispose()
+    {
+        foreach (var (_, surface) in Surfaces)
+        {
+            surface.Dispose();
+        }
+
+        PreviewSurface.Dispose();
+        Internals.Tracker.Dispose();
+        Internals.Tracker.Document.Dispose();
+    }
 }

+ 80 - 6
src/PixiEditor.AvaloniaUI/ViewModels/Document/NodeGraphViewModel.cs

@@ -1,10 +1,16 @@
 using System.Collections.ObjectModel;
+using System.Reflection;
+using Avalonia.Input;
+using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
 using PixiEditor.AvaloniaUI.Models.DocumentModels;
 using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.ViewModels.Nodes;
 using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.Numerics;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Document;
@@ -14,6 +20,7 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
     public DocumentViewModel DocumentViewModel { get; }
     public ObservableCollection<INodeHandler> AllNodes { get; } = new();
     public ObservableCollection<NodeConnectionViewModel> Connections { get; } = new();
+    public ObservableCollection<NodeFrameViewModelBase> Frames { get; } = new();
     public StructureTree StructureTree { get; } = new();
     public INodeHandler? OutputNode { get; private set; }
 
@@ -48,6 +55,32 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
         StructureTree.Update(this);
     }
 
+    public void AddFrame(Guid frameId, IEnumerable<Guid> nodes)
+    {
+        var frame = new NodeFrameViewModel(frameId, AllNodes.Where(x => nodes.Contains(x.Id)));
+
+        Frames.Add(frame);
+    }
+
+    public void AddZone(Guid frameId, string internalName, Guid startId, Guid endId)
+    {
+        var start = AllNodes.First(x => x.Id == startId);
+        var end = AllNodes.First(x => x.Id == endId);
+
+        var zone = new NodeZoneViewModel(frameId, internalName, start, end);
+
+        Frames.Add(zone);
+    }
+
+    public void RemoveFrame(Guid guid)
+    {
+        var frame = Frames.FirstOrDefault(x => x.Id == guid);
+
+        if (frame == null) return;
+
+        Frames.Remove(frame);
+    }
+
     public void SetConnection(NodeConnectionViewModel connection)
     {
         var existingInputConnection = Connections.FirstOrDefault(x => x.InputProperty == connection.InputProperty);
@@ -147,6 +180,11 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
         Internals.ActionAccumulator.AddActions(new NodePosition_Action(node.Id, newPos));
     }
 
+    public void UpdatePropertyValue(INodeHandler node, string property, object? value)
+    {
+        Internals.ActionAccumulator.AddFinishedActions(new UpdatePropertyValue_Action(node.Id, property, value));
+    }
+
     public void EndChangeNodePosition()
     {
         Internals.ActionAccumulator.AddFinishedActions(new EndNodePosition_Action());
@@ -154,7 +192,43 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
 
     public void CreateNode(Type nodeType)
     {
-        Internals.ActionAccumulator.AddFinishedActions(new CreateNode_Action(nodeType, Guid.NewGuid()));
+        IAction change;
+
+        PairNodeAttribute? pairAttribute = nodeType.GetCustomAttribute<PairNodeAttribute>(true);
+        if (pairAttribute != null)
+        {
+            change = new CreateNodePair_Action(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), nodeType);
+        }
+        else
+        {
+            change = new CreateNode_Action(nodeType, Guid.NewGuid());
+        }
+
+        Internals.ActionAccumulator.AddFinishedActions(change);
+    }
+
+    public void RemoveNodes(Guid[] selectedNodes)
+    {
+        IAction[] actions = new IAction[selectedNodes.Length];
+        
+        for (int i = 0; i < selectedNodes.Length; i++)
+        {
+            actions[i] = new DeleteNode_Action(selectedNodes[i]);
+        }
+        
+        Internals.ActionAccumulator.AddFinishedActions(actions);
+    }
+
+    // TODO: Remove this
+    public void CreateNodeFrameAroundEverything()
+    {
+        CreateNodeFrame(AllNodes);
+    }
+
+    public void CreateNodeFrame(IEnumerable<INodeHandler> nodes)
+    {
+        Internals.ActionAccumulator.AddFinishedActions(new CreateNodeFrame_Action(Guid.NewGuid(),
+            nodes.Select(x => x.Id)));
     }
 
     public void ConnectProperties(INodePropertyHandler? start, INodePropertyHandler? end)
@@ -166,7 +240,7 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
 
         var input = start?.IsInput == true ? start : end;
         var output = start?.IsInput == false ? start : end;
-        
+
         if (input == null && output != null)
         {
             input = output.ConnectedInputs.FirstOrDefault();
@@ -187,10 +261,10 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
 
         if (input == null) return;
 
-        IAction action = input != null && output != null ?
-            new ConnectProperties_Action(inputNode.Id, outputNode.Id, inputProperty, outputProperty) :
-            new DisconnectProperty_Action(inputNode.Id, inputProperty);
-        
+        IAction action = input != null && output != null
+            ? new ConnectProperties_Action(inputNode.Id, outputNode.Id, inputProperty, outputProperty)
+            : new DisconnectProperty_Action(inputNode.Id, inputProperty);
+
         Internals.ActionAccumulator.AddFinishedActions(action);
     }
 }

+ 42 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeFrameViewModel.cs

@@ -0,0 +1,42 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.AvaloniaUI.Models.Handlers;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes;
+
+internal sealed class NodeFrameViewModel : NodeFrameViewModelBase
+{
+    public NodeFrameViewModel(Guid id, IEnumerable<INodeHandler> nodes) : base(id, nodes)
+    {
+        CalculateBounds();
+    }
+
+    protected override void CalculateBounds()
+    {
+        
+        // TODO: Use the GetBounds like in NodeZoneViewModel
+        if (Nodes.Count == 0)
+        {
+            if (TopLeft == BottomRight)
+            {
+                BottomRight = TopLeft + new VecD(100, 100);
+            }
+            
+            return;
+        }
+        
+        var minX = Nodes.Min(n => n.PositionBindable.X) - 30;
+        var minY = Nodes.Min(n => n.PositionBindable.Y) - 45;
+        
+        var maxX = Nodes.Max(n => n.PositionBindable.X) + 130;
+        var maxY = Nodes.Max(n => n.PositionBindable.Y) + 130;
+
+        TopLeft = new VecD(minX, minY);
+        BottomRight = new VecD(maxX, maxY);
+
+        Size = BottomRight - TopLeft;
+    }
+}

+ 93 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeFrameViewModelBase.cs

@@ -0,0 +1,93 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.AvaloniaUI.Models.Handlers;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes;
+
+public abstract class NodeFrameViewModelBase : ObservableObject
+{
+    private Guid id;
+    private VecD topLeft;
+    private VecD bottomRight;
+    private VecD size;
+    
+    public ObservableCollection<INodeHandler> Nodes { get; }
+
+    public string InternalName { get; init; }
+    
+    public Guid Id
+    {
+        get => id;
+        set => SetProperty(ref id, value);
+    }
+    
+    public VecD TopLeft
+    {
+        get => topLeft;
+        set => SetProperty(ref topLeft, value);
+    }
+
+    public VecD BottomRight
+    {
+        get => bottomRight;
+        set => SetProperty(ref bottomRight, value);
+    }
+
+    public VecD Size
+    {
+        get => size;
+        set => SetProperty(ref size, value);
+    }
+
+    public NodeFrameViewModelBase(Guid id, IEnumerable<INodeHandler> nodes)
+    {
+        Id = id;
+        Nodes = new ObservableCollection<INodeHandler>(nodes);
+
+        Nodes.CollectionChanged += OnCollectionChanged;
+        AddHandlers(Nodes);
+    }
+
+    private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+    {
+        var action = e.Action;
+        if (action != NotifyCollectionChangedAction.Add && action != NotifyCollectionChangedAction.Remove && action != NotifyCollectionChangedAction.Replace && action != NotifyCollectionChangedAction.Reset)
+        {
+            return;
+        }
+        
+        AddHandlers((IEnumerable<NodeViewModel>)e.NewItems);
+        RemoveHandlers((IEnumerable<NodeViewModel>)e.OldItems);
+    }
+
+    private void AddHandlers(IEnumerable<INodeHandler> nodes)
+    {
+        foreach (var node in nodes)
+        {
+            node.PropertyChanged += NodePropertyChanged;
+        }
+    }
+
+    private void RemoveHandlers(IEnumerable<INodeHandler> nodes)
+    {
+        foreach (var node in nodes)
+        {
+            node.PropertyChanged -= NodePropertyChanged;
+        }
+    }
+
+    private void NodePropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName != nameof(INodeHandler.PositionBindable))
+        {
+            return;
+        }
+        
+        CalculateBounds();
+    }
+
+    protected abstract void CalculateBounds();
+}

+ 68 - 14
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodePropertyViewModel.cs

@@ -12,36 +12,66 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
 {
     private string propertyName;
     private string displayName;
-    private object value;
+    private object? _value;
     private INodeHandler node;
     private bool isInput;
+    private bool isFunc;
     private IBrush socketBrush;
     
     private ObservableCollection<INodePropertyHandler> connectedInputs = new();
     private INodePropertyHandler? connectedOutput;
-    
+
     public string DisplayName
     {
         get => displayName;
         set => SetProperty(ref displayName, value);
     }
     
-    public object Value
+    public object? Value
     {
-        get => value;
-        set => SetProperty(ref value, value);
+        get => _value;
+        set
+        {
+            if (SetProperty(ref _value, value))
+            {
+                ViewModelMain.Current.NodeGraphManager.UpdatePropertyValue((node, PropertyName, value));
+            }
+        }
     }
     
     public bool IsInput
     {
         get => isInput;
-        set => SetProperty(ref isInput, value);
+        set
+        {
+            if (SetProperty(ref isInput, value))
+            {
+                OnPropertyChanged(nameof(ShowInputField));
+            }
+        }
+    }
+
+    public bool IsFunc
+    {
+        get => isFunc;
+        set => SetProperty(ref isFunc, value);
     }
 
     public INodePropertyHandler? ConnectedOutput
     {
         get => connectedOutput;
-        set => SetProperty(ref connectedOutput, value);
+        set
+        {
+            if (SetProperty(ref connectedOutput, value))
+            {
+                OnPropertyChanged(nameof(ShowInputField));
+            }
+        }
+    }
+
+    public bool ShowInputField
+    {
+        get => IsInput && ConnectedOutput == null;
     }
 
     public ObservableCollection<INodePropertyHandler> ConnectedInputs
@@ -74,7 +104,14 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
     {
         Node = node;
         PropertyType = propertyType;
-        if (Application.Current.Styles.TryGetResource($"{PropertyType.Name}SocketBrush", App.Current.ActualThemeVariant, out object brush))
+        var targetType = propertyType;
+
+        if (propertyType.IsAssignableTo(typeof(Delegate)))
+        {
+            targetType = propertyType.GetMethod("Invoke").ReturnType;
+        }
+
+        if (Application.Current.Styles.TryGetResource($"{targetType.Name}SocketBrush", App.Current.ActualThemeVariant, out object brush))
         {
             if (brush is IBrush brushValue)
             {
@@ -96,27 +133,44 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
 
     public static NodePropertyViewModel? CreateFromType(Type type, INodeHandler node)
     {
-        string name = type.Name;
-        name += "PropertyViewModel";
+        Type propertyType = type;
+        
+        if (type.IsAssignableTo(typeof(Delegate)))
+        {
+            propertyType = type.GetMethod("Invoke").ReturnType;
+        }
+        
+        string name = $"{propertyType.Name}PropertyViewModel";
         
         Type viewModelType = Type.GetType($"PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties.{name}");
         if (viewModelType == null)
         {
+            if (propertyType.IsEnum)
+            {
+                return new GenericEnumPropertyViewModel(node, type, propertyType);
+            }
+            
             return new GenericPropertyViewModel(node, type);
         }
         
         return (NodePropertyViewModel)Activator.CreateInstance(viewModelType, node, type);
     }
+
+    public void InternalSetValue(object? value) => SetProperty(ref _value, value, nameof(Value));
 }
 
 internal abstract class NodePropertyViewModel<T> : NodePropertyViewModel
 {
-    private T nodeValue;
-    
     public new T Value
     {
-        get => nodeValue;
-        set => SetProperty(ref nodeValue, value);
+        get
+        {
+            if (base.Value == null)
+                return default;
+
+            return (T)base.Value;
+        }
+        set => base.Value = value;
     }
     
     public NodePropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)

+ 2 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeViewModel.cs

@@ -35,6 +35,8 @@ internal class NodeViewModel : ObservableObject, INodeHandler
         set => SetProperty(ref nodeName, value);
     }
 
+    public string InternalName { get; init; }
+
     public VecD PositionBindable
     {
         get => position;

+ 74 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/NodeZoneViewModel.cs

@@ -0,0 +1,74 @@
+using PixiEditor.AvaloniaUI.Models.Handlers;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes;
+
+public sealed class NodeZoneViewModel : NodeFrameViewModelBase
+{
+    private INodeHandler start;
+    private INodeHandler end;
+    
+    public NodeZoneViewModel(Guid id, string internalName, INodeHandler start, INodeHandler end) : base(id, [start, end])
+    {
+        InternalName = internalName;
+        
+        this.start = start;
+        this.end = end;
+        
+        CalculateBounds();
+    }
+
+    protected override void CalculateBounds()
+    {
+        if (Nodes.Count == 0)
+        {
+            if (TopLeft == BottomRight)
+            {
+                BottomRight = TopLeft + new VecD(100, 100);
+            }
+            
+            return;
+        }
+
+        var bounds = GetBounds();
+        
+        var minX = bounds.Min(n => n.X);
+        var minY = bounds.Min(n => n.Y);
+        
+        var maxX = bounds.Max(n => n.Right);
+        var maxY = bounds.Max(n => n.Bottom);
+
+        TopLeft = new VecD(minX, minY);
+        BottomRight = new VecD(maxX, maxY);
+
+        Size = BottomRight - TopLeft;
+    }
+
+    private List<RectD> GetBounds()
+    {
+        var list = new List<RectD>();
+
+        const int defaultXOffset = -30;
+        const int defaultYOffset = -45;
+        
+        // TODO: Use the actual node height
+        foreach (var node in Nodes)
+        {
+            if (node == start)
+            {
+                list.Add(new RectD(node.PositionBindable + new VecD(100, defaultYOffset), new VecD(100, 400)));
+                continue;
+            }
+
+            if (node == end)
+            {
+                list.Add(new RectD(node.PositionBindable + new VecD(defaultXOffset, defaultYOffset), new VecD(100, 400)));
+                continue;
+            }
+            
+            list.Add(new RectD(node.PositionBindable + new VecD(defaultXOffset, defaultYOffset), new VecD(200, 400)));
+        }
+        
+        return list;
+    }
+}

+ 8 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/BooleanPropertyViewModel.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class BooleanPropertyViewModel : NodePropertyViewModel<bool>
+{
+    public BooleanPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+    }
+}

+ 170 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/ColorMatrixPropertyViewModel.cs

@@ -0,0 +1,170 @@
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class ColorMatrixPropertyViewModel : NodePropertyViewModel<ColorMatrix>
+{
+    public ColorMatrixPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+    }
+    
+    public float M11
+    {
+        get => Value.M11;
+        set => Value = new ColorMatrix(
+            (value, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M12
+    {
+        get => Value.M12;
+        set => Value = new ColorMatrix(
+            (M11, value, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M13
+    {
+        get => Value.M13;
+        set => Value = new ColorMatrix(
+            (M11, M12, value, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M14
+    {
+        get => Value.M14;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, value, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M15
+    {
+        get => Value.M15;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, value), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M21
+    {
+        get => Value.M21;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (value, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M22
+    {
+        get => Value.M22;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, value, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M23
+    {
+        get => Value.M23;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, value, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M24
+    {
+        get => Value.M24;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, value, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M25
+    {
+        get => Value.M25;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, value),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M31
+    {
+        get => Value.M31;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (value, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M32
+    {
+        get => Value.M32;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, value, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M33
+    {
+        get => Value.M33;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, value, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M34
+    {
+        get => Value.M34;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, value, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M35
+    {
+        get => Value.M35;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, value), (M41, M42, M43, M44, M45));
+    }
+
+    public float M41
+    {
+        get => Value.M41;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (value, M42, M43, M44, M45));
+    }
+
+    public float M42
+    {
+        get => Value.M42;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, value, M43, M44, M45));
+    }
+
+    public float M43
+    {
+        get => Value.M43;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, value, M44, M45));
+    }
+
+    public float M44
+    {
+        get => Value.M44;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, value, M45));
+    }
+
+    public float M45
+    {
+        get => Value.M45;
+        set => Value = new ColorMatrix(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, value));
+    }
+}

+ 17 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/ColorPropertyViewModel.cs

@@ -0,0 +1,17 @@
+using PixiEditor.AvaloniaUI.Helpers.Extensions;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class ColorPropertyViewModel : NodePropertyViewModel<Color>
+{
+    public ColorPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+    }
+
+    public new Avalonia.Media.Color Value
+    {
+        get => base.Value.ToColor();
+        set => base.Value = value.ToColor();
+    }
+}

+ 8 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/DoublePropertyViewModel.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class DoublePropertyViewModel : NodePropertyViewModel<double>
+{
+    public DoublePropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+    }
+}

+ 13 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/GenericEnumPropertyViewModel.cs

@@ -0,0 +1,13 @@
+using PixiEditor.AvaloniaUI.Models.Handlers;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class GenericEnumPropertyViewModel : NodePropertyViewModel
+{
+    public GenericEnumPropertyViewModel(INodeHandler node, Type propertyType, Type enumType) : base(node, propertyType)
+    {
+        Values = Enum.GetValues(enumType);
+    }
+
+    public Array Values { get; }
+}

+ 8 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/Int32PropertyViewModel.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class Int32PropertyViewModel : NodePropertyViewModel<int>
+{
+    public Int32PropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+    }
+}

+ 68 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/KernelPropertyViewModel.cs

@@ -0,0 +1,68 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class KernelPropertyViewModel : NodePropertyViewModel<Kernel?>
+{
+    public ObservableCollection<KernelVmReference> ReferenceCollections { get; }
+    
+    public RelayCommand<int> AdjustSizeCommand { get; }
+    
+    public KernelPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+        ReferenceCollections = new ObservableCollection<KernelVmReference>();
+        PropertyChanged += OnPropertyChanged;
+        AdjustSizeCommand = new RelayCommand<int>(Execute, i => i > 0 && Width < 9 || i < 0 && Width > 3);
+    }
+
+    private void Execute(int by)
+    {
+        Value.Resize(Width + by * 2, Height + by * 2);
+        OnPropertyChanged(nameof(Value));
+        AdjustSizeCommand.NotifyCanExecuteChanged();
+    }
+
+    public int Width => Value.Width;
+    
+    public int Height => Value.Height;
+
+    public float Sum => Value.Sum;
+
+    private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName != nameof(Value) || Value == null)
+            return;
+
+        ReferenceCollections.Clear();
+        
+        for (int y = -Value.RadiusY; y <= Value.RadiusY; y++)
+        {
+            for (int x = -Value.RadiusX; x <= Value.RadiusX; x++)
+            {
+                ReferenceCollections.Add(new KernelVmReference(this, x, y));
+            }
+        }
+        
+        OnPropertyChanged(nameof(Width));
+        OnPropertyChanged(nameof(Height));
+        OnPropertyChanged(nameof(Sum));
+    }
+
+    public class KernelVmReference(KernelPropertyViewModel viewModel, int x, int y) : PixiObservableObject
+    {
+        public float Value
+        {
+            get => viewModel.Value[x, y];
+            set
+            {
+                viewModel.Value[x, y] = value;
+                ViewModelMain.Current.NodeGraphManager.UpdatePropertyValue((viewModel.Node, viewModel.PropertyName, viewModel.Value));
+                viewModel.OnPropertyChanged(nameof(Sum));
+                OnPropertyChanged();
+            }
+        }
+    }
+}

+ 170 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/Matrix4x5FPropertyViewModel.cs

@@ -0,0 +1,170 @@
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class Matrix4x5FPropertyViewModel : NodePropertyViewModel<Matrix4x5F>
+{
+    public Matrix4x5FPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+    }
+
+    public float M11
+    {
+        get => Value.M11;
+        set => Value = new Matrix4x5F(
+            (value, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M12
+    {
+        get => Value.M12;
+        set => Value = new Matrix4x5F(
+            (M11, value, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M13
+    {
+        get => Value.M13;
+        set => Value = new Matrix4x5F(
+            (M11, M12, value, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M14
+    {
+        get => Value.M14;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, value, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M15
+    {
+        get => Value.M15;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, value), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M21
+    {
+        get => Value.M21;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (value, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M22
+    {
+        get => Value.M22;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, value, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M23
+    {
+        get => Value.M23;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, value, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M24
+    {
+        get => Value.M24;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, value, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M25
+    {
+        get => Value.M25;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, value),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M31
+    {
+        get => Value.M31;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (value, M32, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M32
+    {
+        get => Value.M32;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, value, M33, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M33
+    {
+        get => Value.M33;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, value, M34, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M34
+    {
+        get => Value.M34;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, value, M35), (M41, M42, M43, M44, M45));
+    }
+
+    public float M35
+    {
+        get => Value.M35;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, value), (M41, M42, M43, M44, M45));
+    }
+
+    public float M41
+    {
+        get => Value.M41;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (value, M42, M43, M44, M45));
+    }
+
+    public float M42
+    {
+        get => Value.M42;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, value, M43, M44, M45));
+    }
+
+    public float M43
+    {
+        get => Value.M43;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, value, M44, M45));
+    }
+
+    public float M44
+    {
+        get => Value.M44;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, value, M45));
+    }
+
+    public float M45
+    {
+        get => Value.M45;
+        set => Value = new Matrix4x5F(
+            (M11, M12, M13, M14, M15), (M21, M22, M23, M24, M25),
+            (M31, M32, M33, M34, M35), (M41, M42, M43, M44, value));
+    }
+}

+ 35 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/VecDPropertyViewModel.cs

@@ -0,0 +1,35 @@
+using System.ComponentModel;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class VecDPropertyViewModel : NodePropertyViewModel<VecD>
+{
+    public VecDPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+        PropertyChanged += OnPropertyChanged;
+    }
+
+    private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName != nameof(Value))
+        {
+            return;
+        }
+        
+        OnPropertyChanged(nameof(XValue));
+        OnPropertyChanged(nameof(YValue));
+    }
+
+    public double XValue
+    {
+        get => Value.X;
+        set => Value = new VecD(value, Value.Y);
+    }
+    
+    public double YValue
+    {
+        get => Value.Y;
+        set => Value = new VecD(Value.X, value);
+    }
+}

+ 35 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Nodes/Properties/VecIPropertyViewModel.cs

@@ -0,0 +1,35 @@
+using System.ComponentModel;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Nodes.Properties;
+
+internal class VecIPropertyViewModel : NodePropertyViewModel<VecI>
+{
+    public VecIPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    {
+        PropertyChanged += OnPropertyChanged;
+    }
+
+    private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName != nameof(Value))
+        {
+            return;
+        }
+        
+        OnPropertyChanged(nameof(XValue));
+        OnPropertyChanged(nameof(YValue));
+    }
+
+    public int XValue
+    {
+        get => Value.X;
+        set => Value = new VecI(value, Value.Y);
+    }
+    
+    public int YValue
+    {
+        get => Value.Y;
+        set => Value = new VecI(Value.X, value);
+    }
+}

+ 11 - 6
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/AnimationsViewModel.cs

@@ -1,7 +1,9 @@
-using ChunkyImageLib;
+using Avalonia.Input;
+using ChunkyImageLib;
 using PixiEditor.AnimationRenderer.Core;
 using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
 using PixiEditor.AvaloniaUI.Models.IO;
+using PixiEditor.AvaloniaUI.ViewModels.Dock;
 using PixiEditor.AvaloniaUI.ViewModels.Document;
 using PixiEditor.DrawingApi.Core.Surface;
 using PixiEditor.DrawingApi.Core.Surface.ImageData;
@@ -34,20 +36,23 @@ internal class AnimationsViewModel : SubViewModel<ViewModelMain>
         activeDocument.AnimationDataViewModel.CreateRasterKeyFrame(
             activeDocument.SelectedStructureMember.Id,
             newFrame,
-            toCloneFrom);
+            toCloneFrom, 
+            frameToCopyFrom);
         
         activeDocument.Operations.SetActiveFrame(newFrame);
     }
     
-    [Command.Basic("PixiEditor.Animation.DeleteKeyFrames", "Delete key frames", "Delete key frames")]
-    public void DeleteKeyFrames(IList<KeyFrameViewModel> keyFrames)
+    [Command.Basic("PixiEditor.Animation.DeleteKeyFrames", "DELETE_KEY_FRAMES", "DELETE_KEY_FRAMES_DESCRIPTIVE",
+        ShortcutContext = typeof(TimelineDockViewModel), Key = Key.Delete)]
+    public void DeleteKeyFrames()
     {
         var activeDocument = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var selected = activeDocument.AnimationDataViewModel.AllKeyFrames.Where(x => x.IsSelected).ToArray();
 
-        if (activeDocument is null)
+        if (activeDocument is null || selected.Length == 0)
             return;
         
-        List<Guid> keyFrameIds = keyFrames.Select(x => x.Id).ToList();
+        List<Guid> keyFrameIds = selected.Select(x => x.Id).ToList();
         
         for(int i = 0; i < keyFrameIds.Count; i++)
         {

+ 14 - 9
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/LayersViewModel.cs

@@ -14,6 +14,7 @@ using PixiEditor.AvaloniaUI.Helpers.Extensions;
 using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
 using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Evaluators;
 using PixiEditor.AvaloniaUI.Models.Dialogs;
+using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.Models.IO;
 using PixiEditor.AvaloniaUI.Models.Layers;
 using PixiEditor.AvaloniaUI.ViewModels.Dock;
@@ -54,7 +55,10 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
         return true;
     }
 
-    [Command.Basic("PixiEditor.Layer.DeleteSelected", "LAYER_DELETE_SELECTED", "LAYER_DELETE_SELECTED_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.CanDeleteSelected", 
+    [Command.Basic("PixiEditor.Layer.DeleteSelected", "LAYER_DELETE_SELECTED", 
+        "LAYER_DELETE_SELECTED_DESCRIPTIVE", 
+        CanExecute = "PixiEditor.Layer.CanDeleteSelected", Key = Key.Delete, 
+        ShortcutContext = typeof(LayersDockViewModel),
         Icon = PixiPerfectIcons.Trash)]
     public void DeleteSelected()
     {
@@ -315,19 +319,20 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
 
     public void MergeSelectedWith(bool above)
     {
-        /*var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         var member = doc?.SelectedStructureMember;
         if (doc is null || member is null)
             return;
-        var (child, parent) = doc.StructureHelper.FindChildAndParent(member.Id);
-        if (child is null || parent is null)
-            return;
-        int index = parent.Children.IndexOf(child);
-        if (!above && index == 0)
+       
+        IStructureMemberHandler? nextMergeableMember = doc.StructureHelper.GetAboveMember(member.Id, false);
+        IStructureMemberHandler? previousMergeableMember = doc.StructureHelper.GetBelowMember(member.Id, false); 
+        
+        if (!above && previousMergeableMember is null)
             return;
-        if (above && index == parent.Children.Count - 1)
+        if (above && nextMergeableMember is null)
             return;
-        doc.Operations.MergeStructureMembers(new List<Guid> { member.Id, above ? parent.Children[index + 1].Id : parent.Children[index - 1].GuidValue });*/
+        
+        doc.Operations.MergeStructureMembers(new List<Guid> { member.Id, above ? nextMergeableMember.Id : previousMergeableMember.Id });
     }
 
     [Command.Basic("PixiEditor.Layer.MergeWithAbove", "MERGE_WITH_ABOVE", "MERGE_WITH_ABOVE_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasMemberAbove")]

+ 31 - 2
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/NodeGraphManagerViewModel.cs

@@ -1,5 +1,8 @@
-using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
+using Avalonia.Input;
+using PixiEditor.AvaloniaUI.Models.Commands.Attributes.Commands;
 using PixiEditor.AvaloniaUI.Models.Handlers;
+using PixiEditor.AvaloniaUI.ViewModels.Dock;
+using PixiEditor.AvaloniaUI.ViewModels.Nodes;
 using PixiEditor.Numerics;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
@@ -10,6 +13,25 @@ internal class NodeGraphManagerViewModel : SubViewModel<ViewModelMain>
     {
     }
 
+    [Command.Basic("PixiEditor.NodeGraph.DeleteSelectedNodes", "DELETE_NODES", "DELETE_NODES_DESCRIPTIVE", 
+        Key = Key.Delete, ShortcutContext = typeof(NodeGraphDockViewModel))]
+    public void DeleteSelectedNodes()
+    {
+        Guid[] selectedNodes = Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.AllNodes
+            .Where(x => x.IsSelected).Select(x => x.Id).ToArray();
+        
+        if (selectedNodes == null || selectedNodes.Length == 0)
+            return;
+
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.RemoveNodes(selectedNodes);
+    }
+
+    [Command.Debug("PixiEditor.NodeGraph.CreateNodeFrameAroundEverything", "Create node frame", "Create node frame")]
+    public void CreateNodeFrameAroundEverything()
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.CreateNodeFrameAroundEverything();
+    }
+
     [Command.Internal("PixiEditor.NodeGraph.CreateNode")]
     public void CreateNode(Type nodeType)
     {
@@ -27,7 +49,14 @@ internal class NodeGraphManagerViewModel : SubViewModel<ViewModelMain>
     {
         Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.SetNodePosition(args.node, args.newPos);
     }
-    
+
+    [Command.Internal("PixiEditor.NodeGraph.UpdateValue")]
+    public void UpdatePropertyValue((INodeHandler node, string property, object value) args)
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.NodeGraph.UpdatePropertyValue(args.node, args.property,
+            args.value);
+    }
+
     [Command.Internal("PixiEditor.NodeGraph.EndChangeNodePos")]
     public void EndChangeNodePos()
     {

+ 11 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/ViewportWindowViewModel.cs

@@ -20,6 +20,7 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     public ExecutionTrigger<VecI> CenterViewportTrigger { get; } = new ExecutionTrigger<VecI>();
     public ExecutionTrigger<double> ZoomViewportTrigger { get; } = new ExecutionTrigger<double>();
 
+    
     public string Index => _index;
 
     public string Id => id;
@@ -58,6 +59,14 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         }
     }
 
+    private ViewportColorChannels _channels = ViewportColorChannels.Default;
+    
+    public ViewportColorChannels Channels
+    {
+        get => _channels;
+        set => SetProperty(ref _channels, value);
+    }
+
     public void IndexChanged()
     {
         _index = Owner.CalculateViewportIndex(this) ?? "";
@@ -122,10 +131,11 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
     void IDockableSelectionEvents.OnSelected()
     {
         Owner.ActiveWindow = this;
+        Owner.Owner.ShortcutController.OverwriteContext(this.GetType());
     }
 
     void IDockableSelectionEvents.OnDeselected()
     {
-
+        Owner.Owner.ShortcutController.ClearContext(GetType());
     }
 }

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

@@ -282,7 +282,7 @@ internal partial class ViewModelMain : ViewModelBase, ICommandsHandler
             // So they remain alive and keep "showing" the now disposed DocumentViewModel
             // And since they reference the DocumentViewModel it doesn't get collected by GC
 
-            // document.Dispose();
+            document.Dispose();
             WindowSubViewModel.CloseViewportsForDocument(document);
 
             return true;

+ 11 - 2
src/PixiEditor.AvaloniaUI/Views/Animations/Timeline.cs

@@ -82,6 +82,15 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
     public static readonly StyledProperty<ICommand> ChangeKeyFramesLengthCommandProperty = AvaloniaProperty.Register<Timeline, ICommand>(
         nameof(ChangeKeyFramesLengthCommand));
 
+    public static readonly StyledProperty<int> DefaultEndFrameProperty = AvaloniaProperty.Register<Timeline, int>(
+        nameof(DefaultEndFrame));
+
+    public int DefaultEndFrame
+    {
+        get => GetValue(DefaultEndFrameProperty);
+        set => SetValue(DefaultEndFrameProperty, value);
+    }
+
     public ICommand ChangeKeyFramesLengthCommand
     {
         get => GetValue(ChangeKeyFramesLengthCommandProperty);
@@ -188,7 +197,7 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
 
     public bool DragAllSelectedKeyFrames(int delta)
     {
-        bool canDrag = SelectedKeyFrames.All(x => x.StartFrameBindable + delta >= 0);
+        bool canDrag = SelectedKeyFrames.All(x => x.StartFrameBindable + delta > 0);
         if (!canDrag)
         {
             return false;
@@ -323,7 +332,7 @@ internal class Timeline : TemplatedControl, INotifyPropertyChanged
 
     private void PlayTimerOnTick(object? sender, EventArgs e)
     {
-        if (ActiveFrame >= KeyFrames.FrameCount)
+        if (ActiveFrame >= (KeyFrames.Count > 0 ? KeyFrames.FrameCount : DefaultEndFrame))
         {
             ActiveFrame = 1;
         }

+ 2 - 2
src/PixiEditor.AvaloniaUI/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -110,7 +110,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
         set => SetValue(SaveFormatProperty, value);
     }
 
-    public Surface ExportPreview
+    public Surface? ExportPreview
     {
         get => GetValue(ExportPreviewProperty);
         set => SetValue(ExportPreviewProperty, value);
@@ -141,7 +141,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
     public string SizeHint => new LocalizedString("EXPORT_SIZE_HINT", GetBestPercentage());
 
     private DocumentViewModel document;
-    private Image[] videoPreviewFrames = [];
+    private Image[]? videoPreviewFrames = [];
     private DispatcherTimer videoPreviewTimer = new DispatcherTimer();
     private int activeFrame = 0;
     private CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();

+ 48 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/ChannelsDockView.axaml

@@ -0,0 +1,48 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:dock="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.Dock"
+             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="PixiEditor.AvaloniaUI.Views.Dock.ChannelsDockView"
+             x:DataType="dock:ChannelsDockViewModel">
+    <Design.DataContext>
+        <dock:ChannelsDockViewModel/>
+    </Design.DataContext>
+    
+    <StackPanel>
+        <!-- TODO: Improve this UI -->
+        <Grid ColumnDefinitions="*,Auto">
+            <TextBlock ui:Translator.Key="RED" />
+            <StackPanel Grid.Column="1" Orientation="Horizontal">
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-eye}" IsChecked="{Binding IsRedVisible}" />
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-trash}" IsChecked="{Binding IsRedSolo}" />
+            </StackPanel>
+        </Grid>
+        
+        <Grid ColumnDefinitions="*,Auto">
+            <TextBlock ui:Translator.Key="GREEN" />
+            <StackPanel Grid.Column="1" Orientation="Horizontal">
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-eye}" IsChecked="{Binding IsGreenVisible}" />
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-trash}" IsChecked="{Binding IsGreenSolo}" />
+            </StackPanel>
+        </Grid>
+
+        <Grid ColumnDefinitions="*,Auto">
+            <TextBlock ui:Translator.Key="BLUE" />
+            <StackPanel Grid.Column="1" Orientation="Horizontal">
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-eye}" IsChecked="{Binding IsBlueVisible}" />
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-trash}" IsChecked="{Binding IsBlueSolo}" />
+            </StackPanel>
+        </Grid>
+
+        <Grid ColumnDefinitions="*,Auto">
+            <TextBlock ui:Translator.Key="ALPHA" />
+            <StackPanel Grid.Column="1" Orientation="Horizontal">
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-eye}" IsChecked="{Binding IsAlphaVisible}" />
+                <ToggleButton Classes="pixi-icon" Content="{DynamicResource icon-trash}" IsChecked="{Binding IsAlphaSolo}" />
+            </StackPanel>
+        </Grid>
+    </StackPanel>
+</UserControl>

+ 14 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/ChannelsDockView.axaml.cs

@@ -0,0 +1,14 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.AvaloniaUI.Views.Dock;
+
+public partial class ChannelsDockView : UserControl
+{
+    public ChannelsDockView()
+    {
+        InitializeComponent();
+    }
+}
+

+ 1 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/DocumentTemplate.axaml

@@ -33,6 +33,7 @@
         UseTouchGestures="{Binding StylusSubViewModel.UseTouchGestures, Source={viewModels1:MainVM}}"
         FlipX="{Binding FlipX, Mode=TwoWay}"
         FlipY="{Binding FlipY, Mode=TwoWay}"
+        Channels="{Binding Channels, Mode=TwoWay}"
         ContextRequested="Viewport_OnContextMenuOpening"
         Document="{Binding Document}">
         <viewportControls:Viewport.ContextFlyout>

+ 1 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/TimelineDockView.axaml

@@ -17,6 +17,7 @@
         ActiveFrame="{Binding DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.ActiveFrameBindable, Mode=TwoWay}"
         NewKeyFrameCommand="{xaml:Command PixiEditor.Animation.CreateRasterKeyFrame}"
         Fps="{Binding DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.FrameRate, Mode=TwoWay}"
+        DefaultEndFrame="{Binding DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.DefaultEndFrame}"
         DuplicateKeyFrameCommand="{xaml:Command PixiEditor.Animation.DuplicateRasterKeyFrame}"
         DeleteKeyFrameCommand="{xaml:Command PixiEditor.Animation.DeleteKeyFrames, UseProvided=True}"
         ChangeKeyFramesLengthCommand="{xaml:Command PixiEditor.Animation.ChangeKeyFramesStartPos, UseProvided=True}"/>

+ 82 - 0
src/PixiEditor.AvaloniaUI/Views/Input/NumberInput.cs

@@ -4,6 +4,7 @@ using System.Linq;
 using System.Text.RegularExpressions;
 using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
 using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Interactivity;
@@ -31,6 +32,8 @@ internal partial class NumberInput : TextBox
     public static readonly StyledProperty<string> FormattedValueProperty = AvaloniaProperty.Register<NumberInput, string>(
         nameof(FormattedValue), "0");
 
+    public static readonly StyledProperty<bool> EnableScrollChangeProperty = AvaloniaProperty.Register<NumberInput, bool>(
+        "EnableScrollChange", true);
     public string FormattedValue
     {
         get => GetValue(FormattedValueProperty);
@@ -110,8 +113,21 @@ internal partial class NumberInput : TextBox
         'i', 'n', 'f', 't', 'y', 'e', 'I', 'N', 'F', 'T', 'Y', 'E'
     };
 
+
     protected override Type StyleKeyOverride => typeof(TextBox);
 
+    public bool EnableScrollChange
+    {
+        get { return (bool)GetValue(EnableScrollChangeProperty); }
+        set { SetValue(EnableScrollChangeProperty, value); }
+    }
+
+    private Control? leftGrabber;
+    private Control? rightGrabber;
+    
+    private double _pressedValue;
+    private double _pressedRelativeX;
+    
     static NumberInput()
     {
         ValueProperty.Changed.Subscribe(OnValueChanged);
@@ -140,6 +156,67 @@ internal partial class NumberInput : TextBox
         VerticalAlignment = VerticalAlignment.Center;
     }
 
+    protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+    {
+        base.OnApplyTemplate(e);
+        
+        InnerLeftContent = leftGrabber = CreateMouseGrabber(); 
+        leftGrabber.HorizontalAlignment = HorizontalAlignment.Left;
+        InnerRightContent = rightGrabber = CreateMouseGrabber(); 
+        rightGrabber.HorizontalAlignment = HorizontalAlignment.Right;
+    }
+
+    protected override void OnSizeChanged(SizeChangedEventArgs e)
+    {
+        if (e.NewSize.Width < 100)
+        {
+            rightGrabber.IsVisible = false;
+        }
+        
+        leftGrabber.Height = e.NewSize.Height - 10;
+        leftGrabber.Width = e.NewSize.Width / 4f;
+        
+        rightGrabber.Height = e.NewSize.Height - 10;
+        rightGrabber.Width = e.NewSize.Width / 4f;
+    }
+
+    private Control CreateMouseGrabber()
+    {
+        var grabber = new Grid()
+        {
+            Cursor = new Cursor(StandardCursorType.SizeWestEast),
+            Background = Brushes.Transparent,
+        };
+
+        grabber.PointerPressed += GrabberPressed;
+        grabber.PointerMoved += GrabberMoved;
+        
+        return grabber;
+    }
+    
+    private void GrabberPressed(object sender, PointerPressedEventArgs e)
+    {
+        e.Pointer.Capture(leftGrabber);
+        _pressedValue = Value;
+        _pressedRelativeX = e.GetPosition(this).X;
+        e.Handled = true;
+    }
+    
+    private void GrabberMoved(object sender, PointerEventArgs e)
+    {
+        if(e.Pointer.Captured != null && (e.Pointer.Captured.Equals(leftGrabber) || e.Pointer.Captured.Equals(rightGrabber)))
+        {
+            double relativeX = e.GetPosition(this).X;
+            double diff = relativeX - _pressedRelativeX;
+
+            double pixelsPerUnit = 5;
+            
+            double newValue = _pressedValue + diff / pixelsPerUnit;
+            Value = (float)Math.Round(Math.Clamp(newValue, Min, Max), Decimals);
+            e.Handled = true; 
+        }
+    }
+
     private void BindTextBoxBehavior(TextBoxFocusBehavior behavior)
     {
         Binding focusNextBinding = new Binding(nameof(FocusNext))
@@ -258,6 +335,11 @@ internal partial class NumberInput : TextBox
 
     protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
     {
+        if (!EnableScrollChange)
+        {
+            return;
+        }
+        
         int step = (int)e.Delta.Y;
 
         double newValue = Value;

+ 20 - 3
src/PixiEditor.AvaloniaUI/Views/Input/SizeInput.axaml.cs

@@ -11,7 +11,7 @@ namespace PixiEditor.AvaloniaUI.Views.Input;
 internal partial class SizeInput : UserControl
 {
     public static readonly StyledProperty<int> SizeProperty =
-        AvaloniaProperty.Register<SizeInput, int>(nameof(Size), defaultValue: 1);
+        AvaloniaProperty.Register<SizeInput, int>(nameof(Size), defaultValue: 1, coerce: Coerce);
 
     public static readonly StyledProperty<int> MaxSizeProperty =
         AvaloniaProperty.Register<SizeInput, int>(nameof(MaxSize), defaultValue: int.MaxValue);
@@ -30,6 +30,7 @@ internal partial class SizeInput : UserControl
         get => GetValue(FocusNextProperty);
         set => SetValue(FocusNextProperty, value);
     }
+
     public Action OnScrollAction
     {
         get { return GetValue(OnScrollActionProperty); }
@@ -102,6 +103,24 @@ internal partial class SizeInput : UserControl
         set => SetValue(UnitProperty, value);
     }
 
+
+    private static int Coerce(AvaloniaObject sender, int value)
+    {
+        if (value <= 0)
+        {
+            return 1;
+        }
+
+        int maxSize = sender.GetValue(MaxSizeProperty);
+        
+        if (value > maxSize)
+        {
+            return maxSize;
+        }
+        
+        return value;
+    }
+
     private static void InputSizeChanged(AvaloniaPropertyChangedEventArgs<int> e)
     {
         int newValue = e.NewValue.Value;
@@ -116,8 +135,6 @@ internal partial class SizeInput : UserControl
         else if (newValue <= 0)
         {
             e.Sender.SetValue(SizeProperty, 1);
-
-            return;
         }
     }
 

+ 3 - 0
src/PixiEditor.AvaloniaUI/Views/Main/Navigation.axaml.cs

@@ -150,6 +150,9 @@ internal partial class Navigation : UserControl
         int x = (int)mousePosConverted.X;
         int y = (int)mousePosConverted.Y;
 
+        if (x < 0 || x > Document.Width || y < 0 || y > Document.Height)
+            return;
+        
         Thickness newPos = new Thickness(x, y, 0, 0);
 
         if (ColorCursorPosition == newPos)

+ 1 - 0
src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/Viewport.axaml

@@ -112,6 +112,7 @@
             ZoomOutOnClick="{Binding ZoomOutOnClick, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=TwoWay}"
             FlipX="{Binding FlipX, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=TwoWay}"
             FlipY="{Binding FlipY, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=TwoWay}"
+            Channels="{Binding Channels, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=TwoWay}"
             AllOverlays="{Binding ElementName=vpUc, Path=ActiveOverlays}"
             FadeOut="{Binding Source={viewModels:ToolVM ColorPickerToolViewModel}, Path=PickOnlyFromReferenceLayer, Mode=OneWay}"
             DefaultCursor="{Binding Source={viewModels:MainVM}, Path=ToolsSubViewModel.ToolCursor, Mode=OneWay}"

+ 9 - 0
src/PixiEditor.AvaloniaUI/Views/Main/ViewportControls/Viewport.axaml.cs

@@ -84,6 +84,9 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
     public static readonly StyledProperty<ICommand> MiddleMouseClickedCommandProperty =
         AvaloniaProperty.Register<Viewport, ICommand>(nameof(MiddleMouseClickedCommand), null);
 
+    public static readonly StyledProperty<ViewportColorChannels> ChannelsProperty = AvaloniaProperty.Register<Viewport, ViewportColorChannels>(
+        nameof(Channels));
+
     public ICommand? MiddleMouseClickedCommand
     {
         get => (ICommand?)GetValue(MiddleMouseClickedCommandProperty);
@@ -193,6 +196,12 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
         set => SetValue(FlipYProperty, value);
     }
 
+    public ViewportColorChannels Channels
+    {
+        get => GetValue(ChannelsProperty);
+        set => SetValue(ChannelsProperty, value);
+    }
+
     private double angleRadians = 0;
 
     public double AngleRadians

+ 12 - 4
src/PixiEditor.AvaloniaUI/Views/Nodes/ConnectionLine.cs

@@ -6,14 +6,16 @@ namespace PixiEditor.AvaloniaUI.Views.Nodes;
 
 public class ConnectionLine : Control
 {
-    public static readonly StyledProperty<SolidColorBrush> ColorProperty = AvaloniaProperty.Register<ConnectionLine, SolidColorBrush>("Color");
+    private Pen pen = new() { LineCap = PenLineCap.Round };
+    
+    public static readonly StyledProperty<LinearGradientBrush> ColorProperty = AvaloniaProperty.Register<ConnectionLine, LinearGradientBrush>("Color");
     public static readonly StyledProperty<double> ThicknessProperty = AvaloniaProperty.Register<ConnectionLine, double>("Thickness");
     public static readonly StyledProperty<Point> StartPointProperty = AvaloniaProperty.Register<ConnectionLine, Point>("StartPoint");
     public static readonly StyledProperty<Point> EndPointProperty = AvaloniaProperty.Register<ConnectionLine, Point>("EndPoint");
 
-    public SolidColorBrush Color
+    public LinearGradientBrush LineBrush
     {
-        get { return (SolidColorBrush)GetValue(ColorProperty); }
+        get { return GetValue(ColorProperty); }
         set { SetValue(ColorProperty, value); }
     }
 
@@ -63,6 +65,12 @@ public class ConnectionLine : Control
         ctx.BeginFigure(p1, false);
         ctx.CubicBezierTo(controlPoint, controlPoint2, p2);
         
-        context.DrawGeometry(Color, new Pen(Color, Thickness) { LineCap = PenLineCap.Round }, geometry);
+        LineBrush.StartPoint = new RelativePoint(p1.X, p1.Y, RelativeUnit.Absolute);
+        LineBrush.EndPoint = new RelativePoint(p2.X, p2.Y, RelativeUnit.Absolute);
+
+        pen.Brush = LineBrush;
+        pen.Thickness = Thickness;
+        
+        context.DrawGeometry(LineBrush, pen, geometry);
     }
 }

+ 33 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/NodeFrameView.cs

@@ -0,0 +1,33 @@
+using Avalonia;
+using Avalonia.Controls.Primitives;
+using PixiEditor.Numerics;
+using Point = PixiEditor.Numerics.Point;
+
+namespace PixiEditor.AvaloniaUI.Views.Nodes;
+
+public class NodeFrameView : TemplatedControl
+{
+    public static readonly StyledProperty<VecD> TopLeftProperty = AvaloniaProperty.Register<ConnectionLine, VecD>(nameof(TopLeft));
+    
+    public VecD TopLeft
+    {
+        get => GetValue(TopLeftProperty);
+        set => SetValue(TopLeftProperty, value);
+    }
+    
+    public static readonly StyledProperty<VecD> BottomRightProperty = AvaloniaProperty.Register<ConnectionLine, VecD>(nameof(BottomRight));
+    
+    public VecD BottomRight
+    {
+        get => GetValue(BottomRightProperty);
+        set => SetValue(BottomRightProperty, value);
+    }
+    
+    public static readonly StyledProperty<VecD> SizeProperty = AvaloniaProperty.Register<ConnectionLine, VecD>(nameof(Size));
+    
+    public VecD Size
+    {
+        get => GetValue(SizeProperty);
+        set => SetValue(SizeProperty, value);
+    }
+}

+ 14 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/BooleanPropertyView.axaml

@@ -0,0 +1,14 @@
+<properties:NodePropertyView xmlns="https://github.com/avaloniaui"
+                             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+                             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+                             xmlns:properties="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes.Properties"
+                             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+                             xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+                             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+                             x:Class="PixiEditor.AvaloniaUI.Views.Nodes.Properties.BooleanPropertyView">
+    <StackPanel Orientation="Horizontal" HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
+        <CheckBox Margin="0,0,4,0" IsVisible="{Binding ShowInputField}" IsChecked="{Binding Value}"/>
+        <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}"/>
+    </StackPanel>
+</properties:NodePropertyView>

+ 14 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/BooleanPropertyView.axaml.cs

@@ -0,0 +1,14 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.AvaloniaUI.Views.Nodes.Properties;
+
+public partial class BooleanPropertyView : NodePropertyView
+{
+    public BooleanPropertyView()
+    {
+        InitializeComponent();
+    }
+}
+

+ 49 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorMatrixPropertyView.axaml

@@ -0,0 +1,49 @@
+<properties:NodePropertyView xmlns="https://github.com/avaloniaui"
+                             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+                             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+                             xmlns:properties="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes.Properties"
+                             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+                             xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input"
+                             xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+                             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+                             x:Class="PixiEditor.AvaloniaUI.Views.Nodes.Properties.ColorMatrixPropertyView">
+    <StackPanel HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
+        <TextBlock ui:Translator.Key="{Binding DisplayName}" />
+        <Grid IsVisible="{Binding ShowInputField}" ColumnDefinitions="Auto,*,*,*,*,*" RowDefinitions="Auto, Auto, Auto, Auto, Auto">
+            <TextBlock Grid.Row="0" Grid.Column="0" Text="T\F" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"  />
+            
+            <TextBlock Grid.Row="1" Grid.Column="0" Text="R" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            <TextBlock Grid.Row="2" Grid.Column="0" Text="G" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            <TextBlock Grid.Row="3" Grid.Column="0" Text="B" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            <TextBlock Grid.Row="4" Grid.Column="0" Text="A" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            
+            <TextBlock Grid.Row="0" Grid.Column="1" Text="R" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            <TextBlock Grid.Row="0" Grid.Column="2" Text="G" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            <TextBlock Grid.Row="0" Grid.Column="3" Text="B" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            <TextBlock Grid.Row="0" Grid.Column="4" Text="A" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            <TextBlock Grid.Row="0" Grid.Column="5" Text="+" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="1,0" TextAlignment="Center" />
+            
+            <input:NumberInput EnableScrollChange="False" Grid.Row="1" Grid.Column="1" IsVisible="{Binding IsInput}" Value="{Binding M11, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="1" Grid.Column="2" IsVisible="{Binding IsInput}" Value="{Binding M12, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="1" Grid.Column="3" IsVisible="{Binding IsInput}" Value="{Binding M13, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="1" Grid.Column="4" IsVisible="{Binding IsInput}" Value="{Binding M14, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="1" Grid.Column="5" IsVisible="{Binding IsInput}" Value="{Binding M15, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="2" Grid.Column="1" IsVisible="{Binding IsInput}" Value="{Binding M21, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="2" Grid.Column="2" IsVisible="{Binding IsInput}" Value="{Binding M22, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="2" Grid.Column="3" IsVisible="{Binding IsInput}" Value="{Binding M23, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="2" Grid.Column="4" IsVisible="{Binding IsInput}" Value="{Binding M24, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="2" Grid.Column="5" IsVisible="{Binding IsInput}" Value="{Binding M25, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="3" Grid.Column="1" IsVisible="{Binding IsInput}" Value="{Binding M31, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="3" Grid.Column="2" IsVisible="{Binding IsInput}" Value="{Binding M32, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="3" Grid.Column="3" IsVisible="{Binding IsInput}" Value="{Binding M33, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="3" Grid.Column="4" IsVisible="{Binding IsInput}" Value="{Binding M34, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="3" Grid.Column="5" IsVisible="{Binding IsInput}" Value="{Binding M35, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="4" Grid.Column="1" IsVisible="{Binding IsInput}" Value="{Binding M41, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="4" Grid.Column="2" IsVisible="{Binding IsInput}" Value="{Binding M42, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="4" Grid.Column="3" IsVisible="{Binding IsInput}" Value="{Binding M43, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="4" Grid.Column="4" IsVisible="{Binding IsInput}" Value="{Binding M44, Mode=TwoWay}" />
+            <input:NumberInput EnableScrollChange="False" Grid.Row="4" Grid.Column="5" IsVisible="{Binding IsInput}" Value="{Binding M45, Mode=TwoWay}" />
+        </Grid>
+    </StackPanel>
+</properties:NodePropertyView>

+ 14 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorMatrixPropertyView.axaml.cs

@@ -0,0 +1,14 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.AvaloniaUI.Views.Nodes.Properties;
+
+public partial class ColorMatrixPropertyView : NodePropertyView
+{
+    public ColorMatrixPropertyView()
+    {
+        InitializeComponent();
+    }
+}
+

+ 20 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorPropertyView.axaml

@@ -0,0 +1,20 @@
+<properties:NodePropertyView xmlns="https://github.com/avaloniaui"
+                             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+                             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+                             xmlns:properties="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes.Properties"
+                             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+                             xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+                             xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input"
+                             xmlns:colorPicker="clr-namespace:ColorPicker;assembly=ColorPicker.AvaloniaUI"
+                             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+                             x:Class="PixiEditor.AvaloniaUI.Views.Nodes.Properties.ColorPropertyView">
+    <Grid HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
+        <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}"/>
+        <colorPicker:PortableColorPicker
+            PointerPressed="InputElement_OnPointerPressed"
+            Width="40" Height="20"
+            IsVisible="{Binding ShowInputField}"
+            SelectedColor="{Binding Value, Mode=TwoWay}" />
+    </Grid>
+</properties:NodePropertyView>

+ 20 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/ColorPropertyView.axaml.cs

@@ -0,0 +1,20 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.AvaloniaUI.Views.Nodes.Properties;
+
+public partial class ColorPropertyView : NodePropertyView
+{
+    public ColorPropertyView()
+    {
+        InitializeComponent();
+    }
+
+    private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e)
+    {
+        e.Handled = true;
+    }
+}
+

+ 17 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/DoublePropertyView.axaml

@@ -0,0 +1,17 @@
+<properties:NodePropertyView x:TypeArguments="system:Double" xmlns="https://github.com/avaloniaui"
+                             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+                             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+                             xmlns:properties="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes.Properties"
+                             xmlns:system="clr-namespace:System;assembly=System.Runtime"
+                             xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input"
+                             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+                             xmlns:converters="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Converters"
+                             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+                             x:Class="PixiEditor.AvaloniaUI.Views.Nodes.Properties.DoublePropertyView">
+    <Grid HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
+        <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}"/>
+        <input:NumberInput EnableScrollChange="False" 
+                           HorizontalAlignment="Right" MinWidth="100" Decimals="6" IsVisible="{Binding ShowInputField}" Value="{Binding Value, Mode=TwoWay}" />
+    </Grid>
+</properties:NodePropertyView>

+ 14 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/Properties/DoublePropertyView.axaml.cs

@@ -0,0 +1,14 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.AvaloniaUI.Views.Nodes.Properties;
+
+public partial class DoublePropertyView : NodePropertyView
+{
+    public DoublePropertyView()
+    {
+        InitializeComponent();
+    }
+}
+

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно