Browse Source

Merge pull request #760 from PixiEditor/effect-nodes

Added Outline and Shader node + fixes
Krzysztof Krysiński 5 months ago
parent
commit
36d97cb41c
54 changed files with 1365 additions and 126 deletions
  1. 1 1
      src/Drawie
  2. 22 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/NodeInputsChanged_ChangeInfo.cs
  3. 4 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/PropertyValueUpdated_ChangeInfo.cs
  4. 8 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/ColorSpaces/ColorSpaceType.cs
  5. 36 23
      src/PixiEditor.ChangeableDocument/Changeables/Graph/InputProperty.cs
  6. 10 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CacheTriggerFlags.cs
  7. 3 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateChannelsNode.cs
  8. 130 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Effects/OutlineNode.cs
  9. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ApplyFilterNode.cs
  10. 0 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/GrayscaleNode.cs
  11. 3 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/KernelFilterNode.cs
  12. 26 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Node.cs
  13. 329 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs
  14. 35 8
      src/PixiEditor.ChangeableDocument/Changeables/Graph/PropertyValidator.cs
  15. 76 7
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/UpdateProperty_Change.cs
  16. BIN
      src/PixiEditor.Extensions.Sdk/build/PixiEditor.Api.CGlueMSBuild.dll
  17. BIN
      src/PixiEditor.Extensions.Sdk/build/PixiEditor.Extensions.MSPackageBuilder.dll
  18. 6 0
      src/PixiEditor.UI.Common/Accents/Base.axaml
  19. BIN
      src/PixiEditor.UI.Common/Fonts/PixiPerfect.ttf
  20. 4 0
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml
  21. BIN
      src/PixiEditor/Data/BetaExampleFiles/Disco Ball.pixi
  22. BIN
      src/PixiEditor/Data/BetaExampleFiles/Mask.pixi
  23. BIN
      src/PixiEditor/Data/BetaExampleFiles/Outline.pixi
  24. 15 2
      src/PixiEditor/Data/Localization/Languages/en.json
  25. 11 0
      src/PixiEditor/Helpers/Converters/NotEmptyStringConverter.cs
  26. 1 1
      src/PixiEditor/Helpers/CrashHelper.cs
  27. 91 17
      src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs
  28. 0 4
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorPathToolExecutor.cs
  29. 2 0
      src/PixiEditor/Models/Handlers/INodePropertyHandler.cs
  30. 5 2
      src/PixiEditor/Models/Rendering/PreviewPainter.cs
  31. 1 1
      src/PixiEditor/Styles/PixiEditor.Controls.axaml
  32. 14 4
      src/PixiEditor/Styles/Templates/NodePropertyViewTemplate.axaml
  33. 8 5
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  34. 1 1
      src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs
  35. 7 0
      src/PixiEditor/ViewModels/Document/Nodes/Effects/OutlineNodeViewModel.cs
  36. 19 1
      src/PixiEditor/ViewModels/Document/Nodes/FilterNodes/GrayscaleNodeViewModel.cs
  37. 29 0
      src/PixiEditor/ViewModels/Document/Nodes/ShaderNodeViewModel.cs
  38. 9 1
      src/PixiEditor/ViewModels/Nodes/NodePropertyViewModel.cs
  39. 96 2
      src/PixiEditor/ViewModels/Nodes/Properties/StringPropertyViewModel.cs
  40. 2 2
      src/PixiEditor/ViewModels/Nodes/Properties/Vec3DPropertyViewModel.cs
  41. 2 2
      src/PixiEditor/ViewModels/SubViewModels/LayersViewModel.cs
  42. 1 1
      src/PixiEditor/ViewModels/Tools/Tools/VectorPathToolViewModel.cs
  43. 8 2
      src/PixiEditor/Views/Nodes/Properties/DoublePropertyView.axaml
  44. 39 0
      src/PixiEditor/Views/Nodes/Properties/NodePropertyView.cs
  45. 79 9
      src/PixiEditor/Views/Nodes/Properties/StringPropertyView.axaml
  46. 39 2
      src/PixiEditor/Views/Nodes/Properties/StringPropertyView.axaml.cs
  47. 1 1
      src/PixiEditor/Views/Nodes/Properties/Vec3DPropertyView.axaml
  48. 2 2
      src/PixiEditor/Views/Nodes/Properties/Vec3DPropertyView.axaml.cs
  49. 12 2
      src/PixiEditor/Views/Rendering/Scene.cs
  50. 3 3
      src/PixiEditor/Views/Windows/BetaExampleButton.axaml
  51. 33 7
      src/PixiEditor/Views/Windows/BetaExampleButton.axaml.cs
  52. 8 3
      src/PixiEditor/Views/Windows/BetaExampleFile.cs
  53. 16 5
      src/PixiEditor/Views/Windows/HelloTherePopup.axaml
  54. 117 0
      tests/PixiEditor.Backend.Tests/ShaderTests.cs

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit 306b9f1b786af58a147166436eb521979f71f18a
+Subproject commit d23a32dd0499ba3f3b9881f48aa7a69395bba329

+ 22 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/NodeInputsChanged_ChangeInfo.cs

@@ -0,0 +1,22 @@
+using System.Collections.Immutable;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
+
+public record NodeInputsChanged_ChangeInfo(Guid NodeId, ImmutableArray<NodePropertyInfo> Inputs) : IChangeInfo
+{
+    public static NodeInputsChanged_ChangeInfo FromNode(Node node)
+    {
+        var infos = CreateNode_ChangeInfo.CreatePropertyInfos(node.InputProperties, true, node.Id);
+        return new NodeInputsChanged_ChangeInfo(node.Id, infos);
+    }
+}
+
+public record NodeOutputsChanged_ChangeInfo(Guid NodeId, ImmutableArray<NodePropertyInfo> Outputs) : IChangeInfo
+{
+    public static NodeOutputsChanged_ChangeInfo FromNode(Node node)
+    {
+        var infos = CreateNode_ChangeInfo.CreatePropertyInfos(node.OutputProperties, false, node.Id);
+        return new NodeOutputsChanged_ChangeInfo(node.Id, infos);
+    }
+}

+ 4 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/PropertyValueUpdated_ChangeInfo.cs

@@ -1,3 +1,6 @@
 namespace PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 namespace PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 
 
-public record PropertyValueUpdated_ChangeInfo(Guid NodeId, string Property, object Value) : IChangeInfo;
+public record PropertyValueUpdated_ChangeInfo(Guid NodeId, string Property, object Value) : IChangeInfo
+{
+    public string? Errors { get; set; }
+}

+ 8 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/ColorSpaces/ColorSpaceType.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.ColorSpaces;
+
+public enum ColorSpaceType
+{
+    Inherit,
+    Srgb,
+    LinearSrgb,
+}

+ 36 - 23
src/PixiEditor.ChangeableDocument/Changeables/Graph/InputProperty.cs

@@ -16,6 +16,7 @@ public class InputProperty : IInputProperty
     private IOutputProperty? connection;
     private IOutputProperty? connection;
 
 
     public event Action ConnectionChanged;
     public event Action ConnectionChanged;
+    public event Action<object> NonOverridenValueChanged;
 
 
     public string InternalPropertyName { get; }
     public string InternalPropertyName { get; }
     public string DisplayName { get; }
     public string DisplayName { get; }
@@ -41,7 +42,28 @@ public class InputProperty : IInputProperty
                 return FuncFactory(connectionValue);
                 return FuncFactory(connectionValue);
             }
             }
 
 
-            return connectionValue;
+            if (connectionValue.GetType().IsAssignableTo(ValueType))
+            {
+                return connectionValue;
+            }
+
+            if (connectionValue is Delegate func && ValueType.IsAssignableTo(typeof(Delegate)))
+            {
+                return FuncFactoryDelegate(func);
+            }
+
+            object target = connectionValue;
+            if (target is ShaderExpressionVariable shaderExpression)
+            {
+                target = shaderExpression.GetConstant();
+            }
+
+            if (!ConversionTable.TryConvert(target, ValueType, out object result))
+            {
+                return null;
+            }
+
+            return Validator.GetClosestValidValue(result);
         }
         }
     }
     }
 
 
@@ -51,6 +73,7 @@ public class InputProperty : IInputProperty
         set
         set
         {
         {
             _internalValue = value;
             _internalValue = value;
+            NonOverridenValueChanged?.Invoke(value);
             NonOverridenValueSet(value);
             NonOverridenValueSet(value);
         }
         }
     }
     }
@@ -92,17 +115,17 @@ public class InputProperty : IInputProperty
     {
     {
         get
         get
         {
         {
-            if(Connection == null && lastConnectionHash != -1)
+            if (Connection == null && lastConnectionHash != -1)
             {
             {
                 return true;
                 return true;
             }
             }
-            
-            if(Connection != null && lastConnectionHash != Connection.GetHashCode())
+
+            if (Connection != null && lastConnectionHash != Connection.GetHashCode())
             {
             {
                 lastConnectionHash = Connection.GetHashCode();
                 lastConnectionHash = Connection.GetHashCode();
                 return true;
                 return true;
             }
             }
-            
+
             if (Value is ICacheable cacheable)
             if (Value is ICacheable cacheable)
             {
             {
                 return cacheable.GetCacheHash() != _lastExecuteHash;
                 return cacheable.GetCacheHash() != _lastExecuteHash;
@@ -140,7 +163,7 @@ public class InputProperty : IInputProperty
         {
         {
             _lastExecuteHash = Value.GetHashCode();
             _lastExecuteHash = Value.GetHashCode();
         }
         }
-        
+
         lastConnectionHash = Connection?.GetHashCode() ?? -1;
         lastConnectionHash = Connection?.GetHashCode() ?? -1;
     }
     }
 
 
@@ -181,23 +204,7 @@ public class InputProperty<T> : InputProperty, IInputProperty<T>
             if (value is T tValue)
             if (value is T tValue)
                 return tValue;
                 return tValue;
 
 
-            if (value is Delegate func && typeof(T).IsAssignableTo(typeof(Delegate)))
-            {
-                return (T)FuncFactoryDelegate(func);
-            }
-
-            object target = value;
-            if (value is ShaderExpressionVariable shaderExpression)
-            {
-                target = shaderExpression.GetConstant();
-            }
-
-            if (!ConversionTable.TryConvert(target, typeof(T), out object result))
-            {
-                return default;
-            }
-
-            return (T)Validator.GetClosestValidValue(result);
+            return (T)Validator.GetClosestValidValue(value);
         }
         }
     }
     }
 
 
@@ -232,4 +239,10 @@ public class InputProperty<T> : InputProperty, IInputProperty<T>
         rules(Validator);
         rules(Validator);
         return this;
         return this;
     }
     }
+
+    public InputProperty<T> NonOverridenChanged(Action<T> callback)
+    {
+        NonOverridenValueChanged += value => callback((T)value);
+        return this;
+    }
 }
 }

+ 10 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CacheTriggerFlags.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+[Flags]
+public enum CacheTriggerFlags
+{
+    None = 0,
+    Inputs = 1,
+    Timeline = 2,
+    All = Inputs | Timeline
+}

+ 3 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateChannelsNode.cs

