Browse Source

Merge pull request #718 from PixiEditor/fixes/08.01.2025

Fixes/08.01.2025
Krzysztof Krysiński 7 months ago
parent
commit
62c6e13a15

+ 10 - 0
src/ChunkyImageLib/Operations/EllipseCache.cs

@@ -0,0 +1,10 @@
+using System.Collections.Concurrent;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+
+namespace ChunkyImageLib.Operations;
+
+public static class EllipseCache
+{
+    public static readonly ConcurrentDictionary<VecI, VectorPath> Ellipses = new();
+}

+ 136 - 7
src/ChunkyImageLib/Operations/EllipseHelper.cs

@@ -106,7 +106,7 @@ public class EllipseHelper
         float radiusY = (rect.Height - 1) / 2.0f;
         float radiusY = (rect.Height - 1) / 2.0f;
         if (rotationRad == 0)
         if (rotationRad == 0)
             return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y);
             return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y);
-        
+
         return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y, rotationRad);
         return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y, rotationRad);
     }
     }
 
 
@@ -185,6 +185,130 @@ public class EllipseHelper
         return listToFill;
         return listToFill;
     }
     }
 
 
+    /// <summary>
+    ///     Constructs pixel-perfect ellipse outline represented as a vector path.
+    ///  This function is quite heavy, for less precise but faster results use <see cref="GenerateEllipseVectorFromRect"/>.
+    /// </summary>
+    /// <param name="rectangle">The rectangle that the ellipse should fit into.</param>
+    /// <returns>A vector path that represents an ellipse outline.</returns>
+    public static VectorPath ConstructEllipseOutline(RectI rectangle)
+    {
+        if (EllipseCache.Ellipses.TryGetValue(rectangle.Size, out var cachedPath))
+        {
+            VectorPath finalPath = new(cachedPath);
+            finalPath.Transform(Matrix3X3.CreateTranslation(rectangle.TopLeft.X, rectangle.TopLeft.Y));
+            
+            return finalPath;
+        }
+        
+        if (rectangle.Width < 3 || rectangle.Height < 3)
+        {
+            VectorPath rectPath = new();
+            rectPath.AddRect((RectD)rectangle);
+
+            return rectPath;
+        }
+
+        if (rectangle is { Width: 3, Height: 3 })
+        {
+            return CreateThreePixelCircle((VecI)rectangle.Center);
+        }
+
+        var center = rectangle.Size / 2d;
+        RectI rect = new RectI(0, 0, rectangle.Width, rectangle.Height);
+        var points = GenerateEllipseFromRect(rect, 0).ToList();
+        points.Sort((vec, vec2) => Math.Sign((vec - center).Angle - (vec2 - center).Angle));
+        List<VecI> finalPoints = new();
+        for (int i = 0; i < points.Count; i++)
+        {
+            VecI prev = points[Mod(i - 1, points.Count)];
+            VecI point = points[i];
+            VecI next = points[Mod(i + 1, points.Count)];
+
+            bool atBottom = point.Y >= center.Y;
+            bool onRight = point.X >= center.X;
+            if (atBottom)
+            {
+                if (onRight)
+                {
+                    if (prev.Y != point.Y)
+                        finalPoints.Add(new(point.X + 1, point.Y));
+                    finalPoints.Add(new(point.X + 1, point.Y + 1));
+                    if (next.X != point.X)
+                        finalPoints.Add(new(point.X, point.Y + 1));
+                }
+                else
+                {
+                    if (prev.X != point.X)
+                        finalPoints.Add(new(point.X + 1, point.Y + 1));
+                    finalPoints.Add(new(point.X, point.Y + 1));
+                    if (next.Y != point.Y)
+                        finalPoints.Add(point);
+                }
+            }
+            else
+            {
+                if (onRight)
+                {
+                    if (prev.X != point.X)
+                        finalPoints.Add(point);
+                    finalPoints.Add(new(point.X + 1, point.Y));
+                    if (next.Y != point.Y)
+                        finalPoints.Add(new(point.X + 1, point.Y + 1));
+                }
+                else
+                {
+                    if (prev.Y != point.Y)
+                        finalPoints.Add(new(point.X, point.Y + 1));
+                    finalPoints.Add(point);
+                    if (next.X != point.X)
+                        finalPoints.Add(new(point.X + 1, point.Y));
+                }
+            }
+        }
+
+        VectorPath path = new();
+
+        path.MoveTo(new VecF(finalPoints[0].X, finalPoints[0].Y));
+        for (var index = 1; index < finalPoints.Count; index++)
+        {
+            var point = finalPoints[index];
+            path.LineTo(new VecF(point.X, point.Y));
+        }
+
+        path.Close();
+        
+        EllipseCache.Ellipses[rectangle.Size] = new VectorPath(path);
+        
+        path.Transform(Matrix3X3.CreateTranslation(rectangle.TopLeft.X, rectangle.TopLeft.Y));
+        return path;
+    }
+
+    public static VectorPath CreateThreePixelCircle(VecI rectanglePos)
+    {
+        var path = new VectorPath();
+        path.MoveTo(new VecF(0, 0));
+        path.LineTo(new VecF(0, -1));
+        path.LineTo(new VecF(1, -1));
+        path.LineTo(new VecF(1, 0));
+        path.LineTo(new VecF(2, 0));
+        path.LineTo(new VecF(2, 1));
+        path.LineTo(new VecF(2, 1));
+        path.LineTo(new VecF(1, 1));
+        path.LineTo(new VecF(1, 2));
+        path.LineTo(new VecF(0, 2));
+        path.LineTo(new VecF(0, 1));
+        path.LineTo(new VecF(-1, 1));
+        path.LineTo(new VecF(-1, 0));
+        path.Close();
+        
+        path.Transform(Matrix3X3.CreateTranslation(rectanglePos.X, rectanglePos.Y));
+        
+        return path;
+    }
+
+    private static int Mod(int x, int m) => (x % m + m) % m;
+
     // This function works, but honestly Skia produces better results, and it doesn't require so much
     // This function works, but honestly Skia produces better results, and it doesn't require so much
     // computation on the CPU. I'm leaving this, because once I (or someone else) figure out how to
     // computation on the CPU. I'm leaving this, because once I (or someone else) figure out how to
     // make it better, and it will be useful.
     // make it better, and it will be useful.
@@ -203,7 +327,7 @@ public class EllipseHelper
 
 
         // less than, because y grows downwards
         // less than, because y grows downwards
         //VecD actualTopmost = possiblyTopmostPoint.Y < possiblyMinPoint.Y ? possiblyTopmostPoint : possiblyMinPoint;
         //VecD actualTopmost = possiblyTopmostPoint.Y < possiblyMinPoint.Y ? possiblyTopmostPoint : possiblyMinPoint;
-        
+
         //rotationRad = double.Round(rotationRad, 1);
         //rotationRad = double.Round(rotationRad, 1);
 
 
         double currentTetha = 0;
         double currentTetha = 0;
@@ -221,13 +345,13 @@ public class EllipseHelper
 
 
             currentTetha += tethaStep;
             currentTetha += tethaStep;
         } while (currentTetha < Math.PI * 2);
         } while (currentTetha < Math.PI * 2);
-        
+
         return listToFill;
         return listToFill;
     }
     }
 
 
     private static void AddPoint(HashSet<VecI> listToFill, VecI floored, VecI[] lastPoints)
     private static void AddPoint(HashSet<VecI> listToFill, VecI floored, VecI[] lastPoints)
     {
     {
-        if(!listToFill.Add(floored)) return;
+        if (!listToFill.Add(floored)) return;
 
 
         if (lastPoints[0] == default)
         if (lastPoints[0] == default)
         {
         {
@@ -247,7 +371,7 @@ public class EllipseHelper
 
 
             lastPoints[0] = floored;
             lastPoints[0] = floored;
             lastPoints[1] = default;
             lastPoints[1] = default;
-            
+
             return;
             return;
         }
         }
 
 
@@ -345,13 +469,18 @@ public class EllipseHelper
         }
         }
     }
     }
 
 
+    /// <summary>
+    ///     This function generates a vector path that represents an oval. For pixel-perfect circle use <see cref="ConstructEllipseOutline"/>.
+    /// </summary>
+    /// <param name="location">The rectangle that the ellipse should fit into.</param>
+    /// <returns>A vector path that represents an oval.</returns>
     public static VectorPath GenerateEllipseVectorFromRect(RectD location)
     public static VectorPath GenerateEllipseVectorFromRect(RectD location)
     {
     {
         VectorPath path = new();
         VectorPath path = new();
         path.AddOval(location);
         path.AddOval(location);
-       
+
         path.Close();
         path.Close();
-        
+
         return path;
         return path;
     }
     }
 }
 }

+ 25 - 13
src/ChunkyImageLib/Operations/EllipseOperation.cs

@@ -46,14 +46,22 @@ internal class EllipseOperation : IMirroredDrawOperation
         {
         {
             if (Math.Abs(rotation) < 0.001)
             if (Math.Abs(rotation) < 0.001)
             {
             {
-                var ellipseList = EllipseHelper.GenerateEllipseFromRect((RectI)location);
+                if (strokeWidth == 0)
+                {
+                    ellipseOutline = EllipseHelper.ConstructEllipseOutline((RectI)location);
+                }
+                else
+                {
+                    var ellipseList = EllipseHelper.GenerateEllipseFromRect((RectI)location);
 
 
-                ellipse = ellipseList.Select(a => new VecF(a)).ToArray();
+                    ellipse = ellipseList.Select(a => new VecF(a)).ToArray();
 
 
-                if (fillColor.A > 0 || paint.BlendMode != BlendMode.SrcOver)
-                {
-                    (var fill, ellipseFillRect) = EllipseHelper.SplitEllipseFillIntoRegions(ellipseList.ToList(), (RectI)location);
-                    ellipseFill = fill.Select(a => new VecF(a)).ToArray();
+                    if (fillColor.A > 0 || paint.BlendMode != BlendMode.SrcOver)
+                    {
+                        (var fill, ellipseFillRect) =
+                            EllipseHelper.SplitEllipseFillIntoRegions(ellipseList.ToList(), (RectI)location);
+                        ellipseFill = fill.Select(a => new VecF(a)).ToArray();
+                    }
                 }
                 }
             }
             }
             else
             else
@@ -98,7 +106,7 @@ internal class EllipseOperation : IMirroredDrawOperation
         paint.IsAntiAliased = false;
         paint.IsAntiAliased = false;
         if (strokeWidth - 1 < 0.01)
         if (strokeWidth - 1 < 0.01)
         {
         {
-            if (Math.Abs(rotation) < 0.001)
+            if (Math.Abs(rotation) < 0.001 && strokeWidth > 0)
             {
             {
                 if (fillColor.A > 0 || paint.BlendMode != BlendMode.SrcOver)
                 if (fillColor.A > 0 || paint.BlendMode != BlendMode.SrcOver)
                 {
                 {
@@ -122,12 +130,15 @@ internal class EllipseOperation : IMirroredDrawOperation
                     paint.Style = PaintStyle.Fill;
                     paint.Style = PaintStyle.Fill;
                     surf.Canvas.DrawPath(ellipseOutline!, paint);
                     surf.Canvas.DrawPath(ellipseOutline!, paint);
                 }
                 }
-                
-                paint.Color = strokeColor;
-                paint.Style = PaintStyle.Stroke;
-                paint.StrokeWidth = 1f;
-                
-                surf.Canvas.DrawPath(ellipseOutline!, paint);
+
+                if (strokeWidth > 0)
+                {
+                    paint.Color = strokeColor;
+                    paint.Style = PaintStyle.Stroke;
+                    paint.StrokeWidth = 1;
+
+                    surf.Canvas.DrawPath(ellipseOutline!, paint);
+                }
 
 
                 surf.Canvas.Restore();
                 surf.Canvas.Restore();
             }
             }
@@ -207,5 +218,6 @@ internal class EllipseOperation : IMirroredDrawOperation
         paint.Dispose();
         paint.Dispose();
         outerPath?.Dispose();
         outerPath?.Dispose();
         innerPath?.Dispose();
         innerPath?.Dispose();
+        ellipseOutline?.Dispose();
     }
     }
 }
 }

+ 5 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ImageLayerNode.cs