@@ -69,6 +69,9 @@ public class SeparateChannelsNode : Node, IRenderInput, IPreviewRenderable
 
 
     private void Paint(RenderContext context, DrawingSurface drawingSurface, ColorFilter colorFilter, ColorFilter grayscaleFilter)
     private void Paint(RenderContext context, DrawingSurface drawingSurface, ColorFilter colorFilter, ColorFilter grayscaleFilter)
     {
     {
+        if(Image.Value == null)
+            return;
+        
         bool grayscale = Grayscale.Value;
         bool grayscale = Grayscale.Value;
         
         
         ColorFilter filter = grayscale ? grayscaleFilter : colorFilter; 
         ColorFilter filter = grayscale ? grayscaleFilter : colorFilter; 

+ 130 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Effects/OutlineNode.cs

@@ -0,0 +1,130 @@
+using Drawie.Backend.Core;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Shaders;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Effects;
+
+[NodeInfo("Outline")]
+public class OutlineNode : RenderNode, IRenderInput
+{
+    public RenderInputProperty Background { get; }
+    public InputProperty<OutlineType> Type { get; }
+    public InputProperty<double> Thickness { get; }
+    public InputProperty<Color> Color { get; }
+
+    private Kernel simpleKernel = new Kernel(3, 3, [1, 1, 1, 1, 1, 1, 1, 1, 1]);
+    private Kernel pixelPerfectKernel = new Kernel(3, 3, [0, 1, 0, 1, -4, 1, 0, 1, 0]);
+    private Kernel gaussianKernel = new Kernel(5, 5, [
+        1, 4, 6, 4, 1,
+        4, 16, 24, 16, 4,
+        6, 24, 36, 24, 6,
+        4, 16, 24, 16, 4,
+        1, 4, 6, 4, 1
+    ]);
+
+    private Paint paint;
+    private ImageFilter filter;
+
+    private OutlineType? lastType = null;
+    private VecI lastDocumentSize;
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+
+    public OutlineNode()
+    {
+        Background = CreateRenderInput("Background", "BACKGROUND");
+        Type = CreateInput("Type", "TYPE", OutlineType.Simple);
+        Thickness = CreateInput("Thickness", "THICKNESS", 1.0)
+            .WithRules(validator => validator.Min(0.0));
+        Color = CreateInput("Color", "COLOR", Colors.Black);
+
+        paint = new Paint();
+
+        Output.FirstInChain = null;
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        base.OnExecute(context);
+        lastDocumentSize = context.DocumentSize;
+        if(lastType == Type.Value)
+        {
+            return;
+        }
+
+        Kernel finalKernel = Type.Value switch
+        {
+            OutlineType.Simple => simpleKernel,
+            OutlineType.Gaussian => gaussianKernel,
+            OutlineType.PixelPerfect => pixelPerfectKernel,
+            _ => simpleKernel
+        };
+
+        VecI offset = new VecI(finalKernel.RadiusX, finalKernel.RadiusY);
+        double gain = 1.0 / finalKernel.Sum;
+
+        filter?.Dispose();
+        filter = ImageFilter.CreateMatrixConvolution(finalKernel, (float)gain, 0, offset, TileMode.Clamp, true);
+
+        lastType = Type.Value;
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (Background.Value == null)
+        {
+            return;
+        }
+
+        if (Thickness.Value > 0)
+        {
+            paint.ImageFilter = filter;
+            paint.ColorFilter = ColorFilter.CreateBlendMode(Color.Value, BlendMode.SrcIn);
+
+            int saved = surface.Canvas.SaveLayer(paint);
+
+            Background.Value.Paint(context, surface);
+
+            surface.Canvas.RestoreToCount(saved);
+
+            for (int i = 1; i < (int)Thickness.Value; i++)
+            {
+                saved = surface.Canvas.SaveLayer(paint);
+
+                surface.Canvas.DrawSurface(surface, 0, 0);
+
+                surface.Canvas.RestoreToCount(saved);
+            }
+        }
+
+        Background.Value.Paint(context, surface);
+    }
+
+    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    {
+        return new RectD(0, 0, lastDocumentSize.X, lastDocumentSize.Y);
+    }
+
+    public override bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
+    {
+        OnPaint(context, renderOn);
+        return true;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new OutlineNode();
+    }
+}
+
+public enum OutlineType
+{
+    Simple,
+    Gaussian,
+    PixelPerfect,
+}

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ApplyFilterNode.cs

@@ -25,7 +25,7 @@ public class ApplyFilterNode : RenderNode, IRenderInput
 
 
     protected override void OnPaint(RenderContext context, DrawingSurface surface)
     protected override void OnPaint(RenderContext context, DrawingSurface surface)
     {
     {
-        if (Background.Value == null)
+        if (Background.Value == null || Filter.Value == null || _paint == null)
             return;
             return;
 
 
         _paint.SetFilters(Filter.Value);
         _paint.SetFilters(Filter.Value);

+ 0 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/GrayscaleNode.cs

@@ -15,7 +15,6 @@ public class GrayscaleNode : FilterNode
     
     
     public InputProperty<bool> Normalize { get; }
     public InputProperty<bool> Normalize { get; }
 
 
-    // TODO: Hide when Mode != Custom
     public InputProperty<Vec3D> CustomWeight { get; }
     public InputProperty<Vec3D> CustomWeight { get; }
     
     
     private GrayscaleMode lastMode;
     private GrayscaleMode lastMode;

+ 3 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/KernelFilterNode.cs

@@ -24,6 +24,7 @@ public class KernelFilterNode : FilterNode
     private TileMode lastTile;
     private TileMode lastTile;
     private double lastGain;
     private double lastGain;
     private double lastBias;
     private double lastBias;
+    private bool lastOnAlpha;
 
 
     private float[] lastKernelValues = new float[9];
     private float[] lastKernelValues = new float[9];
 
 
@@ -40,13 +41,14 @@ public class KernelFilterNode : FilterNode
     {
     {
         var kernel = Kernel.Value;
         var kernel = Kernel.Value;
         
         
-        if (kernel.AsSpan().SequenceEqual(lastKernelValues) && Tile.Value == lastTile && Gain.Value == lastGain && Bias.Value == lastBias)
+        if (kernel.AsSpan().SequenceEqual(lastKernelValues) && Tile.Value == lastTile && Gain.Value == lastGain && Bias.Value == lastBias && OnAlpha.Value == lastOnAlpha)
             return filter;
             return filter;
         
         
         lastKernel = kernel;
         lastKernel = kernel;
         lastTile = Tile.Value;
         lastTile = Tile.Value;
         lastGain = Gain.Value;
         lastGain = Gain.Value;
         lastBias = Bias.Value;
         lastBias = Bias.Value;
+        lastOnAlpha = OnAlpha.Value;
         
         
         filter?.Dispose();
         filter?.Dispose();
         
         

+ 26 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Node.cs

@@ -41,6 +41,9 @@ public abstract class Node : IReadOnlyNode, IDisposable
     }
     }
 
 
     protected virtual bool ExecuteOnlyOnCacheChange => false;
     protected virtual bool ExecuteOnlyOnCacheChange => false;
+    protected virtual CacheTriggerFlags CacheTrigger => CacheTriggerFlags.Inputs;
+
+    private KeyFrameTime lastFrameTime;
 
 
     protected internal bool IsDisposed => _isDisposed;
     protected internal bool IsDisposed => _isDisposed;
     private bool _isDisposed;
     private bool _isDisposed;
@@ -71,7 +74,19 @@ public abstract class Node : IReadOnlyNode, IDisposable
 
 
     protected virtual bool CacheChanged(RenderContext context)
     protected virtual bool CacheChanged(RenderContext context)
     {
     {
-        return inputs.Any(x => x.CacheChanged);
+        bool changed = false;
+
+        if (CacheTrigger.HasFlag(CacheTriggerFlags.Inputs))
+        {
+            changed |= inputs.Any(x => x.CacheChanged);
+        }
+
+        if (CacheTrigger.HasFlag(CacheTriggerFlags.Timeline))
+        {
+            changed |= lastFrameTime.Frame != context.FrameTime.Frame || Math.Abs(lastFrameTime.NormalizedTime - context.FrameTime.NormalizedTime) > float.Epsilon;
+        }
+
+        return changed;
     }
     }
 
 
     protected virtual void UpdateCache(RenderContext context)
     protected virtual void UpdateCache(RenderContext context)
@@ -80,6 +95,8 @@ public abstract class Node : IReadOnlyNode, IDisposable
         {
         {
             input.UpdateCache();
             input.UpdateCache();
         }
         }
+
+        lastFrameTime = context.FrameTime;
     }
     }
 
 
     public void TraverseBackwards(Func<IReadOnlyNode, IInputProperty, bool> action)
     public void TraverseBackwards(Func<IReadOnlyNode, IInputProperty, bool> action)
@@ -333,6 +350,14 @@ public abstract class Node : IReadOnlyNode, IDisposable
         return property;
         return property;
     }
     }
 
 
+    protected void RemoveInputProperty(InputProperty property)
+    {
+        if(inputs.Remove(property))
+        {
+            property.ConnectionChanged -= InvokeConnectionsChanged;
+        }
+    }
+
     protected void AddOutputProperty(OutputProperty property)
     protected void AddOutputProperty(OutputProperty property)
     {
     {
         outputs.Add(property);
         outputs.Add(property);

+ 329 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs

@@ -0,0 +1,329 @@
+using Drawie.Backend.Core;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Shaders;
+using Drawie.Backend.Core.Shaders.Generation;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.ColorSpaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+[NodeInfo("Shader")]
+public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
+{
+    public RenderInputProperty Background { get; }
+    public InputProperty<ColorSpaceType> ColorSpace { get; }
+    public InputProperty<string> ShaderCode { get; }
+
+    private Shader? shader;
+    private Shader? lastImageShader;
+    private string lastShaderCode;
+    private Paint paint;
+
+    private VecI lastDocumentSize;
+    private List<Shader> lastCustomImageShaders = new();
+
+    private Dictionary<string, (InputProperty prop, UniformValueType valueType)> uniformInputs = new();
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+    protected override CacheTriggerFlags CacheTrigger => CacheTriggerFlags.All;
+
+    public ShaderNode()
+    {
+        Background = CreateRenderInput("Background", "BACKGROUND");
+        ColorSpace = CreateInput("ColorSpace", "COLOR_SPACE", ColorSpaceType.Inherit);
+        ShaderCode = CreateInput("ShaderCode", "SHADER_CODE", "")
+            .WithRules(validator => validator.Custom(ValidateShaderCode))
+            .NonOverridenChanged(RegenerateUniformInputs);
+
+        paint = new Paint();
+        Output.FirstInChain = null;
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        base.OnExecute(context);
+
+        lastDocumentSize = context.DocumentSize;
+
+        if (lastShaderCode != ShaderCode.Value)
+        {
+            GenerateShader(context);
+        }
+        else if (shader != null)
+        {
+            Uniforms uniforms = GenerateUniforms(context);
+            shader = shader.WithUpdatedUniforms(uniforms);
+        }
+
+        paint.Shader = shader;
+    }
+
+    private void GenerateShader(RenderContext context)
+    {
+        Uniforms uniforms = null;
+
+        uniforms = GenerateUniforms(context);
+
+        shader?.Dispose();
+
+        if (uniforms != null)
+        {
+            shader = Shader.Create(ShaderCode.Value, uniforms, out _);
+        }
+        else
+        {
+            shader = Shader.Create(ShaderCode.Value, out _);
+        }
+
+        lastShaderCode = ShaderCode.Value;
+    }
+
+    private Uniforms GenerateUniforms(RenderContext context)
+    {
+        Uniforms uniforms;
+        uniforms = new Uniforms();
+
+        uniforms.Add("iResolution", new Uniform("iResolution", context.DocumentSize));
+        uniforms.Add("iNormalizedTime", new Uniform("iNormalizedTime", (float)context.FrameTime.NormalizedTime));
+        uniforms.Add("iFrame", new Uniform("iFrame", context.FrameTime.Frame));
+
+        AddCustomUniforms(uniforms);
+
+        if (Background.Value == null)
+        {
+            lastImageShader?.Dispose();
+            lastImageShader = null;
+            return uniforms;
+        }
+
+        Texture texture = RequestTexture(50, context.DocumentSize, context.ProcessingColorSpace);
+        Background.Value.Paint(context, texture.DrawingSurface);
+
+        var snapshot = texture.DrawingSurface.Snapshot();
+        lastImageShader?.Dispose();
+        lastImageShader = snapshot.ToShader();
+
+        uniforms.Add("iImage", new Uniform("iImage", lastImageShader));
+
+        snapshot.Dispose();
+        return uniforms;
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (shader == null || paint == null)
+        {
+            surface.Canvas.DrawColor(Colors.Magenta, BlendMode.Src);
+            return;
+        }
+
+        DrawingSurface targetSurface = surface;
+
+        if (ColorSpace.Value != ColorSpaceType.Inherit)
+        {
+            if (ColorSpace.Value == ColorSpaceType.Srgb && !context.ProcessingColorSpace.IsSrgb)
+            {
+                targetSurface = RequestTexture(51, context.DocumentSize,
+                    Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgb()).DrawingSurface;
+            }
+            else if (ColorSpace.Value == ColorSpaceType.LinearSrgb && context.ProcessingColorSpace.IsSrgb)
+            {
+                targetSurface = RequestTexture(51, context.DocumentSize,
+                    Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgbLinear()).DrawingSurface;
+            }
+        }
+
+        targetSurface.Canvas.DrawRect(0, 0, context.DocumentSize.X, context.DocumentSize.Y, paint);
+
+        if (targetSurface != surface)
+        {
+            surface.Canvas.DrawSurface(targetSurface, 0, 0);
+        }
+    }
+
+    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    {
+        return new RectD(0, 0, lastDocumentSize.X, lastDocumentSize.Y);
+    }
+
+    public override bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
+    {
+        OnPaint(context, renderOn);
+        return true;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new ShaderNode();
+    }
+
+    private void RegenerateUniformInputs(string newShaderCode)
+    {
+        UniformDeclaration[]? declarations = Shader.GetUniformDeclarations(newShaderCode);
+        if (declarations == null) return;
+
+        if (declarations.Length == 0)
+        {
+            foreach (var input in uniformInputs)
+            {
+                RemoveInputProperty(input.Value.prop);
+            }
+
+            uniformInputs.Clear();
+            return;
+        }
+
+        var uniforms = declarations;
+
+        var nonExistingUniforms = uniformInputs.Keys.Where(x => uniforms.All(y => y.Name != x)).ToList();
+        foreach (var nonExistingUniform in nonExistingUniforms)
+        {
+            RemoveInputProperty(uniformInputs[nonExistingUniform].prop);
+            uniformInputs.Remove(nonExistingUniform);
+        }
+
+        foreach (var uniform in uniforms)
+        {
+            if (IsBuiltInUniform(uniform.Name))
+            {
+                continue;
+            }
+
+            if (uniformInputs.ContainsKey(uniform.Name) && uniformInputs[uniform.Name].valueType != uniform.DataType)
+            {
+                RemoveInputProperty(uniformInputs[uniform.Name].prop);
+                uniformInputs.Remove(uniform.Name);
+            }
+
+            if (!uniformInputs.ContainsKey(uniform.Name))
+            {
+                InputProperty input;
+                if (uniform.DataType == UniformValueType.Float)
+                {
+                    input = CreateInput(uniform.Name, uniform.Name, 0d);
+                }
+                else if (uniform.DataType == UniformValueType.Shader)
+                {
+                    input = CreateInput<Texture>(uniform.Name, uniform.Name, null);
+                }
+                else if (uniform.DataType == UniformValueType.Color)
+                {
+                    input = CreateInput<Color>(uniform.Name, uniform.Name, Colors.Black);
+                }
+                else if (uniform.DataType == UniformValueType.Vector2)
+                {
+                    input = CreateInput<VecD>(uniform.Name, uniform.Name, new VecD(0, 0));
+                }
+                else if (uniform.DataType == UniformValueType.Vector3)
+                {
+                    input = CreateInput<Vec3D>(uniform.Name, uniform.Name, new Vec3D(0, 0, 0));
+                }
+                else
+                {
+                    continue;
+                }
+
+                uniformInputs.Add(uniform.Name, (input, uniform.DataType));
+            }
+        }
+    }
+
+    private void AddCustomUniforms(Uniforms uniforms)
+    {
+        foreach (var imgShader in lastCustomImageShaders)
+        {
+            imgShader.Dispose();
+        }
+
+        lastCustomImageShaders.Clear();
+
+        foreach (var input in uniformInputs)
+        {
+            object value = input.Value.prop.Value;
+            if (input.Value.prop.Value is ShaderExpressionVariable expressionVariable)
+            {
+                value = expressionVariable.GetConstant();
+            }
+
+            if (input.Value.valueType == UniformValueType.Float)
+            {
+                if (value is float floatValue)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, floatValue));
+                }
+                else if (value is double doubleValue)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, (float)doubleValue));
+                }
+                else if (value is int intValue)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, (float)intValue));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Vector2)
+            {
+                if (value is VecD vector)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, vector));
+                }
+                else if (value is VecI vecI)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, new VecD(vecI.X, vecI.Y)));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Vector3)
+            {
+                if (value is Vec3D vector)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, vector));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Vector4)
+            {
+                if (value is Vec4D vector)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, vector));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Color)
+            {
+                if (value is Color color)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, color));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Shader)
+            {
+                if (value is Texture texture)
+                {
+                    var snapshot = texture.DrawingSurface.Snapshot();
+                    Shader snapshotShader = snapshot.ToShader();
+                    lastCustomImageShaders.Add(snapshotShader);
+                    uniforms.Add(input.Key, new Uniform(input.Key, snapshotShader));
+                    snapshot.Dispose();
+                }
+            }
+        }
+    }
+
+    private bool IsBuiltInUniform(string name)
+    {
+        return name is "iResolution" or "iNormalizedTime" or "iFrame" or "iImage";
+    }
+
+    private ValidatorResult ValidateShaderCode(object? value)
+    {
+        if (value is string code)
+        {
+            var result = Shader.Create(code, out string errors);
+            result?.Dispose();
+            return new(string.IsNullOrWhiteSpace(errors), errors);
+        }
+
+        return new(false, "Shader code must be a string");
+    }
+}