@@ -193,6 +193,11 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
 
 
     private KeyFrameData GetFrameWithImage(KeyFrameTime frame)
     private KeyFrameData GetFrameWithImage(KeyFrameTime frame)
     {
     {
+        if (keyFrames.Count == 1)
+        {
+            return keyFrames[0];
+        }
+        
         var imageFrame = keyFrames.OrderBy(x => x.StartFrame).LastOrDefault(x => x.IsInFrame(frame.Frame));
         var imageFrame = keyFrames.OrderBy(x => x.StartFrame).LastOrDefault(x => x.IsInFrame(frame.Frame));
         if (imageFrame?.Data is not ChunkyImage)
         if (imageFrame?.Data is not ChunkyImage)
         {
         {

+ 14 - 3
src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs

@@ -5,6 +5,7 @@ using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core;
+using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
@@ -179,23 +180,33 @@ public class DocumentRenderer : IPreviewRenderable
         return true;
         return true;
     }
     }
 
 
-    public void RenderDocument(DrawingSurface toRenderOn, KeyFrameTime frameTime)
+    public void RenderDocument(DrawingSurface toRenderOn, KeyFrameTime frameTime, VecI renderSize)
     {
     {
         IsBusy = true;
         IsBusy = true;
 
 
-        if (renderTexture == null || renderTexture.Size != Document.Size)
+        if (renderTexture == null || renderTexture.Size != renderSize)
         {
         {
             renderTexture?.Dispose();
             renderTexture?.Dispose();
-            renderTexture = Texture.ForProcessing(Document.Size, Document.ProcessingColorSpace);
+            renderTexture = Texture.ForProcessing(renderSize, Document.ProcessingColorSpace);
         }
         }
 
 
+        renderTexture.DrawingSurface.Canvas.Save();
         renderTexture.DrawingSurface.Canvas.Clear();
         renderTexture.DrawingSurface.Canvas.Clear();
+
+        renderTexture.DrawingSurface.Canvas.SetMatrix(toRenderOn.Canvas.TotalMatrix);
+        toRenderOn.Canvas.Save();
+        toRenderOn.Canvas.SetMatrix(Matrix3X3.Identity);
+        
         RenderContext context =
         RenderContext context =
             new(renderTexture.DrawingSurface, frameTime, ChunkResolution.Full, Document.Size,
             new(renderTexture.DrawingSurface, frameTime, ChunkResolution.Full, Document.Size,
                 Document.ProcessingColorSpace) { FullRerender = true };
                 Document.ProcessingColorSpace) { FullRerender = true };
         Document.NodeGraph.Execute(context);
         Document.NodeGraph.Execute(context);
 
 
         toRenderOn.Canvas.DrawSurface(renderTexture.DrawingSurface, 0, 0);
         toRenderOn.Canvas.DrawSurface(renderTexture.DrawingSurface, 0, 0);
+        
+        renderTexture.DrawingSurface.Canvas.Restore();
+        toRenderOn.Canvas.Restore();
+        
         IsBusy = false;
         IsBusy = false;
     }
     }
     
     

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

@@ -803,5 +803,12 @@
   "TOGGLE_HUD": "Toggle HUD",
   "TOGGLE_HUD": "Toggle HUD",
   "OPEN_TIMELINE": "Open timeline",
   "OPEN_TIMELINE": "Open timeline",
   "OPEN_NODE_GRAPH": "Open node graph",
   "OPEN_NODE_GRAPH": "Open node graph",
-  "TOGGLE_PLAY": "Play/Pause animation"
+  "TOGGLE_PLAY": "Play/Pause animation",
+  "COPY_NODES": "Copy nodes",
+  "COPY_NODES_DESCRIPTIVE": "Copy selected nodes",
+  "PASTE_NODES": "Paste nodes",
+  "PASTE_NODES_DESCRIPTIVE": "Paste copied nodes",
+  "COPY_CELS": "Copy cels",
+  "COPY_CELS_DESCRIPTIVE": "Copy selected cels",
+  "TOGGLE_ONION_SKINNING_DESCRIPTIVE": "Toggle onion skinning"
 }
 }

+ 1 - 0
src/PixiEditor/Helpers/Constants/ClipboardDataFormats.cs

@@ -8,4 +8,5 @@ public static class ClipboardDataFormats
     public const string ImageSlashPng = "image/png";
     public const string ImageSlashPng = "image/png";
     public const string DocumentFormat = "PixiEditor.Document";
     public const string DocumentFormat = "PixiEditor.Document";
     public const string NodeIdList = "PixiEditor.NodeIdList";
     public const string NodeIdList = "PixiEditor.NodeIdList";
+    public const string CelIdList = "PixiEditor.CelIdList";
 }
 }

+ 2 - 2
src/PixiEditor/Helpers/Extensions/MethodExtension.cs

@@ -9,13 +9,13 @@ public static class MethodExtension
     public static async Task<T> InvokeAsync<T>(this MethodInfo @this, object obj, params object[] parameters)
     public static async Task<T> InvokeAsync<T>(this MethodInfo @this, object obj, params object[] parameters)
     {
     {
         //TODO: uhh, make sure this is ok?
         //TODO: uhh, make sure this is ok?
-        Dispatcher.UIThread.InvokeAsync(async () => await Task.Run(async () =>
+        Dispatcher.UIThread.InvokeAsync(async () =>
         {
         {
             var task = (Task)@this.Invoke(obj, parameters);
             var task = (Task)@this.Invoke(obj, parameters);
             await task.ConfigureAwait(false);
             await task.ConfigureAwait(false);
             var resultProperty = task.GetType().GetProperty("Result");
             var resultProperty = task.GetType().GetProperty("Result");
             return (T)resultProperty.GetValue(task);
             return (T)resultProperty.GetValue(task);
-        }));
+        });
 
 
         return default;
         return default;
     }
     }

+ 4 - 2
src/PixiEditor/Models/Commands/Evaluators/IconEvaluator.cs