+ 35 - 8
src/PixiEditor.ChangeableDocument/Changeables/Graph/PropertyValidator.cs

@@ -2,7 +2,7 @@
 
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph;
 namespace PixiEditor.ChangeableDocument.Changeables.Graph;
 
 
-public delegate (bool validationResult, object? closestValidValue) ValidateProperty(object? value);
+public delegate ValidatorResult ValidateProperty(object? value);
 
 
 public class PropertyValidator
 public class PropertyValidator
 {
 {
@@ -25,15 +25,21 @@ public class PropertyValidator
             if (value is T val)
             if (value is T val)
             {
             {
                 bool isValid = val.CompareTo(min) >= 0;
                 bool isValid = val.CompareTo(min) >= 0;
-                return (isValid, isValid ? val : GetReturnValue(val, min, adjust));
+                return new (isValid, isValid ? val : GetReturnValue(val, min, adjust));
             }
             }
 
 
-            return (false, GetReturnValue(min, min, adjust));
+            return new (false, GetReturnValue(min, min, adjust));
         });
         });
 
 
         return this;
         return this;
     }
     }
 
 
+    public PropertyValidator Custom(ValidateProperty rule)
+    {
+        Rules.Add(rule);
+        return this;
+    }
+
     private object? GetReturnValue<T>(T original, T min, Func<T, T>? fallback) where T : IComparable<T>
     private object? GetReturnValue<T>(T original, T min, Func<T, T>? fallback) where T : IComparable<T>
     {
     {
         if (fallback != null)
         if (fallback != null)
@@ -44,25 +50,46 @@ public class PropertyValidator
         return min;
         return min;
     }
     }
 
 
-    public bool Validate(object? value)
+    public bool Validate(object? value, out string? errors)
     {
     {
         object lastValue = value;
         object lastValue = value;
 
 
         foreach (var rule in Rules)
         foreach (var rule in Rules)
         {
         {
-            var (isValid, toPass) = rule(lastValue);
-            lastValue = toPass;
-            if (!isValid)
+            var result = rule(lastValue);
+            lastValue = result.ClosestValidValue;
+            if (!result.IsValid)
             {
             {
+                errors = result.ErrorMessage;
                 return false;
                 return false;
             }
             }
         }
         }
 
 
+        errors = null;
         return true;
         return true;
     }
     }
 
 
     public object? GetClosestValidValue(object? o)
     public object? GetClosestValidValue(object? o)
     {
     {
-        return Rules.Aggregate(o, (current, rule) => rule(current).closestValidValue);
+        return Rules.Aggregate(o, (current, rule) => rule(current).ClosestValidValue);
+    }
+}
+
+public record ValidatorResult
+{
+    public bool IsValid { get; }
+    public object? ClosestValidValue { get; }
+    public string? ErrorMessage { get; }
+
+    public ValidatorResult(bool isValid, string? errorMessage)
+    {
+        IsValid = isValid;
+        ErrorMessage = errorMessage;
+    }
+
+    public ValidatorResult(bool isValid, object? closestValidValue)
+    {
+        IsValid = isValid;
+        ClosestValidValue = closestValidValue;
     }
     }
 }
 }

+ 76 - 7
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/UpdateProperty_Change.cs

@@ -35,15 +35,23 @@ internal class UpdatePropertyValue_Change : Change
         var node = target.NodeGraph.Nodes.First(x => x.Id == _nodeId);
         var node = target.NodeGraph.Nodes.First(x => x.Id == _nodeId);
         var property = node.GetInputProperty(_propertyName);
         var property = node.GetInputProperty(_propertyName);
 
 
+        int inputsHash = CalculateInputsHash(node);
+        int outputsHash = CalculateOutputsHash(node);
+
         previousValue = GetValue(property);
         previousValue = GetValue(property);
-        if (!property.Validator.Validate(_value))
+        string errors = string.Empty;
+        if (!property.Validator.Validate(_value, out errors))
         {
         {
-            _value = property.Validator.GetClosestValidValue(_value);
-            if (_value == previousValue)
+            if (string.IsNullOrEmpty(errors))
             {
             {
-                ignoreInUndo = true;
+                _value = property.Validator.GetClosestValidValue(_value);
+                if (_value == previousValue)
+                {
+                    ignoreInUndo = true;
+                }
             }
             }
-            
+
+            _value = SetValue(property, _value);
             ignoreInUndo = false;
             ignoreInUndo = false;
         }
         }
         else
         else
@@ -52,16 +60,53 @@ internal class UpdatePropertyValue_Change : Change
             ignoreInUndo = false;
             ignoreInUndo = false;
         }
         }
 
 
-        return new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, _value);
+        List<IChangeInfo> changes = new();
+        changes.Add(new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, _value) { Errors = errors });
+
+        int newInputsHash = CalculateInputsHash(node);
+        int newOutputsHash = CalculateOutputsHash(node);
+
+        if (inputsHash != newInputsHash)
+        {
+            changes.Add(NodeInputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        if (outputsHash != newOutputsHash)
+        {
+            changes.Add(NodeOutputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        return changes;
     }
     }
 
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
     {
         var node = target.NodeGraph.Nodes.First(x => x.Id == _nodeId);
         var node = target.NodeGraph.Nodes.First(x => x.Id == _nodeId);
         var property = node.GetInputProperty(_propertyName);
         var property = node.GetInputProperty(_propertyName);
+
+        int inputsHash = CalculateInputsHash(node);
+        int outputsHash = CalculateOutputsHash(node);
+
         SetValue(property, previousValue);
         SetValue(property, previousValue);
 
 
-        return new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, previousValue);
+        List<IChangeInfo> changes = new();
+
+        changes.Add(new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, previousValue));
+
+        int newInputsHash = CalculateInputsHash(node);
+        int newOutputsHash = CalculateOutputsHash(node);
+
+        if (inputsHash != newInputsHash)
+        {
+            changes.Add(NodeInputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        if (outputsHash != newOutputsHash)
+        {
+            changes.Add(NodeOutputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        return changes;
     }
     }
 
 
     private static object SetValue(InputProperty property, object? value)
     private static object SetValue(InputProperty property, object? value)
@@ -93,4 +138,28 @@ internal class UpdatePropertyValue_Change : Change
 
 
         return property.NonOverridenValue;
         return property.NonOverridenValue;
     }
     }
+
+    private static int CalculateInputsHash(Node node)
+    {
+        HashCode hash = new();
+        foreach (var input in node.InputProperties)
+        {
+            hash.Add(input.InternalPropertyName);
+            hash.Add(input.ValueType);
+        }
+
+        return hash.ToHashCode();
+    }
+
+    private static int CalculateOutputsHash(Node node)
+    {
+        HashCode hash = new();
+        foreach (var output in node.OutputProperties)
+        {
+            hash.Add(output.InternalPropertyName);
+            hash.Add(output.ValueType);
+        }
+
+        return hash.ToHashCode();
+    }
 }
 }

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


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


+ 6 - 0
src/PixiEditor.UI.Common/Accents/Base.axaml

@@ -29,6 +29,7 @@
             <Color x:Key="ThemeBorderHighColor">#4F4F4F</Color>
             <Color x:Key="ThemeBorderHighColor">#4F4F4F</Color>
 
 
             <Color x:Key="ErrorColor">#B00020</Color>
             <Color x:Key="ErrorColor">#B00020</Color>
+            <Color x:Key="ErrorOnDarkColor">#FF0000</Color>
 
 
             <Color x:Key="GlyphColor">#444</Color>
             <Color x:Key="GlyphColor">#444</Color>
             <Color x:Key="GlyphBackground">White</Color>
             <Color x:Key="GlyphBackground">White</Color>
@@ -52,6 +53,7 @@
             <Color x:Key="DoubleSocketColor">#efb66d</Color>
             <Color x:Key="DoubleSocketColor">#efb66d</Color>
             <Color x:Key="ColorSocketColor">#8cf2dd</Color>
             <Color x:Key="ColorSocketColor">#8cf2dd</Color>
             <Color x:Key="VecDSocketColor">#c984ca</Color>
             <Color x:Key="VecDSocketColor">#c984ca</Color>
+            <Color x:Key="Vec3DSocketColor">#597513</Color>
             <Color x:Key="VecISocketColor">#c9b4ca</Color>
             <Color x:Key="VecISocketColor">#c9b4ca</Color>
             <Color x:Key="IntSocketColor">#4C64B1</Color>
             <Color x:Key="IntSocketColor">#4C64B1</Color>
             <Color x:Key="StringSocketColor">#C9E4C6</Color>
             <Color x:Key="StringSocketColor">#C9E4C6</Color>
@@ -85,6 +87,7 @@
             <Color x:Key="NumbersCategoryBackgroundColor">#666666</Color>
             <Color x:Key="NumbersCategoryBackgroundColor">#666666</Color>
             <Color x:Key="ColorCategoryBackgroundColor">#3B665D</Color>
             <Color x:Key="ColorCategoryBackgroundColor">#3B665D</Color>
             <Color x:Key="AnimationCategoryBackgroundColor">#4D4466</Color>
             <Color x:Key="AnimationCategoryBackgroundColor">#4D4466</Color>
+            <Color x:Key="EffectsCategoryBackgroundColor">#e36262</Color>
             
             
             <Color x:Key="HorizontalSnapAxisColor">#B00022</Color>
             <Color x:Key="HorizontalSnapAxisColor">#B00022</Color>
             <Color x:Key="VerticalSnapAxisColor">#5fad65</Color>
             <Color x:Key="VerticalSnapAxisColor">#5fad65</Color>
@@ -119,6 +122,7 @@
             <SolidColorBrush x:Key="NotificationCardBackgroundBrush" Color="{StaticResource NotificationCardBackgroundColor}" />
             <SolidColorBrush x:Key="NotificationCardBackgroundBrush" Color="{StaticResource NotificationCardBackgroundColor}" />
 
 
             <SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}" />
             <SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}" />
+            <SolidColorBrush x:Key="ErrorOnDarkBrush" Color="{StaticResource ErrorOnDarkColor}" />
             <SolidColorBrush x:Key="GlyphBrush" Color="{StaticResource GlyphColor}"/>
             <SolidColorBrush x:Key="GlyphBrush" Color="{StaticResource GlyphColor}"/>
             <SolidColorBrush x:Key="ThumbBrush" Color="{StaticResource ThumbColor}"/>
             <SolidColorBrush x:Key="ThumbBrush" Color="{StaticResource ThumbColor}"/>
             
             
@@ -141,6 +145,7 @@
             <SolidColorBrush x:Key="ColorSocketBrush" Color="{StaticResource ColorSocketColor}"/>
             <SolidColorBrush x:Key="ColorSocketBrush" Color="{StaticResource ColorSocketColor}"/>
             <SolidColorBrush x:Key="Half4SocketBrush" Color="{StaticResource ColorSocketColor}"/>
             <SolidColorBrush x:Key="Half4SocketBrush" Color="{StaticResource ColorSocketColor}"/>
             <SolidColorBrush x:Key="VecDSocketBrush" Color="{StaticResource VecDSocketColor}"/>
             <SolidColorBrush x:Key="VecDSocketBrush" Color="{StaticResource VecDSocketColor}"/>
+            <SolidColorBrush x:Key="Vec3DSocketBrush" Color="{StaticResource Vec3DSocketColor}"/>
             <SolidColorBrush x:Key="Float2SocketBrush" Color="{StaticResource VecDSocketColor}"/>
             <SolidColorBrush x:Key="Float2SocketBrush" Color="{StaticResource VecDSocketColor}"/>
             <SolidColorBrush x:Key="VecISocketBrush" Color="{StaticResource VecISocketColor}"/>
             <SolidColorBrush x:Key="VecISocketBrush" Color="{StaticResource VecISocketColor}"/>
             <SolidColorBrush x:Key="Int2SocketBrush" Color="{StaticResource VecISocketColor}"/>
             <SolidColorBrush x:Key="Int2SocketBrush" Color="{StaticResource VecISocketColor}"/>
@@ -174,6 +179,7 @@
             <SolidColorBrush x:Key="NumbersCategoryBackgroundBrush" Color="{StaticResource NumbersCategoryBackgroundColor}" />
             <SolidColorBrush x:Key="NumbersCategoryBackgroundBrush" Color="{StaticResource NumbersCategoryBackgroundColor}" />
             <SolidColorBrush x:Key="ColorCategoryBackgroundBrush" Color="{StaticResource ColorCategoryBackgroundColor}" />
             <SolidColorBrush x:Key="ColorCategoryBackgroundBrush" Color="{StaticResource ColorCategoryBackgroundColor}" />
             <SolidColorBrush x:Key="AnimationCategoryBackgroundBrush" Color="{StaticResource AnimationCategoryBackgroundColor}" />
             <SolidColorBrush x:Key="AnimationCategoryBackgroundBrush" Color="{StaticResource AnimationCategoryBackgroundColor}" />
+            <SolidColorBrush x:Key="EffectsCategoryBackgroundBrush" Color="{StaticResource EffectsCategoryBackgroundColor}" />
 
 
             <SolidColorBrush x:Key="HorizontalSnapAxisBrush" Color="{StaticResource HorizontalSnapAxisColor}"/>
             <SolidColorBrush x:Key="HorizontalSnapAxisBrush" Color="{StaticResource HorizontalSnapAxisColor}"/>
             <SolidColorBrush x:Key="VerticalSnapAxisBrush" Color="{StaticResource VerticalSnapAxisColor}"/>
             <SolidColorBrush x:Key="VerticalSnapAxisBrush" Color="{StaticResource VerticalSnapAxisColor}"/>

BIN
src/PixiEditor.UI.Common/Fonts/PixiPerfect.ttf


+ 4 - 0
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml

@@ -153,6 +153,10 @@
             <system:String x:Key="icon-linked-pipette">&#xE997;</system:String>
             <system:String x:Key="icon-linked-pipette">&#xE997;</system:String>
             <system:String x:Key="icon-text-underline">&#xE998;</system:String>
             <system:String x:Key="icon-text-underline">&#xE998;</system:String>
             <system:String x:Key="icon-text-round">&#xE999;</system:String>
             <system:String x:Key="icon-text-round">&#xE999;</system:String>
+            <system:String x:Key="icon-fullscreen">&#xE98c;</system:String>
+            <system:String x:Key="icon-outline">&#xE99a;</system:String>
+            <system:String x:Key="icon-terminal">&#xE99b;</system:String>
+
         </ResourceDictionary>
         </ResourceDictionary>
     </Styles.Resources>
     </Styles.Resources>
 
 

BIN
src/PixiEditor/Data/BetaExampleFiles/Disco Ball.pixi


BIN
src/PixiEditor/Data/BetaExampleFiles/Mask.pixi


BIN
src/PixiEditor/Data/BetaExampleFiles/Outline.pixi


+ 15 - 2
src/PixiEditor/Data/Localization/Languages/en.json

@@ -648,7 +648,6 @@
   "CREATE_IMAGE_NODE": "Create Image",
   "CREATE_IMAGE_NODE": "Create Image",
   "FOLDER_NODE": "Folder",
   "FOLDER_NODE": "Folder",
   "IMAGE_LAYER_NODE": "Image Layer",
   "IMAGE_LAYER_NODE": "Image Layer",
-  "IMAGE_SPACE_NODE": "Image Space",
   "KERNEL_FILTER_NODE": "Kernel Filter",
   "KERNEL_FILTER_NODE": "Kernel Filter",
   "MATH_NODE": "Math",
   "MATH_NODE": "Math",
   "COLOR_MATRIX_TRANSFORM_FILTER_NODE": "Matrix Transform Filter",
   "COLOR_MATRIX_TRANSFORM_FILTER_NODE": "Matrix Transform Filter",
@@ -855,5 +854,19 @@
   "TEXT_NODE": "Text",
   "TEXT_NODE": "Text",
   "TEXT_LABEL": "Text",
   "TEXT_LABEL": "Text",
   "TEXT_ON_PATH_NODE": "Text on Path",
   "TEXT_ON_PATH_NODE": "Text on Path",
-  "HIGH_DPI_RENDERING": "High DPI Rendering"
+  "HIGH_DPI_RENDERING": "High DPI Rendering",
+  "THICKNESS": "Thickness",
+  "TYPE": "Type",
+  "EFFECTS": "Effects",
+  "OUTLINE_NODE": "Outline",
+  "SHADER_CODE": "Shader Code",
+  "SHADER_NODE": "Shader",
+  "FAILED_TO_OPEN_EDITABLE_STRING_TITLE": "Failed to open file",
+  "FAILED_TO_OPEN_EDITABLE_STRING_MESSAGE": "Failed to edit this string in external editor. Reason: {0}",
+  "STRING_EDIT_IN_DEFAULT_APP": "Edit in default app",
+  "STRING_OPEN_IN_FOLDER": "Open in folder",
+  "DISCO_BALL_EXAMPLE": "Disco Ball",
+  "COLOR_SPACE": "Color Space",
+  "PHOTO_EXAMPLES": "Photo",
+  "MASK_EXAMPLE": "Mask"
 }
 }

+ 11 - 0
src/PixiEditor/Helpers/Converters/NotEmptyStringConverter.cs

@@ -0,0 +1,11 @@
+using System.Globalization;
+
+namespace PixiEditor.Helpers.Converters;
+
+internal class NotEmptyStringConverter : SingleInstanceConverter<NotEmptyStringConverter>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+    {
+        return !string.IsNullOrEmpty(value as string);
+    }
+}

+ 1 - 1
src/PixiEditor/Helpers/CrashHelper.cs

@@ -165,7 +165,7 @@ internal partial class CrashHelper
         string reportText = report.ReportText;
         string reportText = report.ReportText;
         if (catchLocation is not null)
         if (catchLocation is not null)
         {
         {
-            reportText = $"The report was generated from an exception caught in {catchLocation}.\r\n{reportText}";
+            reportText = $"The report was generated from an exception caught in {Path.GetFileName(catchLocation)}.\r\n{reportText}";
         }
         }
 
 
         byte[] bytes = Encoding.UTF8.GetBytes(reportText);
         byte[] bytes = Encoding.UTF8.GetBytes(reportText);

+ 91 - 17
src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs

@@ -187,6 +187,12 @@ internal class DocumentUpdater
             case ConnectProperty_ChangeInfo info:
             case ConnectProperty_ChangeInfo info:
                 ProcessConnectProperty(info);
                 ProcessConnectProperty(info);
                 break;
                 break;
+            case NodeInputsChanged_ChangeInfo info:
+                ProcessInputsChanged(info);
+                break;
+            case NodeOutputsChanged_ChangeInfo info:
+                ProcessOutputsChanged(info);
+                break;
             case NodePosition_ChangeInfo info:
             case NodePosition_ChangeInfo info:
                 ProcessNodePosition(info);
                 ProcessNodePosition(info);
                 break;
                 break;
@@ -370,7 +376,7 @@ internal class DocumentUpdater
             memberVM = doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == info.Id) as ILayerHandler;
             memberVM = doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == info.Id) as ILayerHandler;
             if (memberVM is ITransparencyLockableMember transparencyLockableMember)
             if (memberVM is ITransparencyLockableMember transparencyLockableMember)
             {
             {
-                transparencyLockableMember.SetLockTransparency(layerInfo.LockTransparency);        
+                transparencyLockableMember.SetLockTransparency(layerInfo.LockTransparency);
             }
             }
         }
         }
         else if (info is CreateFolder_ChangeInfo)
         else if (info is CreateFolder_ChangeInfo)
@@ -425,7 +431,7 @@ internal class DocumentUpdater
                 closestMember.Selection = StructureMemberSelectionType.Hard;
                 closestMember.Selection = StructureMemberSelectionType.Hard;
             }
             }
 
 
-            
+
             doc.SetSelectedMember(closestMember);
             doc.SetSelectedMember(closestMember);
         }
         }
 
 
@@ -437,7 +443,6 @@ internal class DocumentUpdater
     {
     {
         IStructureMemberHandler? memberVM = doc.StructureHelper.FindOrThrow(info.Id);
         IStructureMemberHandler? memberVM = doc.StructureHelper.FindOrThrow(info.Id);
         memberVM.SetIsVisible(info.IsVisible);
         memberVM.SetIsVisible(info.IsVisible);
-        
     }
     }
 
 
     private void ProcessUpdateStructureMemberName(StructureMemberName_ChangeInfo info)
     private void ProcessUpdateStructureMemberName(StructureMemberName_ChangeInfo info)
@@ -472,7 +477,7 @@ internal class DocumentUpdater
         var vm = new IRasterCelViewModel(info.TargetLayerGuid, info.Frame, 1,
         var vm = new IRasterCelViewModel(info.TargetLayerGuid, info.Frame, 1,
             info.KeyFrameId,
             info.KeyFrameId,
             (DocumentViewModel)doc, helper);
             (DocumentViewModel)doc, helper);
-        
+
         doc.AnimationHandler.AddKeyFrame(vm);
         doc.AnimationHandler.AddKeyFrame(vm);
     }
     }
 
 
@@ -514,7 +519,7 @@ internal class DocumentUpdater
     private void ProcessCreateNode(CreateNode_ChangeInfo info)
     private void ProcessCreateNode(CreateNode_ChangeInfo info)
     {
     {
         var nodeType = info.Metadata.NodeType;
         var nodeType = info.Metadata.NodeType;
-        
+
         var ns = nodeType.Namespace.Replace("ChangeableDocument.Changeables.Graph.", "ViewModels.Document.");
         var ns = nodeType.Namespace.Replace("ChangeableDocument.Changeables.Graph.", "ViewModels.Document.");
         var name = nodeType.Name.Replace("Node", "NodeViewModel");
         var name = nodeType.Name.Replace("Node", "NodeViewModel");
         var fullViewModelName = $"{ns}.{name}";
         var fullViewModelName = $"{ns}.{name}";
@@ -522,19 +527,83 @@ internal class DocumentUpdater
 
 
         if (nodeViewModelType == null)
         if (nodeViewModelType == null)
             throw new NullReferenceException($"No ViewModel found for {nodeType}. Looking for '{fullViewModelName}'");
             throw new NullReferenceException($"No ViewModel found for {nodeType}. Looking for '{fullViewModelName}'");
-        
+
         var viewModel = (NodeViewModel)Activator.CreateInstance(nodeViewModelType);
         var viewModel = (NodeViewModel)Activator.CreateInstance(nodeViewModelType);
 
 
         InitializeNodeViewModel(info, viewModel);
         InitializeNodeViewModel(info, viewModel);
     }
     }
 
 
+    private void ProcessInputsChanged(NodeInputsChanged_ChangeInfo info)
+    {
+        NodeViewModel node = doc.StructureHelper.FindNode<NodeViewModel>(info.NodeId);
+
+        List<INodePropertyHandler> removedInputs = new List<INodePropertyHandler>();
+
+        foreach (var input in node.Inputs)
+        {
+            if (!info.Inputs.Any(x => x.PropertyName == input.PropertyName))
+            {
+                removedInputs.Add(input);
+            }
+
+            if(info.Inputs.FirstOrDefault(x => x.PropertyName == input.PropertyName && x.ValueType != input.PropertyType) is { } changedInput)
+            {
+                removedInputs.Add(input);
+            }
+        }
+
+        foreach (var input in removedInputs)
+        {
+            node.Inputs.Remove(input);
+            doc.NodeGraphHandler.RemoveConnection(input.Node.Id, input.PropertyName);
+        }
+
+        List<NodePropertyInfo> newInputs =
+            info.Inputs.Where(x => node.Inputs.All(y => y.PropertyName != x.PropertyName)).ToList();
+
+        List<INodePropertyHandler> inputs = CreateProperties([..newInputs], node, true);
+        node.Inputs.AddRange(inputs);
+    }
+
+    private void ProcessOutputsChanged(NodeOutputsChanged_ChangeInfo info)
+    {
+        NodeViewModel node = doc.StructureHelper.FindNode<NodeViewModel>(info.NodeId);
+
+        List<INodePropertyHandler> removedOutputs = new List<INodePropertyHandler>();
+
+        foreach (var output in node.Outputs)
+        {
+            if (!info.Outputs.Any(x => x.PropertyName == output.PropertyName))
+            {
+                removedOutputs.Add(output);
+            }
+
+            if(info.Outputs.FirstOrDefault(x => x.PropertyName == output.PropertyName && x.ValueType != output.Value.GetType()) is { } changedOutput)
+            {
+                removedOutputs.Add(output);
+            }
+        }
+
+        foreach (var output in removedOutputs)
+        {
+            node.Outputs.Remove(output);
+            doc.NodeGraphHandler.RemoveConnection(output.Node.Id, output.PropertyName);
+        }
+
+        List<NodePropertyInfo> newOutputs =
+            info.Outputs.Where(x => node.Outputs.All(y => y.PropertyName != x.PropertyName)).ToList();
+
+        List<INodePropertyHandler> outputs = CreateProperties([..newOutputs], node, false);
+        node.Outputs.AddRange(outputs);
+    }
+
     private void InitializeNodeViewModel(CreateNode_ChangeInfo info, NodeViewModel viewModel)
     private void InitializeNodeViewModel(CreateNode_ChangeInfo info, NodeViewModel viewModel)
     {
     {
         viewModel.Initialize(info.Id, info.InternalName, (DocumentViewModel)doc, helper);
         viewModel.Initialize(info.Id, info.InternalName, (DocumentViewModel)doc, helper);
-        
+
         viewModel.SetName(info.NodeName);
         viewModel.SetName(info.NodeName);
         viewModel.SetPosition(info.Position);
         viewModel.SetPosition(info.Position);
-        
+
         var inputs = CreateProperties(info.Inputs, viewModel, true);
         var inputs = CreateProperties(info.Inputs, viewModel, true);
         var outputs = CreateProperties(info.Outputs, viewModel, false);
         var outputs = CreateProperties(info.Outputs, viewModel, false);
         viewModel.Inputs.AddRange(inputs);
         viewModel.Inputs.AddRange(inputs);
@@ -544,7 +613,7 @@ internal class DocumentUpdater
         viewModel.Metadata = info.Metadata;
         viewModel.Metadata = info.Metadata;
 
 
         AddZoneIfNeeded(info, viewModel);
         AddZoneIfNeeded(info, viewModel);
-        
+
         viewModel.OnInitialized();
         viewModel.OnInitialized();
     }
     }
 
 