@@ -16,8 +16,10 @@ internal class IconEvaluator : Evaluator<IImage>
 {
 {
     public static IconEvaluator Default { get; } = new FontIconEvaluator();
     public static IconEvaluator Default { get; } = new FontIconEvaluator();
 
 
-    public override IImage? CallEvaluate(Command command, object parameter) =>
-        base.CallEvaluate(command, parameter is CommandSearchResult or Command ? parameter : command);
+    public override IImage? CallEvaluate(Command command, object parameter)
+    {
+        return base.CallEvaluate(command, parameter is CommandSearchResult or Command ? parameter : command);
+    }
 
 
     [DebuggerDisplay("IconEvaluator.Default")]
     [DebuggerDisplay("IconEvaluator.Default")]
     private class FontIconEvaluator : IconEvaluator
     private class FontIconEvaluator : IconEvaluator

+ 4 - 1
src/PixiEditor/Models/Commands/Search/CommandSearchResult.cs

@@ -17,7 +17,10 @@ internal class CommandSearchResult : SearchResult
 
 
     public override KeyCombination Shortcut => Command.Shortcut;
     public override KeyCombination Shortcut => Command.Shortcut;
 
 
-    public CommandSearchResult(Command command) => Command = command;
+    public CommandSearchResult(Command command)
+    {
+        Command = command;
+    }
 
 
     public override void Execute()
     public override void Execute()
     {
     {

+ 1 - 1
src/PixiEditor/Models/Commands/Search/FileSearchResult.cs

@@ -30,7 +30,7 @@ internal class FileSearchResult : SearchResult
     {
     {
         FilePath = path;
         FilePath = path;
         var drawing = new GeometryDrawing() { Brush = FileExtensionToColorConverter.GetBrush(FilePath) };
         var drawing = new GeometryDrawing() { Brush = FileExtensionToColorConverter.GetBrush(FilePath) };
-        var geometry = new RectangleGeometry(new Rect(0, 0, 10, 10)) { }; // TODO: Avalonia 11.1 introduces rounded rectangle geometry, let's make this round again then 
+        var geometry = new RectangleGeometry(new Rect(0, 0, 10, 10)) { RadiusX = 2, RadiusY = 2 };
         drawing.Geometry = geometry;
         drawing.Geometry = geometry;
         icon = new DrawingImage(drawing);
         icon = new DrawingImage(drawing);
         this.asReferenceLayer = asReferenceLayer;
         this.asReferenceLayer = asReferenceLayer;

+ 20 - 10
src/PixiEditor/Models/Controllers/ClipboardController.cs

@@ -148,7 +148,7 @@ internal static class ClipboardController
         using Surface documentSurface = new Surface(document.SizeBindable);
         using Surface documentSurface = new Surface(document.SizeBindable);
 
 
         document.Renderer.RenderDocument(documentSurface.DrawingSurface,
         document.Renderer.RenderDocument(documentSurface.DrawingSurface,
-            document.AnimationDataViewModel.ActiveFrameTime);
+            document.AnimationDataViewModel.ActiveFrameTime, document.SizeBindable);
 
 
         Surface surfaceToCopy = new Surface((VecI)copyArea.Size.Ceiling());
         Surface surfaceToCopy = new Surface((VecI)copyArea.Size.Ceiling());
         using Paint paint = new Paint();
         using Paint paint = new Paint();
@@ -574,15 +574,7 @@ internal static class ClipboardController
 
 
     public static async Task CopyNodes(Guid[] nodeIds)
     public static async Task CopyNodes(Guid[] nodeIds)
     {
     {
-        await Clipboard.ClearAsync();
-
-        DataObject data = new DataObject();
-
-        byte[] nodeIdsBytes = Encoding.UTF8.GetBytes(string.Join(";", nodeIds.Select(x => x.ToString())));
-
-        data.Set(ClipboardDataFormats.NodeIdList, nodeIdsBytes);
-
-        await Clipboard.SetDataObjectAsync(data);
+        await CopyIds(nodeIds, ClipboardDataFormats.NodeIdList);
     }
     }
 
 
     public static async Task<List<Guid>> GetNodeIds()
     public static async Task<List<Guid>> GetNodeIds()
@@ -622,4 +614,22 @@ internal static class ClipboardController
         
         
         return formats.Contains(ClipboardDataFormats.NodeIdList);
         return formats.Contains(ClipboardDataFormats.NodeIdList);
     }
     }
+
+    public static async Task CopyCels(Guid[] celIds)
+    {
+        await CopyIds(celIds, ClipboardDataFormats.CelIdList);
+    }
+    
+    public static async Task CopyIds(Guid[] ids, string format)
+    {
+        await Clipboard.ClearAsync();
+
+        DataObject data = new DataObject();
+
+        byte[] idsBytes = Encoding.UTF8.GetBytes(string.Join(";", ids.Select(x => x.ToString())));
+
+        data.Set(format, idsBytes);
+
+        await Clipboard.SetDataObjectAsync(data);
+    }
 }
 }

+ 19 - 1
src/PixiEditor/Models/Rendering/SceneRenderer.cs

@@ -1,5 +1,6 @@
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.DataHolders;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core;
+using Drawie.Backend.Core.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces;
@@ -35,13 +36,25 @@ internal class SceneRenderer
     {
     {
         DrawingSurface renderTarget = target;
         DrawingSurface renderTarget = target;
         Texture? renderTexture = null;
         Texture? renderTexture = null;
+        bool restoreCanvas = false;
 
 
         if (!HighResRendering || !HighDpiRenderNodePresent(Document.NodeGraph))
         if (!HighResRendering || !HighDpiRenderNodePresent(Document.NodeGraph))
         {
         {
             renderTexture = Texture.ForProcessing(Document.Size, Document.ProcessingColorSpace);
             renderTexture = Texture.ForProcessing(Document.Size, Document.ProcessingColorSpace);
-
             renderTarget = renderTexture.DrawingSurface;
             renderTarget = renderTexture.DrawingSurface;
         }
         }
+        else
+        {
+            renderTexture = Texture.ForProcessing(renderTarget.DeviceClipBounds.Size, Document.ProcessingColorSpace);
+            renderTarget = renderTexture.DrawingSurface;
+            
+            target.Canvas.Save();
+            renderTarget.Canvas.Save();
+            
+            renderTarget.Canvas.SetMatrix(target.Canvas.TotalMatrix);
+            target.Canvas.SetMatrix(Matrix3X3.Identity);
+            restoreCanvas = true;
+        }
 
 
         RenderContext context = new(renderTarget, DocumentViewModel.AnimationHandler.ActiveFrameTime,
         RenderContext context = new(renderTarget, DocumentViewModel.AnimationHandler.ActiveFrameTime,
             resolution, Document.Size, Document.ProcessingColorSpace);
             resolution, Document.Size, Document.ProcessingColorSpace);
@@ -52,6 +65,11 @@ internal class SceneRenderer
         {
         {
             target.Canvas.DrawSurface(renderTexture.DrawingSurface, 0, 0);
             target.Canvas.DrawSurface(renderTexture.DrawingSurface, 0, 0);
             renderTexture.Dispose();
             renderTexture.Dispose();
+
+            if (restoreCanvas)
+            {
+                target.Canvas.Restore();
+            }
         }
         }
     }
     }
 
 

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

@@ -507,7 +507,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
                 VecD scaling = new VecD(renderSize.X / (double)SizeBindable.X, renderSize.Y / (double)SizeBindable.Y);
                 VecD scaling = new VecD(renderSize.X / (double)SizeBindable.X, renderSize.Y / (double)SizeBindable.Y);
 
 
                 finalSurface.DrawingSurface.Canvas.Scale((float)scaling.X, (float)scaling.Y);
                 finalSurface.DrawingSurface.Canvas.Scale((float)scaling.X, (float)scaling.Y);
-                Renderer.RenderDocument(finalSurface.DrawingSurface, frameTime);
+                Renderer.RenderDocument(finalSurface.DrawingSurface, frameTime, renderSize);
 
 
                 finalSurface.DrawingSurface.Canvas.Restore();
                 finalSurface.DrawingSurface.Canvas.Restore();
             });
             });