@@ -553,8 +622,9 @@ internal class DocumentUpdater
         if (node.Metadata?.PairNodeGuid != null)
         if (node.Metadata?.PairNodeGuid != null)
         {
         {
             if (node.Metadata.PairNodeGuid == Guid.Empty) return;
             if (node.Metadata.PairNodeGuid == Guid.Empty) return;
-            
-            INodeHandler otherNode = doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == node.Metadata.PairNodeGuid);
+
+            INodeHandler otherNode =
+                doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == node.Metadata.PairNodeGuid);
             if (otherNode != null)
             if (otherNode != null)
             {
             {
                 bool zoneExists =
                 bool zoneExists =
@@ -580,7 +650,9 @@ internal class DocumentUpdater
             prop.PropertyName = input.PropertyName;
             prop.PropertyName = input.PropertyName;
             prop.IsInput = isInput;
             prop.IsInput = isInput;
             prop.IsFunc = input.ValueType.IsAssignableTo(typeof(Delegate));
             prop.IsFunc = input.ValueType.IsAssignableTo(typeof(Delegate));
-            prop.InternalSetValue(prop.IsFunc ? (input.InputValue as ShaderExpressionVariable)?.GetConstant() : input.InputValue);
+            prop.InternalSetValue(prop.IsFunc
+                ? (input.InputValue as ShaderExpressionVariable)?.GetConstant()
+                : input.InputValue);
             inputs.Add(prop);
             inputs.Add(prop);
         }
         }
 
 
@@ -603,7 +675,7 @@ internal class DocumentUpdater
 
 
         doc.NodeGraphHandler.RemoveConnections(info.Id);
         doc.NodeGraphHandler.RemoveConnections(info.Id);
         doc.NodeGraphHandler.RemoveNode(info.Id);
         doc.NodeGraphHandler.RemoveNode(info.Id);
-        
+
         doc.SnappingHandler.SnappingController.RemoveAll(info.Id.ToString());
         doc.SnappingHandler.SnappingController.RemoveAll(info.Id.ToString());
     }
     }
 
 
@@ -664,16 +736,18 @@ internal class DocumentUpdater
         NodeViewModel node = doc.StructureHelper.FindNode<NodeViewModel>(info.NodeId);
         NodeViewModel node = doc.StructureHelper.FindNode<NodeViewModel>(info.NodeId);
         var property = node.FindInputProperty(info.Property);
         var property = node.FindInputProperty(info.Property);
 
 
+        property.Errors = info.Errors;
+
         ProcessStructureMemberProperty(info, property);
         ProcessStructureMemberProperty(info, property);
-        
+
         property.InternalSetValue(info.Value);
         property.InternalSetValue(info.Value);
-        
+
         if (info.Property == CustomOutputNode.OutputNamePropertyName)
         if (info.Property == CustomOutputNode.OutputNamePropertyName)
         {
         {
             doc.NodeGraphHandler.UpdateAvailableRenderOutputs();
             doc.NodeGraphHandler.UpdateAvailableRenderOutputs();
         }
         }
     }
     }