+ 38 - 3
src/PixiEditor/ViewModels/SubViewModels/ClipboardViewModel.cs

@@ -179,7 +179,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
             return;
             return;
 
 
         await block.ExecuteQueuedActions();
         await block.ExecuteQueuedActions();
-        
+
         ConnectRelatedNodes(doc, nodeMapping);
         ConnectRelatedNodes(doc, nodeMapping);
 
 
         doc.Operations.InvokeCustomAction(() =>
         doc.Operations.InvokeCustomAction(() =>
@@ -230,6 +230,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
     [Command.Basic("PixiEditor.Clipboard.CopyNodes", "COPY_NODES", "COPY_NODES_DESCRIPTIVE",
     [Command.Basic("PixiEditor.Clipboard.CopyNodes", "COPY_NODES", "COPY_NODES_DESCRIPTIVE",
         Key = Key.C, Modifiers = KeyModifiers.Control,
         Key = Key.C, Modifiers = KeyModifiers.Control,
         ShortcutContexts = [typeof(NodeGraphDockViewModel)],
         ShortcutContexts = [typeof(NodeGraphDockViewModel)],
+        CanExecute = "PixiEditor.Clipboard.CanCopyNodes",
         Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
         Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
     public async Task CopySelectedNodes()
     public async Task CopySelectedNodes()
     {
     {
@@ -244,6 +245,23 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         await ClipboardController.CopyNodes(selectedNodes);
         await ClipboardController.CopyNodes(selectedNodes);
     }
     }
 
 
+    [Command.Basic("PixiEditor.Clipboard.CopyCels", "COPY_CELS",
+        "COPY_CELS_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanCopyCels",
+        ShortcutContexts = [typeof(TimelineDockViewModel)],
+        Key = Key.C, Modifiers = KeyModifiers.Control, Icon = PixiPerfectIcons.Copy, AnalyticsTrack = true)]
+    public async Task CopySelectedCels()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        var selectedCels = doc.AnimationDataViewModel.AllCels.Where(x => x.IsSelected).Select(x => x.Id).ToArray();
+        if (selectedCels.Length == 0)
+            return;
+
+        await ClipboardController.CopyCels(selectedCels);
+    }
+
 
 
     [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsHex", CopyColor.PrimaryHEX, "COPY_COLOR_HEX",
     [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsHex", CopyColor.PrimaryHEX, "COPY_COLOR_HEX",
         "COPY_COLOR_HEX_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon", AnalyticsTrack = true)]
         "COPY_COLOR_HEX_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon", AnalyticsTrack = true)]
@@ -286,6 +304,20 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
             : ClipboardController.IsImageInClipboard().Result;
             : ClipboardController.IsImageInClipboard().Result;
     }
     }
 
 
+    [Evaluator.CanExecute("PixiEditor.Clipboard.CanCopyCels")]
+    public bool CanCopyCels()
+    {
+        return Owner.DocumentIsNotNull(null) &&
+               Owner.DocumentManagerSubViewModel.ActiveDocument.AnimationDataViewModel.AllCels.Any(x => x.IsSelected);
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Clipboard.CanCopyNodes")]
+    public bool CanCopyNodes()
+    {
+        return Owner.DocumentIsNotNull(null) &&
+               Owner.DocumentManagerSubViewModel.ActiveDocument.NodeGraph.AllNodes.Any(x => x.IsNodeSelected);
+    }
+
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteNodes")]
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteNodes")]
     public bool CanPasteNodes()
     public bool CanPasteNodes()
     {
     {
@@ -293,8 +325,11 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
     }
     }
 
 
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteColor")]
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteColor")]
-    public static async Task<bool> CanPasteColor() =>
-        ColorHelper.ParseAnyFormat((await ClipboardController.Clipboard.GetTextAsync())?.Trim() ?? string.Empty, out _);
+    public static async Task<bool> CanPasteColor()
+    {
+        return ColorHelper.ParseAnyFormat(
+            (await ClipboardController.Clipboard.GetTextAsync())?.Trim() ?? string.Empty, out _);
+    }
 
 
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanCopy")]
     [Evaluator.CanExecute("PixiEditor.Clipboard.CanCopy")]
     public bool CanCopy()
     public bool CanCopy()

+ 1 - 1
src/PixiEditor/ViewModels/SubViewModels/ColorsViewModel.cs

@@ -408,7 +408,7 @@ internal class ColorsViewModel : SubViewModel<ViewModelMain>, IColorsHandler
         PrimaryColor = color.ToColor();
         PrimaryColor = color.ToColor();
     }
     }
 
 
-    [Commands_Command.Basic("PixIEditor.Colors.AddPrimaryToPalettes", "ADD_PRIMARY_COLOR_TO_PALETTE",
+    [Commands_Command.Basic("PixiEditor.Colors.AddPrimaryToPalettes", "ADD_PRIMARY_COLOR_TO_PALETTE",
         "ADD_PRIMARY_COLOR_TO_PALETTE_DESCRIPTIVE", CanExecute = "PixiEditor.HasDocument",
         "ADD_PRIMARY_COLOR_TO_PALETTE_DESCRIPTIVE", CanExecute = "PixiEditor.HasDocument",
         Icon = PixiPerfectIcons.CopyAdd, AnalyticsTrack = true)]
         Icon = PixiPerfectIcons.CopyAdd, AnalyticsTrack = true)]
     public void AddPrimaryColorToPalette()
     public void AddPrimaryColorToPalette()

+ 7 - 29
src/PixiEditor/Views/Main/CommandSearch/CommandSearchControl.axaml

@@ -7,6 +7,7 @@
              xmlns:behaviours="clr-namespace:PixiEditor.Helpers.Behaviours"
              xmlns:behaviours="clr-namespace:PixiEditor.Helpers.Behaviours"
              xmlns:search="clr-namespace:PixiEditor.Models.Commands.Search"
              xmlns:search="clr-namespace:PixiEditor.Models.Commands.Search"
              xmlns:shortcuts="clr-namespace:PixiEditor.Views.Shortcuts"
              xmlns:shortcuts="clr-namespace:PixiEditor.Views.Shortcuts"
+             xmlns:commandSearch="clr-namespace:PixiEditor.Views.Main.CommandSearch"
              mc:Ignorable="d"
              mc:Ignorable="d"
              Foreground="White"
              Foreground="White"
              d:DesignHeight="450" d:DesignWidth="600"
              d:DesignHeight="450" d:DesignWidth="600"
@@ -49,34 +50,10 @@
                     <ItemsControl ItemsSource="{Binding Results, ElementName=uc}" x:Name="itemscontrol">
                     <ItemsControl ItemsSource="{Binding Results, ElementName=uc}" x:Name="itemscontrol">
                         <ItemsControl.ItemTemplate>
                         <ItemsControl.ItemTemplate>
                             <DataTemplate DataType="search:SearchResult">
                             <DataTemplate DataType="search:SearchResult">
-                                <Button Padding="5" Height="40" BorderThickness="0" Background="{DynamicResource ThemeBackgroundBrush}"
-                                        Command="{Binding ButtonClickedCommand, ElementName=uc}" CornerRadius="0"
-                                        CommandParameter="{Binding}"
-                                        HorizontalContentAlignment="Stretch"
-                                        IsEnabled="{Binding CanExecute}"
-                                        Classes.keyboard="{Binding IsSelected}"
-                                        PointerMoved="Button_MouseMove">
-                                    <Button.Styles>
-                                        <Style Selector="Button.keyboard">
-                                            <Setter Property="Background" Value="{DynamicResource ThemeControlHighBrush}"/>
-                                        </Style>
-                                    </Button.Styles>
-                                    <Grid VerticalAlignment="Center" x:Name="dp" Margin="5,0,10,0">
-                                        <Grid.ColumnDefinitions>
-                                            <ColumnDefinition />
-                                            <ColumnDefinition Width="Auto" />
-                                        </Grid.ColumnDefinitions>
-                                        <StackPanel Orientation="Horizontal">
-                                            <Border Width="25" Margin="0,0,5,0" Padding="1">
-                                                <Image HorizontalAlignment="Center" Source="{Binding Icon}" />
-                                            </Border>
-                                            <TextBlock VerticalAlignment="Center"
-                                                       behaviours:TextBlockExtensions.BindableInlines="{Binding TextBlockContent}" />
-                                        </StackPanel>
-
-                                        <shortcuts:ShortcutHint Grid.Column="1" VerticalAlignment="Center" Shortcut="{Binding Shortcut}" />
-                                    </Grid>
-                                </Button>
+                                <commandSearch:SearchResultControl
+                                    Result="{Binding}"
+                                    ButtonClickedCommand="{Binding ButtonClickedCommand, ElementName=uc}"
+                                    PointerMoved="SearchResult_MouseMove"/>
                             </DataTemplate>
                             </DataTemplate>
                         </ItemsControl.ItemTemplate>
                         </ItemsControl.ItemTemplate>
                     </ItemsControl>
                     </ItemsControl>
@@ -85,7 +62,8 @@
         </Border>
         </Border>
         <Border Grid.Row="2" BorderThickness="1" BorderBrush="{DynamicResource ThemeBorderMidBrush}"
         <Border Grid.Row="2" BorderThickness="1" BorderBrush="{DynamicResource ThemeBorderMidBrush}"
                 CornerRadius="0,0,5,5" Background="{DynamicResource ThemeBackgroundBrush1}" Padding="3">
                 CornerRadius="0,0,5,5" Background="{DynamicResource ThemeBackgroundBrush1}" Padding="3">
-            <ContentPresenter Content="{Binding SelectedResult.Description, Mode=OneWay, ElementName=uc}" />
+            <ContentPresenter
+                Content="{Binding SelectedResult.Description, Mode=OneWay, ElementName=uc, FallbackValue={x:Null}}" />
         </Border>
         </Border>
     </Grid>
     </Grid>
 </UserControl>
 </UserControl>

+ 19 - 11
src/PixiEditor/Views/Main/CommandSearch/CommandSearchControl.axaml.cs

@@ -29,8 +29,9 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
         set => SetValue(SearchTermProperty, value);
         set => SetValue(SearchTermProperty, value);
     }
     }
 
 
-    public static readonly StyledProperty<bool> SelectAllProperty = AvaloniaProperty.Register<CommandSearchControl, bool>(
-        nameof(SelectAll));
+    public static readonly StyledProperty<bool> SelectAllProperty =
+        AvaloniaProperty.Register<CommandSearchControl, bool>(
+            nameof(SelectAll));
 
 
     public bool SelectAll
     public bool SelectAll
     {
     {
@@ -39,6 +40,7 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
     }
     }
 
 
     private string warnings = "";
     private string warnings = "";