-    
+
     private void ProcessStructureMemberProperty(PropertyValueUpdated_ChangeInfo info, INodePropertyHandler property)
     private void ProcessStructureMemberProperty(PropertyValueUpdated_ChangeInfo info, INodePropertyHandler property)
     {
     {
         // TODO: This most likely can be handled inside viewmodel itself
         // TODO: This most likely can be handled inside viewmodel itself
@@ -717,7 +791,7 @@ internal class DocumentUpdater
     {
     {
         doc.AnimationHandler.SetOnionFrames(info.OnionFrames, info.Opacity);
         doc.AnimationHandler.SetOnionFrames(info.OnionFrames, info.Opacity);
     }
     }
-    
+
     private void ProcessProcessingColorSpace(ProcessingColorSpace_ChangeInfo info)
     private void ProcessProcessingColorSpace(ProcessingColorSpace_ChangeInfo info)
     {
     {
         doc.SetProcessingColorSpace(info.NewColorSpace);
         doc.SetProcessingColorSpace(info.NewColorSpace);

+ 0 - 4
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorPathToolExecutor.cs

@@ -89,10 +89,6 @@ internal class VectorPathToolExecutor : UpdateableChangeExecutor, IPathExecutorF
                 {
                 {
                     startingPath.MoveTo((VecF)snapped);
                     startingPath.MoveTo((VecF)snapped);
                 }
                 }
-                else
-                {
-                    startingPath.LineTo((VecF)snapped);
-                }
 
 
                 if (toolbar.SyncWithPrimaryColor)
                 if (toolbar.SyncWithPrimaryColor)
                 {
                 {

+ 2 - 0
src/PixiEditor/Models/Handlers/INodePropertyHandler.cs

@@ -5,6 +5,7 @@ namespace PixiEditor.Models.Handlers;
 
 
 public interface INodePropertyHandler
 public interface INodePropertyHandler
 {
 {
+    public bool IsVisible { get; set; }
     public string PropertyName { get; set; }
     public string PropertyName { get; set; }
     public string DisplayName { get; set; }
     public string DisplayName { get; set; }
     public object Value { get; set; }
     public object Value { get; set; }
@@ -14,4 +15,5 @@ public interface INodePropertyHandler
 
 
     public event NodePropertyValueChanged ValueChanged;
     public event NodePropertyValueChanged ValueChanged;
     public INodeHandler Node { get; set; }
     public INodeHandler Node { get; set; }
+    public Type PropertyType { get; }
 }
 }

+ 5 - 2
src/PixiEditor/Models/Rendering/PreviewPainter.cs

@@ -96,8 +96,11 @@ public class PreviewPainter
             return;
             return;
         }
         }
 
 
-        renderTextures[requestId]?.Dispose();
-        renderTextures.Remove(requestId);
+        if (renderTextures.TryGetValue(requestId, out var renderTexture))
+        {
+            renderTexture?.Dispose();
+            renderTextures.Remove(requestId);
+        }
     }
     }
 
 
     public void Repaint()
     public void Repaint()

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

@@ -23,7 +23,7 @@
         </ResourceDictionary>
         </ResourceDictionary>
     </Styles.Resources>
     </Styles.Resources>
 
 
+    <StyleInclude Source="avares://PixiEditor/Styles/Templates/NodePropertyViewTemplate.axaml"/>
     <StyleInclude Source="avares://PixiEditor/Styles/PortingWipStyles.axaml"/>
     <StyleInclude Source="avares://PixiEditor/Styles/PortingWipStyles.axaml"/>
     <StyleInclude Source="avares://PixiEditor/Styles/ToolPickerButton.Styles.axaml"/>
     <StyleInclude Source="avares://PixiEditor/Styles/ToolPickerButton.Styles.axaml"/>
-    <StyleInclude Source="avares://PixiEditor/Styles/Templates/NodePropertyViewTemplate.axaml"/>
 </Styles>
 </Styles>

+ 14 - 4
src/PixiEditor/Styles/Templates/NodePropertyViewTemplate.axaml

@@ -1,12 +1,15 @@
 <Styles xmlns="https://github.com/avaloniaui"
 <Styles xmlns="https://github.com/avaloniaui"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-        xmlns:properties="clr-namespace:PixiEditor.Views.Nodes.Properties">
+        xmlns:properties="clr-namespace:PixiEditor.Views.Nodes.Properties"
+        xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+        xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters">
 
 
     <Style Selector="properties|NodePropertyView">
     <Style Selector="properties|NodePropertyView">
         <Setter Property="ClipToBounds" Value="False" />
         <Setter Property="ClipToBounds" Value="False" />
         <Setter Property="Template">
         <Setter Property="Template">
             <ControlTemplate>
             <ControlTemplate>
-                <Grid Margin="-5, 2" ColumnDefinitions="15, *, 15" MinHeight="18" IsVisible="{Binding DataContext.IsVisible, RelativeSource={RelativeSource TemplatedParent}}">
+                <Grid Margin="-5, 2" ColumnDefinitions="15, *, 15" MinHeight="18"
+                      IsVisible="{Binding DataContext.IsVisible, RelativeSource={RelativeSource TemplatedParent}}">
                     <properties:NodeSocket Name="PART_InputSocket"
                     <properties:NodeSocket Name="PART_InputSocket"
                                            ClipToBounds="False"
                                            ClipToBounds="False"
                                            Node="{Binding DataContext.Node, RelativeSource={RelativeSource TemplatedParent}}"
                                            Node="{Binding DataContext.Node, RelativeSource={RelativeSource TemplatedParent}}"
@@ -17,9 +20,10 @@
                             <x:Boolean>True</x:Boolean>
                             <x:Boolean>True</x:Boolean>
                         </properties:NodeSocket.IsInput>
                         </properties:NodeSocket.IsInput>
                     </properties:NodeSocket>
                     </properties:NodeSocket>
-                    <ContentPresenter Grid.Column="1" VerticalAlignment="Top" Content="{TemplateBinding Content}" />
+                    <ContentPresenter Grid.Column="1" Name="PART_Presenter"
+                                      VerticalAlignment="Top" Content="{TemplateBinding Content}" />
                     <properties:NodeSocket Name="PART_OutputSocket"
                     <properties:NodeSocket Name="PART_OutputSocket"
-                                           ClipToBounds="False" HorizontalAlignment="Right"  Grid.Column="2"
+                                           ClipToBounds="False" HorizontalAlignment="Right" Grid.Column="2"
                                            Node="{Binding DataContext.Node, RelativeSource={RelativeSource TemplatedParent}}"
                                            Node="{Binding DataContext.Node, RelativeSource={RelativeSource TemplatedParent}}"
                                            SocketBrush="{Binding DataContext.SocketBrush, RelativeSource={RelativeSource TemplatedParent}}"
                                            SocketBrush="{Binding DataContext.SocketBrush, RelativeSource={RelativeSource TemplatedParent}}"
                                            IsVisible="{Binding !DataContext.IsInput,RelativeSource={RelativeSource TemplatedParent}}"
                                            IsVisible="{Binding !DataContext.IsInput,RelativeSource={RelativeSource TemplatedParent}}"
@@ -32,4 +36,10 @@
             </ControlTemplate>
             </ControlTemplate>
         </Setter>
         </Setter>
     </Style>
     </Style>
+
+    <Style Selector="properties|NodePropertyView:has-errors ContentPresenter#PART_Presenter">
+        <Setter Property="Foreground" Value="{DynamicResource ErrorOnDarkBrush}" />
+        <Setter Property="(ui:Translator.TooltipKey)"
+                Value="{Binding DataContext.Errors, RelativeSource={RelativeSource TemplatedParent}}" />
+    </Style>
 </Styles>
 </Styles>

+ 8 - 5
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -1010,12 +1010,15 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
 
     public bool RenderFrames(List<Image> frames, Func<Surface, Surface> processFrameAction = null)
     public bool RenderFrames(List<Image> frames, Func<Surface, Surface> processFrameAction = null)
     {
     {
-        if (AnimationDataViewModel.KeyFrames.Count == 0)
-            return false;
-
         var keyFrames = AnimationDataViewModel.KeyFrames;
         var keyFrames = AnimationDataViewModel.KeyFrames;
-        var firstFrame = keyFrames.Min(x => x.StartFrameBindable);
-        var lastFrame = keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable);
+        int firstFrame = 0;
+        int lastFrame = AnimationDataViewModel.FramesCount;
+
+        if (keyFrames.Count > 0)
+        {
+            firstFrame = keyFrames.Min(x => x.StartFrameBindable);
+            lastFrame = keyFrames.Max(x => x.StartFrameBindable + x.DurationBindable);
+        }
 
 
         for (int i = firstFrame; i < lastFrame; i++)
         for (int i = firstFrame; i < lastFrame; i++)
         {
         {

+ 1 - 1
src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs

@@ -301,7 +301,7 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
 
 
         if (input == null && output != null)
         if (input == null && output != null)
         {
         {
-            input = output.ConnectedInputs.FirstOrDefault();
+            input = output.ConnectedInputs?.FirstOrDefault();
             output = null;
             output = null;
         }
         }
 
 

+ 7 - 0
src/PixiEditor/ViewModels/Document/Nodes/Effects/OutlineNodeViewModel.cs

@@ -0,0 +1,7 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Effects;
+using PixiEditor.ViewModels.Nodes;
+
+namespace PixiEditor.ViewModels.Document.Nodes.Effects;
+
+[NodeViewModel("OUTLINE_NODE", "EFFECTS", "\ue99a")]
+internal class OutlineNodeViewModel : NodeViewModel<OutlineNode>;

+ 19 - 1
src/PixiEditor/ViewModels/Document/Nodes/FilterNodes/GrayscaleNodeViewModel.cs

@@ -1,7 +1,25 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
+using PixiEditor.Models.Events;
+using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Nodes;
 using PixiEditor.ViewModels.Nodes;
 
 
 namespace PixiEditor.ViewModels.Document.Nodes.FilterNodes;
 namespace PixiEditor.ViewModels.Document.Nodes.FilterNodes;
 
 
 [NodeViewModel("GRAYSCALE_FILTER_NODE", "FILTERS", "\ue812")]
 [NodeViewModel("GRAYSCALE_FILTER_NODE", "FILTERS", "\ue812")]
-internal class GrayscaleNodeViewModel : NodeViewModel<GrayscaleNode>;
+internal class GrayscaleNodeViewModel : NodeViewModel<GrayscaleNode>
+{
+    private INodePropertyHandler customWeightsProp;
+    public override void OnInitialized()
+    {
+        var modeProp = Inputs.FirstOrDefault(x => x.PropertyName == "Mode");
+        customWeightsProp = Inputs.FirstOrDefault(x => x.PropertyName == "CustomWeight");
+        modeProp.ValueChanged += ModePropOnValueChanged;
+
+        customWeightsProp.IsVisible = modeProp.Value is GrayscaleNode.GrayscaleMode.Custom;
+    }
+
+    private void ModePropOnValueChanged(INodePropertyHandler property, NodePropertyValueChangedArgs args)
+    {
+        customWeightsProp.IsVisible = args.NewValue is GrayscaleNode.GrayscaleMode.Custom;
+    }
+}

+ 29 - 0
src/PixiEditor/ViewModels/Document/Nodes/ShaderNodeViewModel.cs

@@ -0,0 +1,29 @@
+using System.Collections.Specialized;
+using Drawie.Backend.Core.Bridge;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ViewModels.Nodes.Properties;
+
+namespace PixiEditor.ViewModels.Document.Nodes;
+
+[NodeViewModel("SHADER_NODE", "EFFECTS", "\ue99b")]
+internal class ShaderNodeViewModel : NodeViewModel<ShaderNode>
+{
+    public ShaderNodeViewModel()
+    {
+        Inputs.CollectionChanged += InputsOnCollectionChanged;
+    }
+
+    private void InputsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+    {
+        if(e.NewItems == null) return;
+
+        foreach (var newItem in e.NewItems)
+        {
+            if (newItem is StringPropertyViewModel stringPropertyViewModel)
+            {
+                stringPropertyViewModel.Kind = DrawingBackendApi.Current.ShaderImplementation.ShaderLanguageExtension;
+            }
+        }
+    }
+}

+ 9 - 1
src/PixiEditor/ViewModels/Nodes/NodePropertyViewModel.cs

@@ -21,10 +21,13 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
     private bool isInput;
     private bool isInput;
     private bool isFunc;
     private bool isFunc;
     private IBrush socketBrush;
     private IBrush socketBrush;
+    private string errors = string.Empty;
 
 
     private ObservableCollection<INodePropertyHandler> connectedInputs = new();
     private ObservableCollection<INodePropertyHandler> connectedInputs = new();
     private INodePropertyHandler? connectedOutput;
     private INodePropertyHandler? connectedOutput;
 
 
+    public event NodePropertyValueChanged? ValueChanged;
+
     public string DisplayName
     public string DisplayName
     {
     {
         get => displayName;
         get => displayName;
@@ -112,6 +115,12 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
 
 
     public Type PropertyType { get; }
     public Type PropertyType { get; }
 
 
+    public string? Errors
+    {
+        get => errors;
+        set => SetProperty(ref errors, value);
+    }
+
     public NodePropertyViewModel(INodeHandler node, Type propertyType)
     public NodePropertyViewModel(INodeHandler node, Type propertyType)
     {
     {
         Node = node;
         Node = node;
@@ -175,7 +184,6 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
         return (NodePropertyViewModel)Activator.CreateInstance(viewModelType, node, type);
         return (NodePropertyViewModel)Activator.CreateInstance(viewModelType, node, type);
     }
     }
 
 
-    public event NodePropertyValueChanged? ValueChanged;
 
 
     public void InternalSetValue(object? value)
     public void InternalSetValue(object? value)
     {
     {

+ 96 - 2
src/PixiEditor/ViewModels/Nodes/Properties/StringPropertyViewModel.cs

@@ -1,20 +1,39 @@
 using System.ComponentModel;
 using System.ComponentModel;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.Input;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.Handlers;
+using PixiEditor.Models.IO;
+using PixiEditor.OperatingSystem;
 
 
 namespace PixiEditor.ViewModels.Nodes.Properties;
 namespace PixiEditor.ViewModels.Nodes.Properties;
 
 
 internal class StringPropertyViewModel : NodePropertyViewModel<string>
 internal class StringPropertyViewModel : NodePropertyViewModel<string>
 {
 {
+    private string fileWatcherPath = string.Empty;
+    private FileSystemWatcher fileWatcher;
+
+    public RelayCommand OpenInDefaultAppCommand { get; }
+    public RelayCommand OpenInFolderCommand { get; }
+
     public string StringValue
     public string StringValue
     {
     {
         get => Value;
         get => Value;
         set => Value = value;
         set => Value = value;
     }
     }
-    
+
+    public string Kind = "txt";
+
     public StringPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
     public StringPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
     {
     {
         PropertyChanged += StringPropertyViewModel_PropertyChanged;
         PropertyChanged += StringPropertyViewModel_PropertyChanged;
+        OpenInDefaultAppCommand = new RelayCommand(OpenInDefaultApp);
+        OpenInFolderCommand = new RelayCommand(OpenInFolder);
     }
     }
-    
+
     private void StringPropertyViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
     private void StringPropertyViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
     {
     {
         if (e.PropertyName == nameof(Value))
         if (e.PropertyName == nameof(Value))
@@ -22,4 +41,79 @@ internal class StringPropertyViewModel : NodePropertyViewModel<string>
             OnPropertyChanged(nameof(StringValue));
             OnPropertyChanged(nameof(StringValue));
         }
         }
     }
     }
+
+    private void OpenInDefaultApp()
+    {
+        try
+        {
+            if (!string.IsNullOrEmpty(fileWatcherPath) && File.Exists(fileWatcherPath))
+            {
+                OpenInDefaultApp(fileWatcherPath);
+                return;
+            }
+
+            fileWatcherPath = CreateTempFile();
+            CreateFileWatcher(fileWatcherPath);
+            OpenInDefaultApp(fileWatcherPath);
+        }
+        catch (Exception ex)
+        {
+            NoticeDialog.Show(new LocalizedString("FAILED_TO_OPEN_EDITABLE_STRING_MESSAGE", ex.Message),
+                "FAILED_TO_OPEN_EDITABLE_STRING_TITLE");
+            CrashHelper.SendExceptionInfo(ex);
+        }
+    }
+
+    private void OpenInFolder()
+    {
+        if (!string.IsNullOrEmpty(fileWatcherPath) && File.Exists(fileWatcherPath))
+        {
+            IOperatingSystem.Current.OpenFolder(fileWatcherPath);
+            return;
+        }
+
+        fileWatcherPath = CreateTempFile();
+        CreateFileWatcher(fileWatcherPath);
+        IOperatingSystem.Current.OpenFolder(fileWatcherPath);
+    }
+
+    private string CreateTempFile()
+    {
+        string extension = $".{Kind}";
+
+        string dirPath = Path.Combine(Paths.TempFilesPath, "NodeProps");
+        if (!Directory.Exists(dirPath))
+        {
+            Directory.CreateDirectory(dirPath);
+        }
+
+        string filePath = Path.Combine(dirPath, Guid.NewGuid().ToString("N") + extension);
+        File.WriteAllText(filePath, StringValue);
+
+        return filePath;
+    }
+
+    private void CreateFileWatcher(string filePath)
+    {
+        fileWatcher?.Dispose();
+        fileWatcher = new FileSystemWatcher();
+        fileWatcher.Path = Path.GetDirectoryName(filePath);
+        fileWatcher.Filter = Path.GetFileName(filePath);
+        fileWatcher.NotifyFilter = NotifyFilters.LastWrite;
+
+        fileWatcher.Changed += (sender, args) =>
+        {
+            using FileStream stream = new(args.FullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+            using StreamReader reader = new(stream);
+            string text = reader.ReadToEnd();
+            Dispatcher.UIThread.Post(() => StringValue = text);
+        };
+
+        fileWatcher.EnableRaisingEvents = true;
+    }
+
+    private void OpenInDefaultApp(string path)
+    {
+        IOperatingSystem.Current.OpenUri(path);
+    }
 }
 }

+ 2 - 2
src/PixiEditor/ViewModels/Nodes/Properties/VecD3PropertyViewModel.cs → src/PixiEditor/ViewModels/Nodes/Properties/Vec3DPropertyViewModel.cs

@@ -3,9 +3,9 @@ using Drawie.Numerics;
 
 
 namespace PixiEditor.ViewModels.Nodes.Properties;
 namespace PixiEditor.ViewModels.Nodes.Properties;
 
 
-internal class VecD3PropertyViewModel : NodePropertyViewModel<Vec3D>
+internal class Vec3DPropertyViewModel : NodePropertyViewModel<Vec3D>
 {
 {
-    public VecD3PropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
+    public Vec3DPropertyViewModel(NodeViewModel node, Type valueType) : base(node, valueType)
     {
     {
         PropertyChanged += OnPropertyChanged;
         PropertyChanged += OnPropertyChanged;
     }
     }

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

@@ -246,9 +246,9 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
         if (member is null)
         if (member is null)
             return;
             return;
         var path = doc!.StructureHelper.FindPath(member.Id);
         var path = doc!.StructureHelper.FindPath(member.Id);
-        if (path.Count < 2)
+        if (path.Count < 2 || path[1] is not FolderNodeViewModel folderVm)
             return;
             return;
-        var parent = (FolderNodeViewModel)path[1];
+        var parent = folderVm;
         int curIndex = parent.Children.IndexOf(path[0]);
         int curIndex = parent.Children.IndexOf(path[0]);
         if (upwards)
         if (upwards)
         {
         {

+ 1 - 1
src/PixiEditor/ViewModels/Tools/Tools/VectorPathToolViewModel.cs

@@ -81,7 +81,7 @@ internal class VectorPathToolViewModel : ShapeTool, IVectorPathToolHandler
         var doc =
         var doc =
             ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument;
             ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument;
 
 
-        if (doc is null || isActivated) return;
+        if (doc is null) return;
 
 
         if (!doc.PathOverlayViewModel.IsActive)
         if (!doc.PathOverlayViewModel.IsActive)
         {
         {

+ 8 - 2
src/PixiEditor/Views/Nodes/Properties/DoublePropertyView.axaml

@@ -7,11 +7,17 @@
                              xmlns:input="clr-namespace:PixiEditor.Views.Input"
                              xmlns:input="clr-namespace:PixiEditor.Views.Input"
                              xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
                              xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
                              xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
                              xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
+                             xmlns:properties1="clr-namespace:PixiEditor.ViewModels.Nodes.Properties"
                              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
                              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+                             x:DataType="properties1:DoublePropertyViewModel"
                              x:Class="PixiEditor.Views.Nodes.Properties.DoublePropertyView">
                              x:Class="PixiEditor.Views.Nodes.Properties.DoublePropertyView">
+    <Design.DataContext>
+        <properties1:DoublePropertyViewModel/>
+    </Design.DataContext>
     <Grid HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
     <Grid HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
         <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}"/>
         <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}" />
+        <input:NumberInput EnableScrollChange="False" Name="input"
+                           HorizontalAlignment="Right" MinWidth="100" Decimals="6" IsVisible="{Binding ShowInputField}"
+                           Value="{Binding Value, Mode=TwoWay}" />
     </Grid>
     </Grid>
 </properties:NodePropertyView>
 </properties:NodePropertyView>

+ 39 - 0
src/PixiEditor/Views/Nodes/Properties/NodePropertyView.cs

@@ -1,17 +1,33 @@
 using Avalonia;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls;
+using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Primitives;
 using PixiEditor.Models.Handlers;
 using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Nodes;
 using PixiEditor.ViewModels.Nodes;
 
 
 namespace PixiEditor.Views.Nodes.Properties;
 namespace PixiEditor.Views.Nodes.Properties;
 
 
+[PseudoClasses(":has-errors")]
 public abstract class NodePropertyView : UserControl
 public abstract class NodePropertyView : UserControl
 {
 {
+    public static readonly StyledProperty<string> ErrorsProperty = AvaloniaProperty.Register<NodePropertyView, string>(
+        nameof(Errors));
+
+    public string Errors
+    {
+        get => GetValue(ErrorsProperty);
+        set => SetValue(ErrorsProperty, value);
+    }
+
     public NodeSocket InputSocket { get; private set; }
     public NodeSocket InputSocket { get; private set; }
     public NodeSocket OutputSocket { get; private set; }
     public NodeSocket OutputSocket { get; private set; }
     protected override Type StyleKeyOverride => typeof(NodePropertyView);
     protected override Type StyleKeyOverride => typeof(NodePropertyView);
 
 
+    static NodePropertyView()
+    {
+        ErrorsProperty.Changed.Subscribe(OnErrorsChanged);
+    }
+
     protected void SetValue(object value)
     protected void SetValue(object value)
     {
     {
         if (DataContext is NodePropertyViewModel viewModel)
         if (DataContext is NodePropertyViewModel viewModel)
@@ -20,6 +36,21 @@ public abstract class NodePropertyView : UserControl
         }
         }
     }
     }
 
 
+    protected override void OnDataContextChanged(EventArgs e)
+    {
+        base.OnDataContextChanged(e);
+        if (DataContext is NodePropertyViewModel propertyHandler)
+        {
+            propertyHandler.PropertyChanged += (sender, args) =>
+            {
+                if (args.PropertyName == nameof(NodePropertyViewModel.Errors))
+                {
+                    Errors = propertyHandler.Errors;
+                }
+            };
+        }
+    }
+
     protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
     protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
     {
     {
         base.OnApplyTemplate(e);
         base.OnApplyTemplate(e);
@@ -70,6 +101,14 @@ public abstract class NodePropertyView : UserControl
             OutputSocket.IsVisible = false;
             OutputSocket.IsVisible = false;
         }
         }
     }
     }
+
+    private static void OnErrorsChanged(AvaloniaPropertyChangedEventArgs<string> args)
+    {
+        if (args.Sender is NodePropertyView view)
+        {
+            view.PseudoClasses.Set(":has-errors", args.NewValue.HasValue && !string.IsNullOrEmpty(args.NewValue.Value));
+        }
+    }
 }
 }
 
 
 public abstract class NodePropertyView<T> : NodePropertyView
 public abstract class NodePropertyView<T> : NodePropertyView

+ 79 - 9
src/PixiEditor/Views/Nodes/Properties/StringPropertyView.axaml

@@ -11,13 +11,83 @@
                              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
                              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
                              x:DataType="properties1:StringPropertyViewModel"
                              x:DataType="properties1:StringPropertyViewModel"
                              x:Class="PixiEditor.Views.Nodes.Properties.StringPropertyView">
                              x:Class="PixiEditor.Views.Nodes.Properties.StringPropertyView">
-    <DockPanel LastChildFill="True"
-        HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
-        <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}" />
-        <TextBox AcceptsReturn="True" Text="{CompiledBinding StringValue, Mode=TwoWay}" IsVisible="{Binding ShowInputField}">
-            <Interaction.Behaviors>
-                <behaviours:GlobalShortcutFocusBehavior />
-            </Interaction.Behaviors>
-        </TextBox>
-    </DockPanel>
+    <Grid>
+        <DockPanel LastChildFill="True"
+                   HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
+            <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}" />
+            <TextBox MaxWidth="110"
+                     MaxLines="1"
+                     Name="smallTextBox"
+                     Text="{CompiledBinding StringValue, Mode=TwoWay}"
+                     IsVisible="{Binding ShowInputField}">
+                <TextBox.InnerRightContent>
+                    <ToggleButton Name="bigModeToggle" DockPanel.Dock="Right" FontSize="20" Classes="pixi-icon"
+                                  Content="{DynamicResource icon-fullscreen}" />
+                </TextBox.InnerRightContent>
+                <Interaction.Behaviors>
+                    <behaviours:GlobalShortcutFocusBehavior />
+                </Interaction.Behaviors>
+            </TextBox>
+        </DockPanel>
+        <Popup IsOpen="{Binding ElementName=bigModeToggle, Path=IsChecked, Mode=TwoWay}"
+               Placement="AnchorAndGravity"
+               PlacementAnchor="Top"
+               VerticalOffset="20"
+               PlacementGravity="Bottom"
+               IsLightDismissEnabled="True"
+               Opened="Popup_OnOpened"
+               PlacementTarget="{Binding ElementName=bigModeToggle}">
+            <DockPanel LastChildFill="True">
+                <Border CornerRadius="5 5 0 0" DockPanel.Dock="Top"
+                        BorderThickness="1 1 1 0"
+                        BorderBrush="{DynamicResource ThemeBorderHighBrush}"
+                        Background="{DynamicResource ThemeBackgroundBrush1}">
+                    <StackPanel Orientation="Horizontal">
+                        <Button Margin="5" Classes="pixi-icon"
+                                Name="openInDefaultAppButton"
+                                Command="{Binding OpenInDefaultAppCommand}"
+                                ui:Translator.TooltipKey="STRING_EDIT_IN_DEFAULT_APP"
+                                Content="{DynamicResource icon-link}" FontSize="20" />
+                        <Button Margin="5" Classes="pixi-icon"
+                                Name="openInFolderButton"
+                                ui:Translator.TooltipKey="STRING_OPEN_IN_FOLDER"
+                                Command="{Binding OpenInFolderCommand}"
+                                Content="{DynamicResource icon-folder}" FontSize="20" />
+                    </StackPanel>
+                </Border>
+                <Grid>
+                    <Grid.RowDefinitions>
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="70" />
+                    </Grid.RowDefinitions>
+                    <TextBox
+                        CornerRadius="0 0 5 5"
+                        Name="bigTextBox"
+                        Width="500"
+                        Height="600"
+                        AcceptsReturn="True"
+                        AcceptsTab="True"
+                        PointerWheelChanged="InputElement_OnPointerWheelChanged"
+                        Text="{Binding StringValue, Mode=TwoWay}"
+                        IsVisible="{Binding ElementName=bigModeToggle, Path=IsChecked}">
+                        <Interaction.Behaviors>
+                            <behaviours:GlobalShortcutFocusBehavior />
+                        </Interaction.Behaviors>
+                    </TextBox>
+                    <Border
+                        CornerRadius="0 0 5 5"
+                        BorderThickness="1" BorderBrush="{DynamicResource ThemeBorderMidBrush}"
+                        IsVisible="{Binding Errors, Converter={converters:NotEmptyStringConverter}}"
+                        Background="{DynamicResource ThemeBackgroundBrush}" Grid.Row="1">
+                        <ScrollViewer PointerWheelChanged="InputElement_OnPointerWheelChanged" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
+                        <TextBlock
+                            Foreground="{DynamicResource ErrorOnDarkBrush}"
+                            TextWrapping="Wrap"
+                            Text="{Binding Errors}" />
+                        </ScrollViewer>
+                    </Border>
+                </Grid>
+            </DockPanel>
+        </Popup>
+    </Grid>
 </properties:NodePropertyView>
 </properties:NodePropertyView>

+ 39 - 2
src/PixiEditor/Views/Nodes/Properties/StringPropertyView.axaml.cs

@@ -1,14 +1,51 @@
-using Avalonia;
+using System.Windows.Input;
+using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
 using Avalonia.Markup.Xaml;
 using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.Handlers;
+using PixiEditor.Models.IO;
+using PixiEditor.OperatingSystem;
+using PixiEditor.ViewModels.Nodes.Properties;
 
 
 namespace PixiEditor.Views.Nodes.Properties;
 namespace PixiEditor.Views.Nodes.Properties;
 
 
 public partial class StringPropertyView : NodePropertyView
 public partial class StringPropertyView : NodePropertyView
 {
 {
+    public static readonly StyledProperty<ICommand> OpenInDefaultAppCommandProperty = AvaloniaProperty.Register<StringPropertyView, ICommand>(
+        nameof(OpenInDefaultAppCommand));
+
+    public ICommand OpenInDefaultAppCommand
+    {
+        get => GetValue(OpenInDefaultAppCommandProperty);
+        set => SetValue(OpenInDefaultAppCommandProperty, value);
+    }
     public StringPropertyView()
     public StringPropertyView()
     {
     {
         InitializeComponent();
         InitializeComponent();
     }
     }
-}
 
 
+    protected override void OnLoaded(RoutedEventArgs e)
+    {
+        base.OnLoaded(e);
+        ScrollViewer scroll = smallTextBox.FindDescendantOfType<ScrollViewer>();
+        scroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
+        scroll.VerticalScrollBarVisibility = ScrollBarVisibility.Disabled;
+    }
+
+    private void InputElement_OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
+    {
+        e.Handled = true;
+    }
+
+    private void Popup_OnOpened(object? sender, EventArgs e)
+    {
+        Dispatcher.UIThread.Post(() => bigTextBox.Focus());
+    }
+}

+ 1 - 1
src/PixiEditor/Views/Nodes/Properties/VecD3PropertyView.axaml → src/PixiEditor/Views/Nodes/Properties/Vec3DPropertyView.axaml

@@ -7,7 +7,7 @@
                              xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
                              xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
                              xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
                              xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
                              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
                              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
-                             x:Class="PixiEditor.Views.Nodes.Properties.VecD3PropertyView">
+                             x:Class="PixiEditor.Views.Nodes.Properties.Vec3DPropertyView">
     <StackPanel HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
     <StackPanel HorizontalAlignment="{Binding IsInput, Converter={converters:BoolToValueConverter FalseValue='Right', TrueValue='Stretch'}}">
         <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}"/>
         <TextBlock VerticalAlignment="Center" ui:Translator.Key="{Binding DisplayName}"/>
         <StackPanel IsVisible="{Binding ShowInputField}">
         <StackPanel IsVisible="{Binding ShowInputField}">

+ 2 - 2
src/PixiEditor/Views/Nodes/Properties/VecD3PropertyView.axaml.cs → src/PixiEditor/Views/Nodes/Properties/Vec3DPropertyView.axaml.cs

@@ -4,9 +4,9 @@ using Avalonia.Markup.Xaml;
 
 
 namespace PixiEditor.Views.Nodes.Properties;
 namespace PixiEditor.Views.Nodes.Properties;
 
 
-public partial class VecD3PropertyView : NodePropertyView
+public partial class Vec3DPropertyView : NodePropertyView
 {
 {
-    public VecD3PropertyView()
+    public Vec3DPropertyView()
     {
     {
         InitializeComponent();
         InitializeComponent();
     }
     }

+ 12 - 2
src/PixiEditor/Views/Rendering/Scene.cs

@@ -493,7 +493,6 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
                 ? released.InitialPressMouseButton
                 ? released.InitialPressMouseButton
                 : MouseButton.None,
                 : MouseButton.None,
             ClickCount = e is PointerPressedEventArgs pressed ? pressed.ClickCount : 0,
             ClickCount = e is PointerPressedEventArgs pressed ? pressed.ClickCount : 0,
-            
         };
         };
     }
     }
 
 
@@ -659,7 +658,7 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
     {
     {
         renderTexture?.Dispose();
         renderTexture?.Dispose();
         renderTexture = null;
         renderTexture = null;
-        
+
         framebuffer?.Dispose();
         framebuffer?.Dispose();
         framebuffer = null;
         framebuffer = null;
 
 
@@ -767,12 +766,23 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
 
 
     private static void DocumentChanged(Scene scene, AvaloniaPropertyChangedEventArgs e)
     private static void DocumentChanged(Scene scene, AvaloniaPropertyChangedEventArgs e)
     {
     {
+        if (e.OldValue is DocumentViewModel oldDocumentViewModel)
+        {
+            oldDocumentViewModel.SizeChanged -= scene.DocumentViewModelOnSizeChanged;
+        }
+
         if (e.NewValue is DocumentViewModel documentViewModel)
         if (e.NewValue is DocumentViewModel documentViewModel)
         {
         {
+            documentViewModel.SizeChanged += scene.DocumentViewModelOnSizeChanged;
             scene.ContentDimensions = documentViewModel.SizeBindable;
             scene.ContentDimensions = documentViewModel.SizeBindable;
         }
         }
     }
     }
 
 
+    private void DocumentViewModelOnSizeChanged(object? sender, DocumentSizeChangedEventArgs e)
+    {
+        ContentDimensions = e.NewSize;
+    }
+
     private static void DefaultCursorChanged(Scene scene, AvaloniaPropertyChangedEventArgs e)
     private static void DefaultCursorChanged(Scene scene, AvaloniaPropertyChangedEventArgs e)
     {
     {
         if (e.NewValue is Cursor cursor)
         if (e.NewValue is Cursor cursor)

+ 3 - 3
src/PixiEditor/Views/Windows/BetaExampleButton.axaml

@@ -15,8 +15,8 @@
                 Command="{Binding OpenCommand, RelativeSource={RelativeSource AncestorType=windows1:BetaExampleButton}}"
                 Command="{Binding OpenCommand, RelativeSource={RelativeSource AncestorType=windows1:BetaExampleButton}}"
                 x:Name="fileButton">
                 x:Name="fileButton">
             <Grid Width="100" Height="100">
             <Grid Width="100" Height="100">
-                <visuals1:SurfaceControl
-                    Surface="{Binding BetaExampleFile.PreviewImage, RelativeSource={RelativeSource AncestorType=windows1:BetaExampleButton}}"
+                <visuals1:TextureControl
+                    Texture="{Binding BetaExampleFile.PreviewImage, RelativeSource={RelativeSource AncestorType=windows1:BetaExampleButton}}"
                     Margin="10"
                     Margin="10"
                     Stretch="Uniform"
                     Stretch="Uniform"
                     x:Name="image">
                     x:Name="image">
@@ -27,7 +27,7 @@
                             <Binding ElementName="image" Path="Width" />
                             <Binding ElementName="image" Path="Width" />
                         </MultiBinding>
                         </MultiBinding>
                     </ui:RenderOptionsBindable.BitmapInterpolationMode>
                     </ui:RenderOptionsBindable.BitmapInterpolationMode>
-                </visuals1:SurfaceControl>
+                </visuals1:TextureControl>
             </Grid>
             </Grid>
         </Button>
         </Button>
 
 

+ 33 - 7
src/PixiEditor/Views/Windows/BetaExampleButton.axaml.cs

@@ -1,6 +1,7 @@
 using Avalonia;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls;
 using CommunityToolkit.Mvvm.Input;
 using CommunityToolkit.Mvvm.Input;
+using Drawie.Backend.Core;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.Models.AnalyticsAPI;
 using PixiEditor.ViewModels;
 using PixiEditor.ViewModels;
@@ -38,7 +39,7 @@ public partial class BetaExampleButton : UserControl
         get => GetValue(DisplayNameProperty);
         get => GetValue(DisplayNameProperty);
         set => SetValue(DisplayNameProperty, value);
         set => SetValue(DisplayNameProperty, value);
     }
     }
-    
+
     public BetaExampleFile BetaExampleFile
     public BetaExampleFile BetaExampleFile
     {
     {
         get => GetValue(BetaExampleFileProperty);
         get => GetValue(BetaExampleFileProperty);
@@ -47,33 +48,58 @@ public partial class BetaExampleButton : UserControl
 
 
     public AsyncRelayCommand OpenCommand { get; }
     public AsyncRelayCommand OpenCommand { get; }
 
 
+    private static Dictionary<string, BetaExampleFile> exampleFilesCache = new();
+
     public BetaExampleButton()
     public BetaExampleButton()
     {
     {
         OpenCommand = new AsyncRelayCommand(OpenExample);
         OpenCommand = new AsyncRelayCommand(OpenExample);
-        
+
         InitializeComponent();
         InitializeComponent();
-        FileNameProperty.Changed.AddClassHandler((BetaExampleButton o, AvaloniaPropertyChangedEventArgs<string> args) => FileNameChanged(o, args));
+        FileNameProperty.Changed.AddClassHandler((BetaExampleButton o, AvaloniaPropertyChangedEventArgs<string> args) =>
+            FileNameChanged(o, args));
     }
     }
 
 
     private static void FileNameChanged(BetaExampleButton sender, AvaloniaPropertyChangedEventArgs<string> e)
     private static void FileNameChanged(BetaExampleButton sender, AvaloniaPropertyChangedEventArgs<string> e)
     {
     {
+        if (e.OldValue.Value != null)
+        {
+            if (exampleFilesCache.ContainsKey(e.OldValue.Value))
+            {
+                var oldFile = exampleFilesCache[e.OldValue.Value];
+                oldFile.Dispose();
+
+                exampleFilesCache.Remove(e.OldValue.Value);
+            }
+        }
+
+        if (e.NewValue.HasValue == false || string.IsNullOrWhiteSpace(e.NewValue.Value))
+        {
+            return;
+        }
+
+        if (exampleFilesCache.ContainsKey(e.NewValue.Value))
+        {
+            sender.BetaExampleFile = exampleFilesCache[e.NewValue.Value];
+            return;
+        }
+
         sender.BetaExampleFile = new BetaExampleFile(e.NewValue.Value, sender.DisplayName);
         sender.BetaExampleFile = new BetaExampleFile(e.NewValue.Value, sender.DisplayName);
+        exampleFilesCache.Add(e.NewValue.Value, sender.BetaExampleFile);
     }
     }
-    
+
     private async Task OpenExample()
     private async Task OpenExample()
     {
     {
         await using var stream = BetaExampleFile.GetStream();
         await using var stream = BetaExampleFile.GetStream();
-        
+
         var bytes = new byte[stream.Length];
         var bytes = new byte[stream.Length];
         await stream.ReadExactlyAsync(bytes);
         await stream.ReadExactlyAsync(bytes);
 
 
         Application.Current.ForDesktopMainWindow(mainWindow => mainWindow.Activate());
         Application.Current.ForDesktopMainWindow(mainWindow => mainWindow.Activate());
         CloseCommand.Execute(null);
         CloseCommand.Execute(null);
-        
+
         ViewModelMain.Current.FileSubViewModel.OpenRecoveredDotPixi(null, bytes);
         ViewModelMain.Current.FileSubViewModel.OpenRecoveredDotPixi(null, bytes);
         ViewModelMain.Current.DocumentManagerSubViewModel.Documents[^1].Operations.UseSrgbProcessing();
         ViewModelMain.Current.DocumentManagerSubViewModel.Documents[^1].Operations.UseSrgbProcessing();
         ViewModelMain.Current.DocumentManagerSubViewModel.Documents[^1].Operations.ClearUndo();
         ViewModelMain.Current.DocumentManagerSubViewModel.Documents[^1].Operations.ClearUndo();
         Analytics.SendOpenExample(FileName);
         Analytics.SendOpenExample(FileName);
     }
     }
-
 }
 }

+ 8 - 3
src/PixiEditor/Views/Windows/BetaExampleFile.cs

@@ -6,11 +6,11 @@ using PixiEditor.Parser;
 
 
 namespace PixiEditor.Views.Windows;
 namespace PixiEditor.Views.Windows;
 
 
-public class BetaExampleFile
+public class BetaExampleFile : IDisposable
 {
 {
     private readonly string resourcePath;
     private readonly string resourcePath;
     
     
-    public Surface PreviewImage { get; }
+    public Texture PreviewImage { get; }
     
     
     public LocalizedString DisplayName { get; }
     public LocalizedString DisplayName { get; }
     
     
@@ -22,8 +22,13 @@ public class BetaExampleFile
         var stream = GetStream();
         var stream = GetStream();
         var bytes = PixiParser.ReadPreview(stream);
         var bytes = PixiParser.ReadPreview(stream);
 
 
-        PreviewImage = Surface.Load(bytes);
+        PreviewImage = Texture.Load(bytes);
     }
     }
     
     
     public Stream GetStream() => AssetLoader.Open(new Uri(resourcePath));
     public Stream GetStream() => AssetLoader.Open(new Uri(resourcePath));
+
+    public void Dispose()
+    {
+        PreviewImage.Dispose();
+    }
 }
 }

+ 16 - 5
src/PixiEditor/Views/Windows/HelloTherePopup.axaml

@@ -16,8 +16,7 @@
                          xmlns:visuals="clr-namespace:PixiEditor.Views.Visuals"
                          xmlns:visuals="clr-namespace:PixiEditor.Views.Visuals"
                          xmlns:windows="clr-namespace:PixiEditor.Views.Windows"
                          xmlns:windows="clr-namespace:PixiEditor.Views.Windows"
                          mc:Ignorable="d"
                          mc:Ignorable="d"
-                         Title="Hello there!" Height="680" Width="982" MinHeight="500" MinWidth="600"
-                         >
+                         Title="Hello there!" Height="680" Width="982" MinHeight="500" MinWidth="600">
 
 
     <Window.Styles>
     <Window.Styles>
         <Style Selector="TextBlock">
         <Style Selector="TextBlock">
@@ -78,7 +77,7 @@
                                 ui:Translator.Key="NEW_FILE" />
                                 ui:Translator.Key="NEW_FILE" />
                         <Button Classes="pixi-icon" Content="{DynamicResource icon-paste-as-new-layer}"
                         <Button Classes="pixi-icon" Content="{DynamicResource icon-paste-as-new-layer}"
                                 Command="{Binding NewFromClipboardCommand}"
                                 Command="{Binding NewFromClipboardCommand}"
-                                ui:Translator.TooltipKey="NEW_FROM_CLIPBOARD"/>
+                                ui:Translator.TooltipKey="NEW_FROM_CLIPBOARD" />
                     </StackPanel>
                     </StackPanel>
 
 
                     <StackPanel Grid.Row="2" HorizontalAlignment="Center" Margin="0,30,0,0">
                     <StackPanel Grid.Row="2" HorizontalAlignment="Center" Margin="0,30,0,0">
@@ -86,7 +85,7 @@
                                    ui:Translator.Key="BETA_EXAMPLE_FILES" />
                                    ui:Translator.Key="BETA_EXAMPLE_FILES" />
 
 
                         <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
                         <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
-                            <windows:BetaExampleButton FileName="Pond.pixi" DisplayName="POND_EXAMPLE"
+                            <windows:BetaExampleButton FileName="Disco Ball.pixi" DisplayName="DISCO_BALL_EXAMPLE"
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                             <windows:BetaExampleButton FileName="Tree.pixi" DisplayName="TREE_EXAMPLE"
                             <windows:BetaExampleButton FileName="Tree.pixi" DisplayName="TREE_EXAMPLE"
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
@@ -351,12 +350,14 @@
 
 
                     <ScrollViewer HorizontalAlignment="Center" VerticalScrollBarVisibility="Auto">
                     <ScrollViewer HorizontalAlignment="Center" VerticalScrollBarVisibility="Auto">
                         <panels:AlignableWrapPanel>
                         <panels:AlignableWrapPanel>
+                            <windows:BetaExampleButton FileName="Disco Ball.pixi" DisplayName="DISCO_BALL_EXAMPLE"
+                                                       CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                             <windows:BetaExampleButton FileName="Pond.pixi" DisplayName="POND_EXAMPLE"
                             <windows:BetaExampleButton FileName="Pond.pixi" DisplayName="POND_EXAMPLE"
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                             <windows:BetaExampleButton FileName="Tree.pixi" DisplayName="TREE_EXAMPLE"
                             <windows:BetaExampleButton FileName="Tree.pixi" DisplayName="TREE_EXAMPLE"
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                             <windows:BetaExampleButton FileName="Island.pixi" DisplayName="ISLAND_EXAMPLE"
                             <windows:BetaExampleButton FileName="Island.pixi" DisplayName="ISLAND_EXAMPLE"
-                                                         CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
+                                                       CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                             <windows:BetaExampleButton FileName="Stars.pixi" DisplayName="STARS_EXAMPLE"
                             <windows:BetaExampleButton FileName="Stars.pixi" DisplayName="STARS_EXAMPLE"
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                             <windows:BetaExampleButton FileName="Outline.pixi" DisplayName="OUTLINE_EXAMPLE"
                             <windows:BetaExampleButton FileName="Outline.pixi" DisplayName="OUTLINE_EXAMPLE"
@@ -373,6 +374,16 @@
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                                                        CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
                         </StackPanel>
                         </StackPanel>
                     </ScrollViewer>
                     </ScrollViewer>
+
+                    <TextBlock ui:Translator.Key="PHOTO_EXAMPLES" Margin="0,8,0,2" HorizontalAlignment="Center"
+                               TextAlignment="Center" FontSize="18" FontWeight="SemiBold" />
+                    <ScrollViewer HorizontalAlignment="Center" VerticalScrollBarVisibility="Auto">
+                        <StackPanel Orientation="Horizontal">
+                            <windows:BetaExampleButton FileName="Mask.pixi" DisplayName="MASK_EXAMPLE"
+                                                       CloseCommand="{Binding CloseCommand, RelativeSource={RelativeSource AncestorType=windows:HelloTherePopup}}" />
+                        </StackPanel>
+                    </ScrollViewer>
+
                 </StackPanel>
                 </StackPanel>
             </ScrollViewer>
             </ScrollViewer>
 
 

+ 117 - 0
tests/PixiEditor.Backend.Tests/ShaderTests.cs

@@ -0,0 +1,117 @@
+using Drawie.Backend.Core.Shaders;
+using Drawie.Skia.Implementations;
+
+namespace PixiEditor.Backend.Tests;
+
+public class ShaderTests
+{
+    [Fact]
+    public void TestThatFindUniformTypeSolvesTrivialCase()
+    {
+        string sksl = """
+                      uniform float flUniform;
+
+                      half4 main(vec2 coords)
+                      {
+                          return half4(1, 1, 1, 1);
+                      }
+
+                      """;
+        UniformValueType? valueType = SkiaShaderImplementation.FindUniformType(sksl, "flUniform");
+
+        Assert.Equal(UniformValueType.Float, valueType);
+    }
+
+    [Fact]
+    public void TestThatFindUniformTypeSolvesDoesntGetFooledByComment()
+    {
+        string sksl = """
+                      /* uniform vec2 flUniform */uniform float flUniform;
+                      // uniform half4 vecUniform
+                      uniform vec2 vecUniform;
+
+                      half4 main(vec2 coords)
+                      {
+                          return half4(1, 1, 1, 1);
+                      }
+
+                      """;
+
+        UniformValueType? flValueType = SkiaShaderImplementation.FindUniformType(sksl, "flUniform");
+        UniformValueType? vecValueType = SkiaShaderImplementation.FindUniformType(sksl, "vecUniform");
+
+        Assert.Equal(UniformValueType.Float, flValueType);
+        Assert.Equal(UniformValueType.Vector2, vecValueType);
+    }
+
+    [Fact]
+    public void TestThatFindUniformTypeFindsUniformsInTheMiddle()
+    {
+        string sksl = """
+                      float getBlue()
+                      {
+                       return 0.5;
+                      }
+                      
+                      uniform vec2 uni1;
+                      
+                      half4 main(vec2 coords)
+                      {
+                          return half4(uni1.xy, getBlue(), 1);
+                      }
+                      
+                      """;
+
+        UniformValueType? uni1Type = SkiaShaderImplementation.FindUniformType(sksl, "uni1");
+
+        Assert.Equal(UniformValueType.Vector2, uni1Type);
+    }
+
+    [Fact]
+    public void TestThatMinifiedSkslIsParsedCorrectly()
+    {
+        string sksl = "uniform float flUniform; half4 main(vec2 coords) { return half4(1, 1, 1, 1); }";
+        UniformValueType? valueType = SkiaShaderImplementation.FindUniformType(sksl, "flUniform");
+
+        Assert.Equal(UniformValueType.Float, valueType);
+    }
+
+    [Fact]
+    public void TestThatAllTypesAreParsedCorrectly()
+    {
+        string sksl = """
+                      uniform float floatUniform;
+                      uniform vec2 vector2Uniform;
+                      uniform vec3 vec3dUniform;
+                      uniform half4 halfArrayUniform;
+                      uniform vec4 vec4Uniform;
+                      uniform shader shaderUniform;
+                      layout(color) uniform half4 colorUniform;
+                      layout ( color ) uniform vec4 colorUniform2;
+
+                      half4 main(vec2 coords)
+                      {
+                          return half4(1, 1, 1, 1);
+                      }
+
+                      """;
+
+        UniformValueType? floatType = SkiaShaderImplementation.FindUniformType(sksl, "floatUniform");
+        UniformValueType? vector2Type = SkiaShaderImplementation.FindUniformType(sksl, "vector2Uniform");
+        UniformValueType? half3Type = SkiaShaderImplementation.FindUniformType(sksl, "halfArrayUniform");
+        UniformValueType? vec3dType = SkiaShaderImplementation.FindUniformType(sksl, "vec3dUniform");
+        UniformValueType? vec4Type = SkiaShaderImplementation.FindUniformType(sksl, "vec4Uniform");
+        UniformValueType? shaderType = SkiaShaderImplementation.FindUniformType(sksl, "shaderUniform");
+        UniformValueType? colorType = SkiaShaderImplementation.FindUniformType(sksl, "colorUniform");
+        UniformValueType? colorType2 = SkiaShaderImplementation.FindUniformType(sksl, "colorUniform2");
+
+        Assert.Equal(UniformValueType.Float, floatType);
+        Assert.Equal(UniformValueType.Vector2, vector2Type);
+        Assert.Equal(UniformValueType.Vector3, vec3dType);
+        Assert.Equal(UniformValueType.Vector4, half3Type);
+        Assert.Equal(UniformValueType.Vector4, vec4Type);
+        Assert.Equal(UniformValueType.Shader, shaderType);
+        Assert.Equal(UniformValueType.Color, colorType);
+        Assert.Equal(UniformValueType.Color, colorType2);
+    }
+}