+
     public string Warnings
     public string Warnings
     {
     {
         get => warnings;
         get => warnings;
@@ -56,6 +58,7 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
     public event PropertyChangedEventHandler? PropertyChanged;
     public event PropertyChangedEventHandler? PropertyChanged;
 
 
     private SearchResult? selectedResult;
     private SearchResult? selectedResult;
+
     public SearchResult? SelectedResult
     public SearchResult? SelectedResult
     {
     {
         get => selectedResult;
         get => selectedResult;
@@ -71,6 +74,7 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
     }
     }
 
 
     private SearchResult? mouseSelectedResult;
     private SearchResult? mouseSelectedResult;
+
     public SearchResult? MouseSelectedResult
     public SearchResult? MouseSelectedResult
     {
     {
         get => mouseSelectedResult;
         get => mouseSelectedResult;
@@ -94,6 +98,8 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
 
 
     public CommandSearchControl()
     public CommandSearchControl()
     {
     {
+        InitializeComponent();
+
         ButtonClickedCommand = new RelayCommand(() =>
         ButtonClickedCommand = new RelayCommand(() =>
         {
         {
             Hide();
             Hide();
@@ -101,13 +107,11 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
             MouseSelectedResult = null;
             MouseSelectedResult = null;
         });
         });
 
 
-        InitializeComponent();
-
         PointerPressed += OnPointerDown;
         PointerPressed += OnPointerDown;
         KeyDown += OnPreviewKeyDown;
         KeyDown += OnPreviewKeyDown;
         Loaded += (_, _) => UpdateSearchResults();
         Loaded += (_, _) => UpdateSearchResults();
     }
     }
-    
+
 
 
     private static void OnIsVisibleChanged(AvaloniaPropertyChangedEventArgs<bool> e)
     private static void OnIsVisibleChanged(AvaloniaPropertyChangedEventArgs<bool> e)
     {
     {
@@ -142,7 +146,8 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
     private void UpdateSearchResults()
     private void UpdateSearchResults()
     {
     {
         Results.Clear();
         Results.Clear();
-        (List<SearchResult> newResults, List<string> warnings) = CommandSearchControlHelper.ConstructSearchResults(SearchTerm);
+        (List<SearchResult> newResults, List<string> warnings) =
+            CommandSearchControlHelper.ConstructSearchResults(SearchTerm);
         foreach (var result in newResults)
         foreach (var result in newResults)
             Results.Add(result);
             Results.Add(result);
         Warnings = warnings.Aggregate(new StringBuilder(), static (builder, item) =>
         Warnings = warnings.Aggregate(new StringBuilder(), static (builder, item) =>
@@ -196,13 +201,15 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
             textBox.SelectionStart = 4;
             textBox.SelectionStart = 4;
             textBox.SelectionEnd = 4;
             textBox.SelectionEnd = 4;
         }
         }
-        else if (e.Key == Key.Space && SearchTerm.StartsWith("rgb") && textBox.CaretIndex > 0 && char.IsDigit(SearchTerm[textBox.CaretIndex - 1]))
+        else if (e.Key == Key.Space && SearchTerm.StartsWith("rgb") && textBox.CaretIndex > 0 &&
+                 char.IsDigit(SearchTerm[textBox.CaretIndex - 1]))
         {
         {
             var prev = textBox.CaretIndex;
             var prev = textBox.CaretIndex;
             if (SearchTerm.Length == textBox.CaretIndex || SearchTerm[textBox.CaretIndex] != ',')
             if (SearchTerm.Length == textBox.CaretIndex || SearchTerm[textBox.CaretIndex] != ',')
             {
             {
                 SearchTerm = SearchTerm.Insert(textBox.CaretIndex, ",");
                 SearchTerm = SearchTerm.Insert(textBox.CaretIndex, ",");
             }
             }
+
             textBox.CaretIndex = prev + 1;
             textBox.CaretIndex = prev + 1;
         }
         }
         else if (e is { Key: Key.S, KeyModifiers: KeyModifiers.Control } &&
         else if (e is { Key: Key.S, KeyModifiers: KeyModifiers.Control } &&
@@ -269,15 +276,17 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
         int newIndex = Results.IndexOf(SelectedResult) + delta;
         int newIndex = Results.IndexOf(SelectedResult) + delta;
         newIndex = (newIndex % Results.Count + Results.Count) % Results.Count;
         newIndex = (newIndex % Results.Count + Results.Count) % Results.Count;
 
 
-        SelectedResult = delta > 0 ? Results.IndexOrNext(x => x.CanExecute, newIndex) : Results.IndexOrPrevious(x => x.CanExecute, newIndex);
+        SelectedResult = delta > 0
+            ? Results.IndexOrNext(x => x.CanExecute, newIndex)
+            : Results.IndexOrPrevious(x => x.CanExecute, newIndex);
 
 
         newIndex = Results.IndexOf(SelectedResult);
         newIndex = Results.IndexOf(SelectedResult);
         itemscontrol.ContainerFromIndex(newIndex)?.BringIntoView();
         itemscontrol.ContainerFromIndex(newIndex)?.BringIntoView();
     }
     }
 
 
-    private void Button_MouseMove(object sender, PointerEventArgs e)
+    private void SearchResult_MouseMove(object sender, PointerEventArgs e)
     {
     {
-        var searchResult = ((Button)sender).DataContext as SearchResult;
+        var searchResult = ((SearchResultControl)sender).DataContext as SearchResult;
         MouseSelectedResult = searchResult;
         MouseSelectedResult = searchResult;
     }
     }
 
 
@@ -285,6 +294,5 @@ internal partial class CommandSearchControl : UserControl, INotifyPropertyChange
     {
     {
         CommandSearchControl control = ((CommandSearchControl)e.Sender);
         CommandSearchControl control = ((CommandSearchControl)e.Sender);
         control.UpdateSearchResults();
         control.UpdateSearchResults();
-        control.PropertyChanged?.Invoke(control, new PropertyChangedEventArgs(nameof(control.SearchTerm)));
     }
     }
 }
 }

+ 43 - 0
src/PixiEditor/Views/Main/CommandSearch/SearchResultControl.axaml

@@ -0,0 +1,43 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:behaviours="clr-namespace:PixiEditor.Helpers.Behaviours"
+             xmlns:shortcuts="clr-namespace:PixiEditor.Views.Shortcuts"
+             x:ClassModifier="internal"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             Name="resultControl"
+             x:Class="PixiEditor.Views.Main.CommandSearch.SearchResultControl">
+    <Button DataContext="{Binding ElementName=resultControl}" Padding="5" Height="40" BorderThickness="0"
+            Command="{Binding ButtonClickedCommand}" CornerRadius="0"
+            CommandParameter="{Binding}"
+            HorizontalContentAlignment="Stretch"
+            Classes.keyboard="{Binding Result.IsSelected}"
+            IsEnabled="{Binding CanExecute}">
+        <Button.Styles>
+            <Style Selector="Button">
+                <Setter Property="Background" Value="{DynamicResource ThemeBackgroundBrush}"/>
+            </Style>
+            <Style Selector="Button.keyboard">
+                <Setter Property="Background"
+                        Value="{DynamicResource ThemeControlHighBrush}" />
+            </Style>
+        </Button.Styles>
+        <Grid VerticalAlignment="Center" x:Name="dp" Margin="5,0,10,0">
+            <Grid.ColumnDefinitions>
+                <ColumnDefinition />
+                <ColumnDefinition Width="Auto" />
+            </Grid.ColumnDefinitions>
+            <StackPanel Orientation="Horizontal">
+                <!--TODO: Below causes heap corruption crash randomly when searching-->
+                <Border Width="25" Margin="0,0,5,0" Padding="1">
+                    <Image HorizontalAlignment="Center" Source="{Binding EvaluatedIcon}" />
+                </Border>
+                <TextBlock VerticalAlignment="Center"
+                           behaviours:TextBlockExtensions.BindableInlines="{Binding Result.TextBlockContent}" />
+            </StackPanel>
+
+            <shortcuts:ShortcutHint Grid.Column="1" VerticalAlignment="Center" Shortcut="{Binding Result.Shortcut}" />
+        </Grid>
+    </Button>
+</UserControl>

+ 65 - 0
src/PixiEditor/Views/Main/CommandSearch/SearchResultControl.axaml.cs

@@ -0,0 +1,65 @@
+using System.ComponentModel;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using Avalonia.Threading;
+using PixiEditor.Models.Commands.Search;
+
+namespace PixiEditor.Views.Main.CommandSearch;
+
+internal partial class SearchResultControl : UserControl, INotifyPropertyChanged
+{
+    public static readonly StyledProperty<SearchResult> ResultProperty =
+        AvaloniaProperty.Register<SearchResultControl, SearchResult>(
+            nameof(Result));
+
+    public static readonly StyledProperty<ICommand> ButtonClickedCommandProperty =
+        AvaloniaProperty.Register<SearchResultControl, ICommand>(
+            nameof(ButtonClickedCommand));
+
+    public ICommand ButtonClickedCommand
+    {
+        get => GetValue(ButtonClickedCommandProperty);
+        set => SetValue(ButtonClickedCommandProperty, value);
+    }
+
+    public SearchResult Result
+    {
+        get => GetValue(ResultProperty);
+        set => SetValue(ResultProperty, value);
+    }
+
+    public IImage? EvaluatedIcon { get; private set; }
+    public bool CanExecute { get; private set; } = true;
+
+    public new event PropertyChangedEventHandler? PropertyChanged;
+
+    public SearchResultControl()
+    {
+        InitializeComponent();
+    }
+
+    protected override void OnLoaded(RoutedEventArgs e)
+    {
+        base.OnLoaded(e);
+
+        EvaluateCanExecute();
+        EvaluateIcon();
+    }
+
+    private void EvaluateCanExecute()
+    {
+        CanExecute = Result.CanExecute;
+        PropertyChanged?.Invoke(this, new(nameof(CanExecute)));
+    }
+
+    private void EvaluateIcon()
+    {
+        IImage icon = Result.Icon;
+        EvaluatedIcon = icon;
+        PropertyChanged?.Invoke(this, new(nameof(EvaluatedIcon)));
+    }
+}

+ 2 - 92
src/PixiEditor/Views/Overlays/BrushShapeOverlay/BrushShapeOverlay.cs

@@ -57,7 +57,7 @@ internal class BrushShapeOverlay : Overlay
     public BrushShapeOverlay()
     public BrushShapeOverlay()
     {
     {
         IsHitTestVisible = false;
         IsHitTestVisible = false;
-        threePixelCircle = CreateThreePixelCircle();
+        threePixelCircle = EllipseHelper.CreateThreePixelCircle(VecI.Zero);
     }
     }
 
 
     protected override void OnOverlayPointerMoved(OverlayPointerArgs args)
     protected override void OnOverlayPointerMoved(OverlayPointerArgs args)
@@ -124,7 +124,7 @@ internal class BrushShapeOverlay : Overlay
         {
         {
             if (BrushSize != lastSize)
             if (BrushSize != lastSize)
             {
             {
-                var geometry = ConstructEllipseOutline(new RectI(0, 0, rectI.Width, rectI.Height));
+                var geometry = EllipseHelper.ConstructEllipseOutline(new RectI(0, 0, rectI.Width, rectI.Height));
                 lastNonTranslatedCircle = new VectorPath(geometry);
                 lastNonTranslatedCircle = new VectorPath(geometry);
                 lastSize = BrushSize;
                 lastSize = BrushSize;
             }
             }
@@ -150,94 +150,4 @@ internal class BrushShapeOverlay : Overlay
     {
     {
         paint.StrokeWidth = (float)(1.0f / newZoom);
         paint.StrokeWidth = (float)(1.0f / newZoom);
     }
     }
-
-    private static int Mod(int x, int m) => (x % m + m) % m;
-
-    private static VectorPath CreateThreePixelCircle()
-    {
-        var path = new VectorPath();
-        path.MoveTo(new VecF(0, 0));
-        path.LineTo(new VecF(0, -1));
-        path.LineTo(new VecF(1, -1));
-        path.LineTo(new VecF(1, 0));
-        path.LineTo(new VecF(2, 0));
-        path.LineTo(new VecF(2, 1));
-        path.LineTo(new VecF(2, 1));
-        path.LineTo(new VecF(1, 1));
-        path.LineTo(new VecF(1, 2));
-        path.LineTo(new VecF(0, 2));
-        path.LineTo(new VecF(0, 1));
-        path.LineTo(new VecF(-1, 1));
-        path.LineTo(new VecF(-1, 0));
-        path.Close();
-        return path;
-    }
-
-    private static VectorPath ConstructEllipseOutline(RectI rectangle)
-    {
-        var center = rectangle.Center;
-        var points = EllipseHelper.GenerateEllipseFromRect(rectangle, 0).ToList();
-        points.Sort((vec, vec2) => Math.Sign((vec - center).Angle - (vec2 - center).Angle));
-        List<VecI> finalPoints = new();
-        for (int i = 0; i < points.Count; i++)
-        {
-            VecI prev = points[Mod(i - 1, points.Count)];
-            VecI point = points[i];
-            VecI next = points[Mod(i + 1, points.Count)];
-
-            bool atBottom = point.Y >= center.Y;
-            bool onRight = point.X >= center.X;
-            if (atBottom)
-            {
-                if (onRight)
-                {
-                    if (prev.Y != point.Y)
-                        finalPoints.Add(new(point.X + 1, point.Y));
-                    finalPoints.Add(new(point.X + 1, point.Y + 1));
-                    if (next.X != point.X)
-                        finalPoints.Add(new(point.X, point.Y + 1));
-                }
-                else
-                {
-                    if (prev.X != point.X)
-                        finalPoints.Add(new(point.X + 1, point.Y + 1));
-                    finalPoints.Add(new(point.X, point.Y + 1));
-                    if (next.Y != point.Y)
-                        finalPoints.Add(point);
-                }
-            }
-            else
-            {
-                if (onRight)
-                {
-                    if (prev.X != point.X)
-                        finalPoints.Add(point);
-                    finalPoints.Add(new(point.X + 1, point.Y));
-                    if (next.Y != point.Y)
-                        finalPoints.Add(new(point.X + 1, point.Y + 1));
-                }
-                else
-                {
-                    if (prev.Y != point.Y)
-                        finalPoints.Add(new(point.X, point.Y + 1));
-                    finalPoints.Add(point);
-                    if (next.X != point.X)
-                        finalPoints.Add(new(point.X + 1, point.Y));
-                }
-            }
-        }
-
-        VectorPath path = new();
-
-        path.MoveTo(new VecF(finalPoints[0].X, finalPoints[0].Y));
-        for (var index = 1; index < finalPoints.Count; index++)
-        {
-            var point = finalPoints[index];
-            path.LineTo(new VecF(point.X, point.Y));
-        }
-
-        path.Close();
-
-        return path;
-    }
 }
 }