ソースを参照

Merge pull request #751 from PixiEditor/text

Text tool
Krzysztof Krysiński 5 ヶ月 前
コミット
c3ccdeff93
86 ファイル変更2856 行追加130 行削除
  1. 1 1
      src/Drawie
  2. 9 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/Shapes/IReadOnlyTextData.cs
  3. 17 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs
  4. 20 6
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/ShapeVectorData.cs
  5. 203 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs
  6. 81 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextNode.cs
  7. 13 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs
  8. 46 0
      src/PixiEditor.ChangeableDocument/Changes/Structure/SetLowDpiRendering_Change.cs
  9. 6 0
      src/PixiEditor.ChangeableDocument/Changes/Vectors/ConvertToCurve_Change.cs
  10. 11 10
      src/PixiEditor.ChangeableDocument/Changes/Vectors/SetShapeGeometry_UpdateableChange.cs
  11. 1 0
      src/PixiEditor.Extensions/UI/Overlays/OverlayPointerArgs.cs
  12. 6 2
      src/PixiEditor.UI.Common/Accents/Base.axaml
  13. BIN
      src/PixiEditor.UI.Common/Fonts/PixiPerfect.ttf
  14. 48 28
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml
  15. 16 1
      src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml.cs
  16. 26 3
      src/PixiEditor/Data/Configs/ToolSetsConfig.json
  17. 12 0
      src/PixiEditor/Data/Localization/Languages/en.json
  18. 18 0
      src/PixiEditor/Helpers/Converters/FontFamilyNameToAvaloniaFontFamily.cs
  19. 7 0
      src/PixiEditor/Helpers/DocumentViewModelBuilder.cs
  20. 3 1
      src/PixiEditor/Helpers/Extensions/PixiParserDocumentEx.cs
  21. 4 0
      src/PixiEditor/Helpers/ServiceCollectionHelpers.cs
  22. 2 2
      src/PixiEditor/Helpers/SupportedFilesHelper.cs
  23. 8 0
      src/PixiEditor/Helpers/ThemeResources.cs
  24. 18 3
      src/PixiEditor/Models/Controllers/ClipboardController.cs
  25. 44 0
      src/PixiEditor/Models/Controllers/FontLibrary.cs
  26. 3 3
      src/PixiEditor/Models/Controllers/InputDevice/MouseInputFilter.cs
  27. 10 6
      src/PixiEditor/Models/Controllers/InputDevice/MouseOnCanvasEventArgs.cs
  28. 3 3
      src/PixiEditor/Models/Controllers/ShortcutController.cs
  29. 16 0
      src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs
  30. 2 0
      src/PixiEditor/Models/DocumentModels/Public/DocumentEventsModule.cs
  31. 6 0
      src/PixiEditor/Models/DocumentModels/Public/DocumentToolsModule.cs
  32. 6 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/IQuickToolSwitchable.cs
  33. 6 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITextOverlayEvents.cs
  34. 22 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedExecutor.cs
  35. 232 0
      src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorTextToolExecutor.cs
  36. 2 0
      src/PixiEditor/Models/Files/IoFileType.cs
  37. 18 0
      src/PixiEditor/Models/Files/OtfFileType.cs
  38. 18 0
      src/PixiEditor/Models/Files/TtfFileType.cs
  39. 5 1
      src/PixiEditor/Models/Handlers/IDocument.cs
  40. 1 0
      src/PixiEditor/Models/Handlers/IDocumentOperations.cs
  41. 1 0
      src/PixiEditor/Models/Handlers/ILayerHandler.cs
  42. 16 0
      src/PixiEditor/Models/Handlers/ITextOverlayHandler.cs
  43. 3 2
      src/PixiEditor/Models/Handlers/IToolHandler.cs
  44. 1 0
      src/PixiEditor/Models/Handlers/IToolsHandler.cs
  45. 15 0
      src/PixiEditor/Models/Handlers/Toolbars/ITextToolbar.cs
  46. 6 0
      src/PixiEditor/Models/Handlers/Tools/ITextToolHandler.cs
  47. 66 0
      src/PixiEditor/Models/IO/CustomDocumentFormats/FontDocumentBuilder.cs
  48. 1 0
      src/PixiEditor/Models/IO/Paths.cs
  49. 4 0
      src/PixiEditor/Models/Serialization/Factories/SerializationFactory.cs
  50. 112 0
      src/PixiEditor/Models/Serialization/Factories/TextSerializationFactory.cs
  51. 4 0
      src/PixiEditor/PixiEditor.csproj
  52. 17 5
      src/PixiEditor/ViewModels/Document/DocumentViewModel.Serialization.cs
  53. 53 3
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  54. 4 0
      src/PixiEditor/ViewModels/Document/Nodes/ImageLayerNodeViewModel.cs
  55. 9 0
      src/PixiEditor/ViewModels/Document/Nodes/Shapes/TextNodeViewModel.cs
  56. 29 0
      src/PixiEditor/ViewModels/Document/Nodes/VectorLayerNodeViewModel.cs
  57. 18 0
      src/PixiEditor/ViewModels/Document/ResourceStorageLocator.cs
  58. 124 0
      src/PixiEditor/ViewModels/Document/TransformOverlays/TextOverlayViewModel.cs
  59. 30 16
      src/PixiEditor/ViewModels/SubViewModels/ToolsViewModel.cs
  60. 1 0
      src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/BoolSettingViewModel.cs
  61. 84 0
      src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/FontFamilySettingViewModel.cs
  62. 7 0
      src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/Setting.cs
  63. 17 5
      src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/SizeSettingViewModel.cs
  64. 4 1
      src/PixiEditor/ViewModels/Tools/ToolSettings/Toolbars/ShapeToolbar.cs
  65. 153 0
      src/PixiEditor/ViewModels/Tools/ToolSettings/Toolbars/TextToolbar.cs
  66. 7 2
      src/PixiEditor/ViewModels/Tools/ToolViewModel.cs
  67. 2 2
      src/PixiEditor/ViewModels/Tools/Tools/MoveToolViewModel.cs
  68. 77 0
      src/PixiEditor/ViewModels/Tools/Tools/TextToolViewModel.cs
  69. 2 2
      src/PixiEditor/ViewModels/Tools/Tools/VectorEllipseToolViewModel.cs
  70. 2 2
      src/PixiEditor/ViewModels/Tools/Tools/VectorLineToolViewModel.cs
  71. 2 2
      src/PixiEditor/ViewModels/Tools/Tools/VectorPathToolViewModel.cs
  72. 2 2
      src/PixiEditor/ViewModels/Tools/Tools/VectorRectangleToolViewModel.cs
  73. 19 1
      src/PixiEditor/Views/Main/Tools/Toolbar.axaml
  74. 3 3
      src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml.cs
  75. 65 0
      src/PixiEditor/Views/Main/ViewportControls/ViewportOverlays.cs
  76. 8 2
      src/PixiEditor/Views/Overlays/Overlay.cs
  77. 1 1
      src/PixiEditor/Views/Overlays/PathOverlay/VectorPathOverlay.cs
  78. 73 0
      src/PixiEditor/Views/Overlays/TextOverlay/Caret.cs
  79. 746 0
      src/PixiEditor/Views/Overlays/TextOverlay/TextOverlay.cs
  80. 13 2
      src/PixiEditor/Views/Overlays/TransformOverlay/TransformOverlay.cs
  81. 9 0
      src/PixiEditor/Views/Rendering/Scene.cs
  82. 28 4
      src/PixiEditor/Views/Tools/ToolSettings/Settings/BoolSettingView.axaml
  83. 31 0
      src/PixiEditor/Views/Tools/ToolSettings/Settings/FontFamilySettingView.axaml
  84. 15 0
      src/PixiEditor/Views/Tools/ToolSettings/Settings/FontFamilySettingView.axaml.cs
  85. 1 0
      src/PixiEditor/Views/Tools/ToolSettings/Settings/SizeSettingView.axaml
  86. 1 1
      src/PixiParser

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit be25b58b54da66ad551bbfdf1449f0afc52d9f52
+Subproject commit 52c1a1efd9be0c5bb94199056d7abd16fea372a1

+ 9 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/Shapes/IReadOnlyTextData.cs

@@ -0,0 +1,9 @@
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces.Shapes;
+
+public interface IReadOnlyTextData
+{
+    public string Text { get; }
+    public VecD Position { get; }
+}

+ 17 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs

@@ -5,6 +5,7 @@ using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 
@@ -67,6 +68,22 @@ public abstract class RenderNode : Node, IPreviewRenderable, IHighDpiRenderNode
         return textureCache.RequestTexture(id, size, processingCs, clear);
     }
 
+    public override void SerializeAdditionalData(Dictionary<string, object> additionalData)
+    {
+        base.SerializeAdditionalData(additionalData);
+        additionalData["AllowHighDpiRendering"] = AllowHighDpiRendering;
+    }
+
+    internal override OneOf<None, IChangeInfo, List<IChangeInfo>> DeserializeAdditionalData(IReadOnlyDocument target, IReadOnlyDictionary<string, object> data)
+    {
+        base.DeserializeAdditionalData(target, data);
+
+        if(data.TryGetValue("AllowHighDpiRendering", out var value))
+            AllowHighDpiRendering = (bool)value;
+
+        return new None();
+    }
+
     public override void Dispose()
     {
         base.Dispose();

+ 20 - 6
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/ShapeVectorData.cs

@@ -11,18 +11,30 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 
 public abstract class ShapeVectorData : ICacheable, ICloneable, IReadOnlyShapeVectorData
 {
-    public Matrix3X3 TransformationMatrix { get; set; } = Matrix3X3.Identity; 
-    
+    private float strokeWidth = 1;
+
+    public Matrix3X3 TransformationMatrix { get; set; } = Matrix3X3.Identity;
+
     public Color StrokeColor { get; set; } = Colors.White;
     public Color FillColor { get; set; } = Colors.White;
-    public float StrokeWidth { get; set; } = 1;
+
+    public float StrokeWidth
+    {
+        get => strokeWidth;
+        set
+        {
+            strokeWidth = value;
+            OnStrokeWidthChanged();
+        }
+    }
+    
     public bool Fill { get; set; } = true;
-    public abstract RectD GeometryAABB { get; } 
+    public abstract RectD GeometryAABB { get; }
     public abstract RectD VisualAABB { get; }
     public RectD TransformedAABB => new ShapeCorners(GeometryAABB).WithMatrix(TransformationMatrix).AABBBounds;
     public RectD TransformedVisualAABB => new ShapeCorners(VisualAABB).WithMatrix(TransformationMatrix).AABBBounds;
-    public abstract ShapeCorners TransformationCorners { get; } 
-    
+    public abstract ShapeCorners TransformationCorners { get; }
+
     protected void ApplyTransformTo(Canvas canvas)
     {
         Matrix3X3 canvasMatrix = canvas.TotalMatrix;
@@ -46,6 +58,8 @@ public abstract class ShapeVectorData : ICacheable, ICloneable, IReadOnlyShapeVe
     }
 
     protected virtual void AdjustCopy(ShapeVectorData copy) { }
+    
+    protected virtual void OnStrokeWidthChanged() { }
 
     public override int GetHashCode()
     {

+ 203 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs

@@ -0,0 +1,203 @@
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces.Shapes;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+
+public class TextVectorData : ShapeVectorData, IReadOnlyTextData
+{
+    private string text;
+    private Font font = Font.CreateDefault();
+    private double? spacing = null;
+    private double strokeWidth = 1;
+
+    public string Text
+    {
+        get => text;
+        set
+        {
+            text = value;
+            richText = new RichText(value) { Spacing = Spacing, MaxWidth = MaxWidth, StrokeWidth = StrokeWidth };
+
+            lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+
+    public VecD Position { get; set; }
+
+    public double MaxWidth { get; set; } = double.MaxValue;
+
+    public Font Font
+    {
+        get => font;
+        set
+        {
+            if (value != null)
+            {
+                value.Changed -= FontChanged;
+            }
+
+            font = value;
+            if (value != null)
+            {
+                value.Changed += FontChanged;
+            }
+
+            lastBounds = richText.MeasureBounds(value);
+        }
+    }
+
+    private void FontChanged()
+    {
+        if (richText == null)
+        {
+            return;
+        }
+
+        lastBounds = richText.MeasureBounds(Font);
+    }
+
+    public double? Spacing
+    {
+        get => spacing;
+        set
+        {
+            spacing = value;
+            richText.Spacing = value;
+            lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+    
+    public bool AntiAlias { get; set; } = true;
+
+    protected override void OnStrokeWidthChanged()
+    {
+        if(richText == null)
+        {
+            return;
+        }
+
+        richText.StrokeWidth = StrokeWidth;
+        lastBounds = richText.MeasureBounds(Font);
+    }
+
+    public override RectD GeometryAABB
+    {
+        get
+        {
+            return lastBounds.Offset(Position);
+        }
+    }
+
+    public override ShapeCorners TransformationCorners =>
+        new ShapeCorners(GeometryAABB).WithMatrix(TransformationMatrix);
+
+    public override RectD VisualAABB => GeometryAABB;
+    public VectorPath? Path { get; set; }
+    public FontFamilyName? MissingFontFamily { get; set; }
+    public string MissingFontText { get; set; }
+
+    private RichText richText;
+    private RectD lastBounds;
+
+    public TextVectorData()
+    {
+
+    }
+
+    public TextVectorData(string text)
+    {
+        Text = text;
+    }
+
+
+    public override VectorPath ToPath()
+    {
+        var path = richText.ToPath(Font);
+        path.Offset(Position);
+
+        return path;
+    }
+
+    public override void RasterizeGeometry(Canvas canvas)
+    {
+        Rasterize(canvas, false);
+    }
+
+    public override void RasterizeTransformed(Canvas canvas)
+    {
+        Rasterize(canvas, true);
+    }
+
+    private void Rasterize(Canvas canvas, bool applyTransform)
+    {
+        int num = 0;
+        if (applyTransform)
+        {
+            num = canvas.Save();
+            ApplyTransformTo(canvas);
+        }
+
+        using Paint paint = new Paint() { IsAntiAliased = AntiAlias };
+
+        richText.Fill = Fill;
+        richText.FillColor = FillColor;
+        richText.StrokeColor = StrokeColor;
+        richText.StrokeWidth = StrokeWidth;
+        richText.Spacing = Spacing;
+
+        if (MissingFontFamily != null)
+        {
+            paint.Color = Fill ? FillColor : StrokeColor;
+            canvas.DrawText($"{MissingFontText}: " + MissingFontFamily.Value.Name, Position, Font, paint);
+        }
+        else
+        {
+            PaintText(canvas, paint);
+        }
+
+        if (applyTransform)
+        {
+            canvas.RestoreToCount(num);
+        }
+    }
+
+    private void PaintText(Canvas canvas, Paint paint)
+    {
+        richText.Paint(canvas, Position, Font, paint, Path);
+    }
+
+    public override bool IsValid()
+    {
+        return !string.IsNullOrEmpty(Text);
+    }
+
+    public override int GetCacheHash()
+    {
+        return HashCode.Combine(Text, Position, Font, StrokeColor, FillColor, StrokeWidth, TransformationMatrix);
+    }
+
+    protected override void AdjustCopy(ShapeVectorData copy)
+    {
+        if (copy is TextVectorData textData)
+        {
+            textData.Font = Font.FromFontFamily(Font.Family);
+            textData.Font.Size = Font.Size;
+            textData.Font.Edging = Font.Edging;
+            textData.Font.SubPixel = Font.SubPixel;
+            textData.Font.Bold = Font.Bold;
+            textData.Font.Italic = Font.Italic;
+
+            textData.lastBounds = lastBounds;
+        }
+    }
+
+    public override int CalculateHash()
+    {
+        return GetCacheHash();
+    }
+}

+ 81 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextNode.cs

@@ -0,0 +1,81 @@
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("Text")]
+public class TextNode : ShapeNode<TextVectorData>
+{
+    public InputProperty<string> Text { get; }
+    public InputProperty<VecD> TextPosition { get; }
+    public InputProperty<string> FontFamily { get; }
+    public InputProperty<double> FontSize { get; }
+    public InputProperty<ShapeVectorData> OnPathData { get; }
+    
+    private string lastText = "";
+    private VecD lastPosition = new VecD();
+    private string lastFontFamily = "";
+    private double lastFontSize = 12d;
+    private VectorPath? lastPath;
+
+    private TextVectorData? cachedData;
+    public TextNode()
+    {
+        Text = CreateInput("Text", "TEXT", "");
+        TextPosition = CreateInput("Position", "POSITION", new VecD());
+        FontFamily = CreateInput("FontFamily", "FONT_FAMILY", "");
+        FontSize = CreateInput("FontSize", "FONT_SIZE", 12d);
+        OnPathData = CreateInput<ShapeVectorData>("PathToDrawOn", "ON_PATH_DATA", null);
+    }
+    
+    protected override TextVectorData? GetShapeData(RenderContext context)
+    {
+        string text = Text.Value;
+        VecD position = TextPosition.Value;
+        string fontFamily = FontFamily.Value;
+        double fontSize = FontSize.Value;
+        VectorPath? path = OnPathData.Value?.ToPath();
+        
+        if (text == lastText && position == lastPosition && fontFamily == lastFontFamily && fontSize == lastFontSize && path == lastPath)
+        {
+            return cachedData;
+        }
+        
+        lastText = text;
+        lastPosition = position;
+        lastFontFamily = fontFamily;
+        lastFontSize = fontSize;
+        lastPath = path;
+
+        Font font = Font.FromFamilyName(fontFamily);
+        if(font == null)
+        {
+            font = Font.CreateDefault();
+        }
+        
+        font.Size = fontSize;
+        
+        cachedData = new TextVectorData()
+        {
+            Text = text,
+            Position = position,
+            Font = font,
+            Path = path,
+        };
+        
+        return cachedData;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new TextNode();
+    }
+
+    public override void Dispose()
+    {
+        base.Dispose();
+    }
+}

+ 13 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs

@@ -17,6 +17,7 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 [NodeInfo("VectorLayer")]
 public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorNode, IRasterizable
 {
+    public OutputProperty<ShapeVectorData> Shape { get; }
     public Matrix3X3 TransformationMatrix
     {
         get => ShapeData?.TransformationMatrix ?? Matrix3X3.Identity;
@@ -31,7 +32,11 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
         }
     }
 
-    public ShapeVectorData? ShapeData { get; set; }
+    public ShapeVectorData? ShapeData
+    {
+        get => Shape.Value;
+        set => Shape.Value = value;
+    }
     IReadOnlyShapeVectorData IReadOnlyVectorNode.ShapeData => ShapeData;
 
 
@@ -43,6 +48,7 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
     public VectorLayerNode()
     {
         AllowHighDpiRendering = true;
+        Shape = CreateOutput<ShapeVectorData>("Shape", "SHAPE", null);
     }
     
     protected override VecI GetTargetSize(RenderContext ctx)
@@ -167,6 +173,11 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
 
     public override Node CreateCopy()
     {
-        return new VectorLayerNode() { ShapeData = (ShapeVectorData?)ShapeData?.Clone(), ClipToPreviousMember = this.ClipToPreviousMember };
+        return new VectorLayerNode()
+        {
+            ShapeData = (ShapeVectorData?)ShapeData?.Clone(),
+            ClipToPreviousMember = this.ClipToPreviousMember,
+            AllowHighDpiRendering = this.AllowHighDpiRendering
+        };
     }
 }

+ 46 - 0
src/PixiEditor.ChangeableDocument/Changes/Structure/SetLowDpiRendering_Change.cs

@@ -0,0 +1,46 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+namespace PixiEditor.ChangeableDocument.Changes.Structure;
+
+internal class SetLowDpiRendering_Change : Change
+{
+    public readonly Guid memberId;
+    public bool value;
+    
+    private bool originalValue;
+    
+    [GenerateMakeChangeAction]
+    public SetLowDpiRendering_Change(Guid memberId, bool value)
+    {
+        this.memberId = memberId;
+        this.value = value;
+    }
+    
+    public override bool InitializeAndValidate(Document target)
+    {
+        return target.TryFindNode(memberId, out RenderNode node);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        RenderNode node = target.FindNodeOrThrow<RenderNode>(memberId);
+        
+        bool toSet = !value;
+        
+        originalValue = node.AllowHighDpiRendering;
+        node.AllowHighDpiRendering = toSet;
+        
+        ignoreInUndo = originalValue == toSet;
+
+        return new None();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        RenderNode node = target.FindNodeOrThrow<RenderNode>(memberId);
+        
+        node.AllowHighDpiRendering = originalValue;
+
+        return new None();
+    }
+}

+ 6 - 0
src/PixiEditor.ChangeableDocument/Changes/Vectors/ConvertToCurve_Change.cs

@@ -12,6 +12,7 @@ internal class ConvertToCurve_Change : Change
     public readonly Guid memberId;
 
     private ShapeVectorData originalData;
+    private bool originalHighDpiRendering;
 
     [GenerateMakeChangeAction]
     public ConvertToCurve_Change(Guid memberId)
@@ -44,6 +45,9 @@ internal class ConvertToCurve_Change : Change
             TransformationMatrix = originalData.TransformationMatrix
         };
 
+        originalHighDpiRendering = node.AllowHighDpiRendering;
+        node.AllowHighDpiRendering = true;
+
         ignoreInUndo = false;
 
         var aabb = node.ShapeData.TransformedVisualAABB;
@@ -58,6 +62,8 @@ internal class ConvertToCurve_Change : Change
         VectorLayerNode node = target.FindNodeOrThrow<VectorLayerNode>(memberId);
         node.ShapeData = originalData;
 
+        node.AllowHighDpiRendering = originalHighDpiRendering;
+
         var aabb = node.ShapeData.TransformedVisualAABB;
         var affected = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
             (RectI)aabb, ChunkyImage.FullChunkSize));

+ 11 - 10
src/PixiEditor.ChangeableDocument/Changes/Vectors/SetShapeGeometry_UpdateableChange.cs

@@ -12,7 +12,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     public ShapeVectorData Data { get; set; }
 
     private ShapeVectorData? originalData;
-    
+
     private AffectedArea lastAffectedArea;
 
     [GenerateUpdateableChangeActions]
@@ -42,8 +42,9 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
         var node = target.FindNode<VectorLayerNode>(TargetId);
-        node.ShapeData = Data;
 
+        node.ShapeData = Data;
+        
         RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
         aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
 
@@ -51,14 +52,14 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
             (RectI)aabb, ChunkyImage.FullChunkSize));
 
         var tmp = new AffectedArea(affected);
-        
+
         if (lastAffectedArea.Chunks != null)
         {
             affected.UnionWith(lastAffectedArea);
         }
-        
+
         lastAffectedArea = tmp;
-        
+
         return new VectorShape_ChangeInfo(node.Id, affected);
     }
 
@@ -68,7 +69,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
         ignoreInUndo = false;
         var node = target.FindNode<VectorLayerNode>(TargetId);
         node.ShapeData = Data;
-        
+
         RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
         aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
 
@@ -84,12 +85,12 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
         node.ShapeData = originalData;
 
         AffectedArea affected = new AffectedArea();
-        
+
         if (node.ShapeData != null)
-        { 
+        {
             RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
             aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
-         
+
             affected = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
                 (RectI)aabb, ChunkyImage.FullChunkSize));
         }
@@ -101,7 +102,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     {
         if (other is SetShapeGeometry_UpdateableChange change)
         {
-            return change.TargetId == TargetId;
+            return change.TargetId == TargetId && change.Data is not TextVectorData; // text should not be merged into one change
         }
 
         return false;

+ 1 - 0
src/PixiEditor.Extensions/UI/Overlays/OverlayPointerArgs.cs

@@ -11,4 +11,5 @@ public class OverlayPointerArgs
     public MouseButton InitialPressMouseButton { get; set; }
     public IOverlayPointer Pointer { get; set; }
     public bool Handled { get; set; }
+    public int ClickCount { get; set; }
 }

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

@@ -56,10 +56,13 @@
             <Color x:Key="IntSocketColor">#4C64B1</Color>
             <Color x:Key="EllipseDataSocketColor">#a473a5</Color>
             <Color x:Key="PointsDataSocketColor">#e1d0e1</Color>
+            <Color x:Key="TextDataSocketColor">#f2f2f2</Color>
             <GradientStops x:Key="ShapeDataSocketGradient">
                 <GradientStop Offset="0" Color="{StaticResource EllipseDataSocketColor}"/>
-                <GradientStop Offset="0.5" Color="{StaticResource EllipseDataSocketColor}"/>
-                <GradientStop Offset="0.5" Color="{StaticResource PointsDataSocketColor}"/>
+                <GradientStop Offset="0.33" Color="{StaticResource EllipseDataSocketColor}"/>
+                <GradientStop Offset="0.33" Color="{StaticResource TextDataSocketColor}"/>
+                <GradientStop Offset="0.66" Color="{StaticResource TextDataSocketColor}"/>
+                <GradientStop Offset="0.66" Color="{StaticResource PointsDataSocketColor}"/>
                 <GradientStop Offset="1" Color="{StaticResource PointsDataSocketColor}"/>
             </GradientStops>
             
@@ -145,6 +148,7 @@
             <ConicGradientBrush x:Key="ShapeVectorDataSocketBrush" GradientStops="{StaticResource ShapeDataSocketGradient}"/>
             <SolidColorBrush x:Key="EllipseVectorDataSocketBrush" Color="{StaticResource EllipseDataSocketColor}"/>
             <SolidColorBrush x:Key="PointsVectorDataSocketBrush" Color="{StaticResource PointsDataSocketColor}"/>
+            <SolidColorBrush x:Key="TextVectorDataSocketBrush" Color="{StaticResource TextDataSocketColor}"/>
             
             <!-- Zones & Frames -->
             <SolidColorBrush x:Key="PixiEditorModifyImageLeftBorderBrush" Color="{StaticResource PixiEditorModifyImageBorderColor}"/>

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


+ 48 - 28
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml

@@ -1,10 +1,10 @@
 <Styles xmlns="https://github.com/avaloniaui"
-                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-                    xmlns:system="clr-namespace:System;assembly=System.Runtime">
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:system="clr-namespace:System;assembly=System.Runtime">
     <Styles.Resources>
         <ResourceDictionary>
             <FontFamily x:Key="PixiPerfectIcons">avares://PixiEditor.UI.Common/Fonts/PixiPerfect.ttf#PixiPerfect</FontFamily>
-            
+
             <system:String x:Key="icon-add-reference">&#xE900;</system:String>
             <system:String x:Key="icon-add-to-mask">&#xE901;</system:String>
             <system:String x:Key="icon-alpha-lock">&#xE902;</system:String>
@@ -110,12 +110,13 @@
             <system:String x:Key="icon-zoom-out">&#xE962;</system:String>
             <system:String x:Key="icon-pen">&#xE971;</system:String>
             <system:String x:Key="icon-link">&#xE96B;</system:String>
+            <system:String x:Key="icon-upload">&#xE96D;</system:String>
             <system:String x:Key="icon-search">&#xE976;</system:String>
             <system:String x:Key="icon-hard-drive">&#xE96C;</system:String>
             <system:String x:Key="icon-copy-add">&#xe921;</system:String>
             <system:String x:Key="icon-check">&#xe96a;</system:String>
             <system:String x:Key="icon-dot">&#xe963;</system:String>
-            
+
             <system:String x:Key="icon-paste-reference-layer">&#xe977;</system:String>
             <system:String x:Key="icon-paste-as-new-layer">&#xe978;</system:String>
             <system:String x:Key="icon-star">&#xe97c;</system:String>
@@ -132,46 +133,65 @@
             <system:String x:Key="icon-swatches">&#xE982;</system:String>
             <system:String x:Key="icon-nodes">&#xe984;</system:String>
             <system:String x:Key="icon-onion">&#xe985;</system:String>
-            
+
             <system:String x:Key="icon-lowres-circle">&#xe986;</system:String>
             <system:String x:Key="icon-snapping">&#xe987;</system:String>
             <system:String x:Key="icon-lowres-square">&#xe988;</system:String>
             <system:String x:Key="icon-lowres-line">&#xe989;</system:String>
+
+            <system:String x:Key="icon-align-center">&#xE98C;</system:String>
+            <system:String x:Key="icon-bold">&#xE98D;</system:String>
+            <system:String x:Key="icon-text-antialiased">&#xE98E;</system:String>
+            <system:String x:Key="icon-italic">&#xE98F;</system:String>
+            <system:String x:Key="icon-align-stretch">&#xE990;</system:String>
+            <system:String x:Key="icon-align-left">&#xE991;</system:String>
+            <system:String x:Key="icon-letter-spacing">&#xE992;</system:String>
+            <system:String x:Key="icon-line-height">&#xE993;</system:String>
+            <system:String x:Key="icon-text-pixel">&#xE994;</system:String>
+            <system:String x:Key="icon-align-right">&#xE995;</system:String>
+            <system:String x:Key="icon-strikethrough">&#xE996;</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-round">&#xE999;</system:String>
         </ResourceDictionary>
     </Styles.Resources>
-    
+
     <Style Selector="TextBlock.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
     </Style>
-    
+
+    <Style Selector="Label.pixi-icon">
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
+    </Style>
+
     <Style Selector="Run.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
     </Style>
-    
+
     <Style Selector="Button.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
-        <Setter Property="Background" Value="Transparent"/>
-        <Setter Property="FontSize" Value="24"/>
-        <Setter Property="Padding" Value="0"/>
-        <Setter Property="HorizontalContentAlignment" Value="Center"/>
-        <Setter Property="VerticalContentAlignment" Value="Center"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
+        <Setter Property="Background" Value="Transparent" />
+        <Setter Property="FontSize" Value="24" />
+        <Setter Property="Padding" Value="0" />
+        <Setter Property="HorizontalContentAlignment" Value="Center" />
+        <Setter Property="VerticalContentAlignment" Value="Center" />
     </Style>
-    
+
     <Style Selector="Button.pixi-icon:pointerover">
-        <Setter Property="Background" Value="Transparent"/>
+        <Setter Property="Background" Value="Transparent" />
     </Style>
-    
+
     <Style Selector="ToggleButton.pixi-icon">
-        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}"/>
-        <Setter Property="BorderThickness" Value="0"/>
-        <Setter Property="Background" Value="Transparent"/>
-        <Setter Property="FontSize" Value="24"/>
-        <Setter Property="Padding" Value="0"/>
-        <Setter Property="HorizontalContentAlignment" Value="Center"/>
-        <Setter Property="VerticalContentAlignment" Value="Center"/>
+        <Setter Property="FontFamily" Value="{DynamicResource PixiPerfectIcons}" />
+        <Setter Property="BorderThickness" Value="0" />
+        <Setter Property="Background" Value="Transparent" />
+        <Setter Property="FontSize" Value="24" />
+        <Setter Property="Padding" Value="0" />
+        <Setter Property="HorizontalContentAlignment" Value="Center" />
+        <Setter Property="VerticalContentAlignment" Value="Center" />
     </Style>
-    
+
     <Style Selector="ToggleButton.pixi-icon:checked">
-        <Setter Property="Background" Value="{DynamicResource ThemeControlHighBrush}"/>
+        <Setter Property="Background" Value="{DynamicResource ThemeControlHighBrush}" />
     </Style>
 </Styles>

+ 16 - 1
src/PixiEditor.UI.Common/Fonts/PixiPerfectIcons.axaml.cs

@@ -128,7 +128,7 @@ public static class PixiPerfectIcons
     public const string Nodes = "\uE984";
     public const string Onion = "\uE965";
         
-    public const string Reset = "R"; // TODO: Create a reset icon
+    public const string Reset = "\uE98A";
     public const string ToggleLayerVisible = "\u25a1;"; // TODO: Create a toggle layer visible icon
     public const string ToggleMask = "\u25a1;"; // TODO: Create a toggle mask icon
     public const string Pen = "\uE971";
@@ -137,6 +137,21 @@ public static class PixiPerfectIcons
     public const string LowResSquare = "\uE988";
     public const string LowResLine = "\uE989";
 
+    public const string AlignCenter = "\uE98C";
+    public const string Bold = "\uE98D";
+    public const string TextAntialiased = "\uE98E";
+    public const string Italic = "\uE98F";
+    public const string AlignStretch = "\uE990";
+    public const string AlignLeft = "\uE991";
+    public const string LetterSpacing = "\uE992";
+    public const string LineHeight = "\uE993";
+    public const string TextPixel = "\uE994";
+    public const string AlignRight = "\uE995";
+    public const string Strikethrough = "\uE996";
+    public const string LinkedPipette = "\uE997";
+    public const string TextUnderline = "\uE998";
+    public const string TextRound = "\uE999";
+
     public static Stream GetFontStream()
     {
         return AssetLoader.Open(new Uri("avares://PixiEditor.UI.Common/Fonts/PixiPerfect.ttf"));

+ 26 - 3
src/PixiEditor/Data/Configs/ToolSetsConfig.json

@@ -19,6 +19,14 @@
       "RasterLine",
       "RasterEllipse",
       "RasterRectangle",
+      {
+        "ToolName": "Text",
+        "Settings": {
+          "AntiAliasing": false,
+          "ForceLowDpiRendering": true
+        },
+        "Icon": "\ue994"
+      },
       {
         "ToolName": "Eraser",
         "Settings": {
@@ -42,7 +50,7 @@
           "AntiAliasing": true,
           "ExposeHardness": true,
           "ExposeSpacing": true,
-          "BrushShapeSetting": "CircleSmooth" 
+          "BrushShapeSetting": "CircleSmooth"
         }
       },
       "Select",
@@ -78,7 +86,15 @@
         "Settings": {
           "AntiAliasing": true
         },
-        "Icon": "\uE953" 
+        "Icon": "\uE953"
+      },
+      {
+        "ToolName": "Text",
+        "Settings": {
+          "AntiAliasing": true,
+          "ForceLowDpiRendering": true
+        },
+        "Icon": "\ue98E"
       },
       {
         "ToolName": "Eraser",
@@ -102,7 +118,14 @@
       "VectorPath",
       "VectorLine",
       "VectorEllipse",
-      "VectorRectangle"
+      "VectorRectangle",
+      {
+        "ToolName": "Text",
+        "Settings": {
+          "AntiAliasing": true,
+          "ForceLowDpiRendering": false
+        }
+      }
     ]
   }
 ]

+ 12 - 0
src/PixiEditor/Data/Localization/Languages/en.json

@@ -837,5 +837,17 @@
   "COLOR_NODE": "Color",
   "CONVERT_TO_CURVE": "Convert to curve",
   "CONVERT_TO_CURVE_DESCRIPTIVE": "Convert selected vector layer to a curve/path",
+  "FONT_FILES": "Font Files",
+  "UNIT_PT": "pt",
+  "FONT_LABEL": "Family",
+  "FONT_SIZE_LABEL": "Size",
+  "SPACING_LABEL": "Spacing",
+  "TEXT_TOOL": "Text",
+  "MISSING_FONT": "Missing font",
+  "TEXT_LAYER_NAME": "Text",
+  "TEXT_TOOL_TOOLTIP": "Create text ({0}).",
+  "BOLD_TOOLTIP": "Bold",
+  "ITALIC_TOOLTIP": "Italic",
+  "CUSTOM_FONT": "Custom font",
   "DUMP_GPU_DIAGNOSTICS": "Dump GPU diagnostics"
 }

+ 18 - 0
src/PixiEditor/Helpers/Converters/FontFamilyNameToAvaloniaFontFamily.cs

@@ -0,0 +1,18 @@
+using System.Globalization;
+using Avalonia.Media;
+using Drawie.Backend.Core.Text;
+
+namespace PixiEditor.Helpers.Converters;
+
+internal class FontFamilyNameToAvaloniaFontFamily : SingleInstanceConverter<FontFamilyNameToAvaloniaFontFamily>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+    {
+        if (value is FontFamilyName familyName)
+        {
+            return new FontFamily(familyName.Name);
+        }
+
+        return value;
+    }
+}

+ 7 - 0
src/PixiEditor/Helpers/DocumentViewModelBuilder.cs

@@ -35,6 +35,7 @@ internal class DocumentViewModelBuilder
     public string ImageEncoderUsed { get; set; } = "QOI";
     public bool UsesLegacyColorBlending { get; set; } = false;
     public Version? PixiParserVersionUsed { get; set; }
+    public ResourceStorage DocumentResources { get; set; }
 
     public DocumentViewModelBuilder WithSize(int width, int height)
     {
@@ -244,6 +245,12 @@ internal class DocumentViewModelBuilder
         PixiParserVersionUsed = version;
         return this;
     }
+
+    public DocumentViewModelBuilder WithResources(ResourceStorage documentResources)
+    {
+        DocumentResources = documentResources;
+        return this;
+    }
 }
 
 internal class AnimationDataBuilder

+ 3 - 1
src/PixiEditor/Helpers/Extensions/PixiParserDocumentEx.cs

@@ -1,6 +1,7 @@
 using Drawie.Backend.Core;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using Drawie.Numerics;
+using PixiEditor.Models.IO;
 using PixiEditor.Parser;
 using PixiEditor.Parser.Graph;
 using PixiEditor.Parser.Skia;
@@ -38,7 +39,8 @@ internal static class PixiParserDocumentEx
             .WithSwatches(document.Swatches, x => new(x.R, x.G, x.B))
             .WithReferenceLayer(document.ReferenceLayer, BuildReferenceLayer, encoder)
             .WithGraph(document.Graph, BuildGraph)
-            .WithAnimationData(document.AnimationData, document.Graph));
+            .WithAnimationData(document.AnimationData, document.Graph)
+            .WithResources(document.Resources));
     }
 
     private static void BuildGraph(NodeGraph graph, NodeGraphBuilder graphBuilder)

+ 4 - 0
src/PixiEditor/Helpers/ServiceCollectionHelpers.cs

@@ -100,6 +100,7 @@ internal static class ServiceCollectionHelpers
             .AddTool<IVectorRectangleToolHandler, VectorRectangleToolViewModel>()
             .AddTool<IVectorLineToolHandler, VectorLineToolViewModel>()
             .AddTool<IVectorPathToolHandler, VectorPathToolViewModel>()
+            .AddTool<ITextToolHandler, TextToolViewModel>()
             .AddTool<ZoomToolViewModel>()
             // File types
             .AddSingleton<IoFileType, PixiFileType>()
@@ -109,10 +110,13 @@ internal static class ServiceCollectionHelpers
             .AddSingleton<IoFileType, GifFileType>()
             .AddSingleton<IoFileType, Mp4FileType>()
             .AddSingleton<IoFileType, SvgFileType>()
+            .AddSingleton<IoFileType, TtfFileType>()
+            .AddSingleton<IoFileType, OtfFileType>()
             // Serialization Factories
             .AddAssemblyTypes<SerializationFactory>()
             // Custom document builders
             .AddSingleton<IDocumentBuilder, SvgDocumentBuilder>()
+            .AddSingleton<IDocumentBuilder, FontDocumentBuilder>()
             // Palette Parsers
             .AddSingleton<IPalettesProvider, PaletteProvider>()
             .AddSingleton<PaletteFileParser, JascFileParser>()

+ 2 - 2
src/PixiEditor/Helpers/SupportedFilesHelper.cs

@@ -70,7 +70,7 @@ internal class SupportedFilesHelper
 
     public static List<FilePickerFileType> BuildSaveFilter(FileTypeDialogDataSet.SetKind setKind = FileTypeDialogDataSet.SetKind.Any)
     {
-        var allSupportedExtensions = GetAllSupportedFileTypes(setKind);
+        var allSupportedExtensions = GetAllSupportedFileTypes(setKind).Where(x => x.CanSave).ToList();
         var filter = allSupportedExtensions.Select(i => i.SaveFilter).ToList();
 
         return filter;
@@ -84,7 +84,7 @@ internal class SupportedFilesHelper
             return null;
 
         string extension = Path.GetExtension(file.Path.LocalPath);
-        return allSupportedExtensions.Single(i => i.Extensions.Contains(extension));
+        return allSupportedExtensions.Single(i => i.CanSave && i.Extensions.Contains(extension));
     }
 
     public static List<FilePickerFileType> BuildOpenFilter()

+ 8 - 0
src/PixiEditor/Helpers/ThemeResources.cs

@@ -24,4 +24,12 @@ public static class ThemeResources
     public static Color BorderMidColor =>
         ResourceLoader.GetResource<SolidColorBrush>("ThemeBorderMidBrush", Application.Current.ActualThemeVariant).Color
             .ToColor();
+
+    public static Color ThemeControlHighlightColor =>
+        ResourceLoader.GetResource<Avalonia.Media.Color>("ThemeControlHighlightColor", Application.Current.ActualThemeVariant)
+            .ToColor();
+
+    public static Color SelectionFillColor =>
+        ResourceLoader.GetResource<Avalonia.Media.Color>("SelectionFillColor", Application.Current.ActualThemeVariant)
+            .ToColor();
 }

+ 18 - 3
src/PixiEditor/Models/Controllers/ClipboardController.cs

@@ -161,6 +161,11 @@ internal static class ClipboardController
 
         await Clipboard.SetDataObjectAsync(data);
     }
+    
+    public static async Task<string> GetTextFromClipboard()
+    {
+        return await Clipboard.GetTextAsync();
+    }
 
     private static async Task AddImageToClipboard(Surface actuallySurface, DataObject data)
     {
@@ -257,16 +262,26 @@ internal static class ClipboardController
             pos = dataObjectWithPos.GetVecD(ClipboardDataFormats.PositionFormat);
         }
 
+        RectD? tightBounds = null;
         for (var i = 0; i < layerIds.Length; i++)
         {
             var layerId = layerIds[i];
 
             var layer = doc.StructureHelper.Find(layerId);
-            if (layer is not { TightBounds: not null } || !layer.TightBounds.Value.Pos.AlmostEquals(pos))
-                return false;
+
+            if(layer == null) return false;
+
+            if(tightBounds == null)
+            {
+                tightBounds = layer.TightBounds;
+            }
+            else if(layer.TightBounds.HasValue)
+            {
+                tightBounds = tightBounds.Value.Union(layer.TightBounds.Value);
+            }
         }
 
-        return true;
+        return tightBounds.HasValue && tightBounds.Value.Pos.AlmostEquals(pos);
     }
 
     private static Guid[] GetLayerIds(IEnumerable<IDataObject> data)

+ 44 - 0
src/PixiEditor/Models/Controllers/FontLibrary.cs

@@ -0,0 +1,44 @@
+using System.Collections.ObjectModel;
+using Avalonia.Media;
+using Drawie.Backend.Core.Text;
+
+namespace PixiEditor.Models.Controllers;
+
+public static class FontLibrary
+{
+    private static List<FontFamilyName> _customFonts = new List<FontFamilyName>();
+    private static List<FontFamilyName> _allFonts = new List<FontFamilyName>();
+
+    public static FontFamilyName DefaultFontFamily { get; } = new FontFamilyName("Arial");
+
+    public static FontFamilyName[] SystemFonts { get; } = FontManager.Current.SystemFonts.Select(x => new FontFamilyName(x.Name)).ToArray();
+    
+    public static IReadOnlyList<FontFamilyName> CustomFonts => _customFonts;
+
+    public static FontFamilyName[] AllFonts
+    {
+        get
+        {
+            if (_allFonts.Count != SystemFonts.Length + CustomFonts.Count)
+            {
+                _allFonts = SystemFonts.Concat(CustomFonts).ToList();
+            }
+
+            return _allFonts.ToArray();
+        }
+    }
+
+    public static event Action<FontFamilyName> FontAdded;
+
+    public static bool TryAddCustomFont(FontFamilyName fontFamily)
+    {
+        if (!CustomFonts.Any(x => x.Name == fontFamily.Name && x.FontUri == fontFamily.FontUri))
+        {
+            _customFonts.Add(fontFamily);
+            FontAdded?.Invoke(fontFamily);
+            return true;
+        }
+        
+        return false;
+    }
+}

+ 3 - 3
src/PixiEditor/Models/Controllers/InputDevice/MouseInputFilter.cs

@@ -51,13 +51,13 @@ internal class MouseInputFilter
 
     public void DeactivatedInlet(object? sender, EventArgs e)
     {
-        MouseOnCanvasEventArgs argsLeft = new(MouseButton.Left, VecD.Zero, KeyModifiers.None);
+        MouseOnCanvasEventArgs argsLeft = new(MouseButton.Left, VecD.Zero, KeyModifiers.None, 0);
         MouseUpInlet(argsLeft);
         
-        MouseOnCanvasEventArgs argsMiddle = new(MouseButton.Middle, VecD.Zero, KeyModifiers.None);
+        MouseOnCanvasEventArgs argsMiddle = new(MouseButton.Middle, VecD.Zero, KeyModifiers.None, 0);
         MouseUpInlet(argsMiddle);
         
-        MouseOnCanvasEventArgs argsRight = new(MouseButton.Right, VecD.Zero, KeyModifiers.None);
+        MouseOnCanvasEventArgs argsRight = new(MouseButton.Right, VecD.Zero, KeyModifiers.None, 0);
         MouseUpInlet(argsRight);
     }
 }

+ 10 - 6
src/PixiEditor/Models/Controllers/InputDevice/MouseOnCanvasEventArgs.cs

@@ -3,17 +3,21 @@ using Drawie.Backend.Core.Numerics;
 using Drawie.Numerics;
 
 namespace PixiEditor.Models.Controllers.InputDevice;
+
 internal class MouseOnCanvasEventArgs : EventArgs
 {
-    public MouseOnCanvasEventArgs(MouseButton button, VecD positionOnCanvas, KeyModifiers keyModifiers)
+    public MouseButton Button { get; }
+    public VecD PositionOnCanvas { get; }
+    public KeyModifiers KeyModifiers { get; }
+    public bool Handled { get; set; }
+    
+    public int ClickCount { get; set; } = 1;
+
+    public MouseOnCanvasEventArgs(MouseButton button, VecD positionOnCanvas, KeyModifiers keyModifiers, int clickCount)
     {
         Button = button;
         PositionOnCanvas = positionOnCanvas;
         KeyModifiers = keyModifiers;
+        ClickCount = clickCount;
     }
-
-    public MouseButton Button { get; }
-    public VecD PositionOnCanvas { get; }
-    public KeyModifiers KeyModifiers { get; }
-    public bool Handled { get; set; }
 }

+ 3 - 3
src/PixiEditor/Models/Controllers/ShortcutController.cs

@@ -38,14 +38,14 @@ internal class ShortcutController
         _shortcutExecutionBlockers.Clear();
     }
 
-    public KeyCombination GetToolShortcut<T>()
+    public KeyCombination? GetToolShortcut<T>()
     {
         return GetToolShortcut(typeof(T));
     }
 
-    public KeyCombination GetToolShortcut(Type type)
+    public KeyCombination? GetToolShortcut(Type type)
     {
-        return CommandController.Current.Commands.First(x => x is Command.ToolCommand tool && tool.ToolType == type).Shortcut;
+        return CommandController.Current.Commands.FirstOrDefault(x => x is Command.ToolCommand tool && tool.ToolType == type)?.Shortcut;
     }
 
     public void KeyPressed(bool isRepeat, Key key, KeyModifiers modifiers)

+ 16 - 0
src/PixiEditor/Models/DocumentModels/ChangeExecutionController.cs

@@ -305,4 +305,20 @@ internal class ChangeExecutionController
             vectorPathToolExecutor.OnPathChanged(path);
         }
     }
+
+    public void TextOverlayTextChangedInlet(string text)
+    {
+        if (currentSession is ITextOverlayEvents textOverlayHandler)
+        {
+            textOverlayHandler.OnTextChanged(text);
+        }
+    }
+
+    public void QuickToolSwitchInlet()
+    {
+        if (currentSession is IQuickToolSwitchable quickToolSwitchable)
+        {
+            quickToolSwitchable.OnQuickToolSwitch();
+        }
+    }
 }

+ 2 - 0
src/PixiEditor/Models/DocumentModels/Public/DocumentEventsModule.cs

@@ -51,4 +51,6 @@ internal class DocumentEventsModule
     public void OnSymmetryDragStarted(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragStartedInlet(dir);
     public void OnSymmetryDragged(SymmetryAxisDragInfo info) => Internals.ChangeController.SymmetryDraggedInlet(info);
     public void OnSymmetryDragEnded(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragEndedInlet(dir);
+
+    public void QuickToolSwitchInlet() => Internals.ChangeController.QuickToolSwitchInlet();
 }

+ 6 - 0
src/PixiEditor/Models/DocumentModels/Public/DocumentToolsModule.cs

@@ -79,4 +79,10 @@ internal class DocumentToolsModule
     public void UseLassoTool() => Internals.ChangeController.TryStartExecutor<LassoToolExecutor>();
 
     public void UseMagicWandTool() => Internals.ChangeController.TryStartExecutor<MagicWandToolExecutor>();
+
+    public void UseTextTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<VectorTextToolExecutor>(force);
+    }
 }

+ 6 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/IQuickToolSwitchable.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+
+internal interface IQuickToolSwitchable : IExecutorFeature
+{
+    public void OnQuickToolSwitch();
+}

+ 6 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/Features/ITextOverlayEvents.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+
+public interface ITextOverlayEvents :IExecutorFeature
+{
+    public void OnTextChanged(string text);
+}

+ 22 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedExecutor.cs

@@ -16,6 +16,7 @@ using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.Models.Controllers.InputDevice;
 using PixiEditor.Models.DocumentPassthroughActions;
+using PixiEditor.ViewModels;
 using PixiEditor.ViewModels.Document.Nodes;
 
 namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
@@ -142,6 +143,15 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
     public override void OnLeftMouseButtonDown(MouseOnCanvasEventArgs args)
     {
         args.Handled = true;
+
+        if (args.ClickCount >= 2)
+        {
+            if (SwitchToLayerTool())
+            {
+                return;
+            }
+        }
+
         var allLayers = document.StructureHelper.GetAllLayers();
         var topMostWithinClick = allLayers.Where(x =>
                 x is { IsVisibleBindable: true, TightBounds: not null } &&
@@ -178,6 +188,18 @@ internal class TransformSelectedExecutor : UpdateableChangeExecutor, ITransforma
         }
     }
 
+    private bool SwitchToLayerTool()
+    {
+        if (document.SelectedStructureMember is ILayerHandler layerHandler && layerHandler.QuickEditTool != null)
+        {
+            ViewModelMain.Current.ToolsSubViewModel.SetActiveTool(layerHandler.QuickEditTool, false);
+            ViewModelMain.Current.ToolsSubViewModel.QuickToolSwitchInlet();
+            return true;
+        }
+
+        return false;
+    }
+
     public void OnTransformStopped()
     {
         DuplicateIfRequired();

+ 232 - 0
src/PixiEditor/Models/DocumentModels/UpdateableChangeExecutors/VectorTextToolExecutor.cs

@@ -0,0 +1,232 @@
+using Avalonia.Input;
+using Avalonia.Threading;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Controllers.InputDevice;
+using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors.Features;
+using PixiEditor.Models.Handlers;
+using PixiEditor.Models.Handlers.Toolbars;
+using PixiEditor.Models.Handlers.Tools;
+using PixiEditor.Models.Tools;
+using PixiEditor.ViewModels;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+
+internal class VectorTextToolExecutor : UpdateableChangeExecutor, ITextOverlayEvents, IQuickToolSwitchable
+{
+    private ITextToolHandler textHandler;
+    private ITextToolbar toolbar;
+    private IStructureMemberHandler selectedMember;
+
+    private string lastText = "";
+    private VecD position;
+    private Matrix3X3 lastMatrix = Matrix3X3.Identity;
+    private Font? cachedFont;
+    private bool isListeningForValidLayer;
+
+    public override bool BlocksOtherActions => false;
+
+    public override ExecutorType Type => ExecutorType.ToolLinked;
+
+    public override ExecutionState Start()
+    {
+        textHandler = GetHandler<ITextToolHandler>();
+        if (textHandler == null)
+        {
+            return ExecutionState.Error;
+        }
+
+        toolbar = textHandler.Toolbar as ITextToolbar;
+        if (toolbar == null)
+        {
+            return ExecutionState.Error;
+        }
+
+        selectedMember = document.SelectedStructureMember;
+
+        if (selectedMember is not IVectorLayerHandler layerHandler)
+        {
+            isListeningForValidLayer = true;
+            return ExecutionState.Success;
+        }
+
+        isListeningForValidLayer = false;
+        var shape = layerHandler.GetShapeData(document.AnimationHandler.ActiveFrameBindable);
+        if (shape is TextVectorData textData)
+        {
+            document.TextOverlayHandler.Show(textData.Text, textData.Position, textData.Font,
+                textData.TransformationMatrix, textData.Spacing);
+
+            toolbar.Fill = textData.Fill;
+            toolbar.FillColor = textData.FillColor.ToColor();
+            toolbar.StrokeColor = textData.StrokeColor.ToColor();
+            toolbar.ToolSize = textData.StrokeWidth;
+            toolbar.FontFamily = textData.Font.Family;
+            toolbar.FontSize = textData.Font.Size;
+            toolbar.Spacing = textData.Spacing ?? textData.Font.Size;
+            toolbar.Bold = textData.Font.Bold;
+            toolbar.Italic = textData.Font.Italic;
+
+            lastText = textData.Text;
+            position = textData.Position;
+            lastMatrix = textData.TransformationMatrix;
+        }
+        else if (shape is null)
+        {
+            document.TextOverlayHandler.Show("", controller.LastPrecisePosition, toolbar.ConstructFont(),
+                Matrix3X3.Identity, toolbar.Spacing);
+            lastText = "";
+            position = controller.LastPrecisePosition;
+        }
+        else
+        {
+            return ExecutionState.Error;
+        }
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnLeftMouseButtonDown(MouseOnCanvasEventArgs args)
+    {
+        var allLayers = document.StructureHelper.GetAllLayers();
+        var topMostWithinClick = allLayers.Where(x =>
+                x is IVectorLayerHandler { IsVisibleBindable: true, TightBounds: not null } &&
+                x.TightBounds.Value.ContainsInclusive(args.PositionOnCanvas))
+            .OrderByDescending(x => allLayers.IndexOf(x));
+
+        var firstLayer = topMostWithinClick.FirstOrDefault();
+        args.Handled = firstLayer != null;
+        if (firstLayer is not IVectorLayerHandler layerHandler)
+        {
+            if (document.TextOverlayHandler.IsActive)
+            {
+                args.Handled = true;
+                document.TextOverlayHandler.Hide();
+            }
+            return;
+        }
+
+        document.Operations.SetSelectedMember(layerHandler.Id);
+        document.Operations.InvokeCustomAction(
+            () =>
+        {
+            document.TextOverlayHandler.SetCursorPosition(args.PositionOnCanvas);
+        }, false);
+    }
+
+    public void OnQuickToolSwitch()
+    {
+        document.TextOverlayHandler.SetCursorPosition(internals.ChangeController.LastPrecisePosition);
+    }
+
+    public override void ForceStop()
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndSetShapeGeometry_Action());
+        document.TextOverlayHandler.Hide();
+    }
+
+    public void OnTextChanged(string text)
+    {
+        var constructedText = ConstructTextData(text);
+        internals.ActionAccumulator.AddFinishedActions(
+            new SetShapeGeometry_Action(selectedMember.Id, constructedText),
+            new EndSetShapeGeometry_Action(),
+            new SetLowDpiRendering_Action(selectedMember.Id, toolbar.ForceLowDpiRendering));
+        lastText = text;
+        document.TextOverlayHandler.Font = constructedText.Font;
+    }
+
+    public override void OnSettingsChanged(string name, object value)
+    {
+        if (isListeningForValidLayer)
+        {
+            return;
+        }
+
+        if (name == nameof(ITextToolbar.FontFamily))
+        {
+            Font toDispose = cachedFont;
+            Dispatcher.UIThread.Post(() =>
+            {
+                toDispose?.Dispose();
+            });
+
+            cachedFont = toolbar.ConstructFont();
+            document.TextOverlayHandler.Font = cachedFont;
+        }
+        else
+        {
+            if (cachedFont == null)
+            {
+                cachedFont = toolbar.ConstructFont();
+            }
+
+            document.TextOverlayHandler.Font.Size = toolbar.FontSize;
+            cachedFont.Size = toolbar.FontSize;
+            cachedFont.Bold = toolbar.Bold;
+            cachedFont.Italic = toolbar.Italic;
+        }
+
+        var constructedText = ConstructTextData(lastText);
+        internals.ActionAccumulator.AddActions(
+            new SetShapeGeometry_Action(selectedMember.Id, constructedText),
+            new SetLowDpiRendering_Action(selectedMember.Id, toolbar.ForceLowDpiRendering));
+
+        document.TextOverlayHandler.Font = constructedText.Font;
+        document.TextOverlayHandler.Spacing = toolbar.Spacing;
+    }
+
+    public override void OnColorChanged(Color color, bool primary)
+    {
+        if (!primary || !toolbar.SyncWithPrimaryColor)
+        {
+            return;
+        }
+
+        toolbar.StrokeColor = color.ToColor();
+        toolbar.FillColor = color.ToColor();
+    }
+
+    private TextVectorData ConstructTextData(string text)
+    {
+        if (cachedFont == null || cachedFont.Family.Name != toolbar.FontFamily.Name)
+        {
+            Font toDispose = cachedFont;
+            Dispatcher.UIThread.Post(() =>
+            {
+                toDispose?.Dispose();
+            });
+            cachedFont = toolbar.ConstructFont();
+        }
+        else
+        {
+            cachedFont.Size = toolbar.FontSize;
+        }
+
+        return new TextVectorData()
+        {
+            Text = text,
+            Position = position,
+            Fill = toolbar.Fill,
+            FillColor = toolbar.FillColor.ToColor(),
+            StrokeWidth = (float)toolbar.ToolSize,
+            StrokeColor = toolbar.StrokeColor.ToColor(),
+            TransformationMatrix = lastMatrix,
+            Font = cachedFont,
+            Spacing = toolbar.Spacing,
+            AntiAlias = toolbar.AntiAliasing,
+            // TODO: MaxWidth = toolbar.MaxWidth
+            // TODO: Path
+        };
+    }
+
+    bool IExecutorFeature.IsFeatureEnabled(IExecutorFeature feature)
+    {
+        return feature is ITextOverlayEvents || feature is IQuickToolSwitchable;
+    }
+}

+ 2 - 0
src/PixiEditor/Models/Files/IoFileType.cs

@@ -39,6 +39,8 @@ internal abstract class IoFileType
     {
         get { return Extensions.Select(GetExtensionFormattedForDialog).ToList(); }
     }
+
+    public virtual bool CanSave => true;
     
     string GetExtensionFormattedForDialog(string extension)
     {

+ 18 - 0
src/PixiEditor/Models/Files/OtfFileType.cs

@@ -0,0 +1,18 @@
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.Files;
+
+internal class OtfFileType : IoFileType
+{
+    public override string[] Extensions { get; } = new[] { ".otf" };
+    public override string DisplayName { get; } = "OpenType Font";
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
+
+    public override bool CanSave => false;
+
+    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving OTF files is not supported.");
+    }
+}

+ 18 - 0
src/PixiEditor/Models/Files/TtfFileType.cs

@@ -0,0 +1,18 @@
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.Document;
+
+namespace PixiEditor.Models.Files;
+
+internal class TtfFileType : IoFileType
+{
+    public override string[] Extensions { get; } = new[] { ".ttf" };
+    public override string DisplayName { get; } = "TrueType Font";
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
+
+    public override bool CanSave => false;
+
+    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    {
+        throw new NotSupportedException("Saving TTF files is not supported.");
+    }
+}

+ 5 - 1
src/PixiEditor/Models/Handlers/IDocument.cs

@@ -36,6 +36,7 @@ internal interface IDocument : IHandler
     public IReadOnlyCollection<IStructureMemberHandler> SoftSelectedStructureMembers { get; }
     public ITransformHandler TransformHandler { get; }
     public IPathOverlayHandler PathOverlayHandler { get; }
+    public ITextOverlayHandler TextOverlayHandler { get; }
     public bool Busy { get; set; }
     public ILineOverlayHandler LineToolOverlayHandler { get; }
     public bool HorizontalSymmetryAxisEnabledBindable { get; }
@@ -57,7 +58,10 @@ internal interface IDocument : IHandler
     public void UpdateSelectionPath(VectorPath infoNewPath);
     public void SetProcessingColorSpace(ColorSpace infoColorSpace);
     public void SetSize(VecI infoSize);
-    public Color PickColor(VecD controllerLastPrecisePosition, DocumentScope scope, bool includeReference, bool includeCanvas, int frame, bool isTopMost);
+
+    public Color PickColor(VecD controllerLastPrecisePosition, DocumentScope scope, bool includeReference,
+        bool includeCanvas, int frame, bool isTopMost);
+
     public HashSet<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false);
     public void UpdateSavedState();
 

+ 1 - 0
src/PixiEditor/Models/Handlers/IDocumentOperations.cs

@@ -12,4 +12,5 @@ internal interface IDocumentOperations
     public void SetSelectedMember(Guid memberId);
     public void ClearSoftSelectedMembers();
     public Guid? CreateStructureMember(Type type, ActionSource source, string? name = null);
+    public void InvokeCustomAction(Action action, bool stopActiveExecutor = true);
 }

+ 1 - 0
src/PixiEditor/Models/Handlers/ILayerHandler.cs

@@ -3,4 +3,5 @@
 internal interface ILayerHandler : IStructureMemberHandler
 {
     public bool ShouldDrawOnMask { get; set; }
+    public Type? QuickEditTool { get; }
 }

+ 16 - 0
src/PixiEditor/Models/Handlers/ITextOverlayHandler.cs

@@ -0,0 +1,16 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+
+namespace PixiEditor.Models.Handlers;
+
+public interface ITextOverlayHandler : IHandler
+{
+    public void Show(string text, VecD position, Font font, Matrix3X3 matrix, double? spacing = null);
+    public void Hide();
+    public Font Font { get; set; }
+    public VecD Position { get; set; }
+    public double? Spacing { get; set; }
+    public bool IsActive { get; }
+    public void SetCursorPosition(VecD closestToPosition);
+}

+ 3 - 2
src/PixiEditor/Models/Handlers/IToolHandler.cs

@@ -63,9 +63,10 @@ internal interface IToolHandler : IHandler
     public void SetToolSetSettings(IToolSetHandler toolset, Dictionary<string, object>? settings);
     public void ApplyToolSetSettings(IToolSetHandler toolset);
     public void OnToolDeselected(bool transient);
-    public void OnPostUndo();
-    public void OnPostRedo();
+    public void OnPostUndoInlet();
+    public void OnPostRedoInlet();
     public void OnActiveFrameChanged(int newFrame);
     public void OnPreUndoInlet();
     public void OnPreRedoInlet();
+    public void QuickToolSwitchInlet();
 }

+ 1 - 0
src/PixiEditor/Models/Handlers/IToolsHandler.cs

@@ -35,4 +35,5 @@ internal interface IToolsHandler : IHandler
     public void OnPostRedoInlet();
     public void OnPreRedoInlet();
     public void OnPreUndoInlet();
+    public void QuickToolSwitchInlet();
 }

+ 15 - 0
src/PixiEditor/Models/Handlers/Toolbars/ITextToolbar.cs

@@ -0,0 +1,15 @@
+using Drawie.Backend.Core.Text;
+
+namespace PixiEditor.Models.Handlers.Toolbars;
+
+internal interface ITextToolbar : IFillableShapeToolbar
+{
+    public double FontSize { get; set; }
+    public FontFamilyName FontFamily { get; set; }
+    public double Spacing { get; set; }
+    public bool ForceLowDpiRendering { get; set; }
+    public bool Bold { get; set; }
+    public bool Italic { get; set; }
+
+    public Font ConstructFont();
+}

+ 6 - 0
src/PixiEditor/Models/Handlers/Tools/ITextToolHandler.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.Handlers.Tools;
+
+internal interface ITextToolHandler : IToolHandler
+{
+    
+}

+ 66 - 0
src/PixiEditor/Models/IO/CustomDocumentFormats/FontDocumentBuilder.cs

@@ -0,0 +1,66 @@
+using System.Text;
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Controllers;
+
+namespace PixiEditor.Models.IO.CustomDocumentFormats;
+
+internal class FontDocumentBuilder : IDocumentBuilder
+{
+    public IReadOnlyCollection<string> Extensions { get; } = [".ttf", ".otf"];
+
+    public void Build(DocumentViewModelBuilder builder, string path)
+    {
+        Font font = Font.FromFontFamily(new FontFamilyName(new Uri(path), Path.GetFileNameWithoutExtension(path)));
+        font.Size = 12;
+
+        List<char> glyphs = new();
+        int lastGlyph = 0;
+        for (int i = 0; i < char.MaxValue; i++)
+        {
+            if (font.ContainsGlyph(i) && font.MeasureText(((char)i).ToString()) > 0)
+            {
+                lastGlyph++;
+                glyphs.Add((char)i);
+                if (lastGlyph >= font.GlyphCount - 1)
+                {
+                    break;
+                }
+            }
+        }
+
+        int rows = (int)Math.Ceiling(Math.Sqrt(glyphs.Count));
+        int cols = (int)Math.Ceiling((double)glyphs.Count / rows);
+
+        StringBuilder sb = new();
+        for (int i = 0; i < glyphs.Count; i++)
+        {
+            sb.Append(glyphs[i]);
+            if (i % cols == cols - 1)
+            {
+                sb.Append('\n');
+            }
+        }
+
+        TextVectorData textData = new() { Text = sb.ToString(), Font = font, StrokeWidth = 0, Spacing = 12 };
+        RectD bounds = textData.GeometryAABB;
+
+        const int padding = 1;
+
+        FontLibrary.TryAddCustomFont(font.Family);
+
+        textData.Position = new VecD(0, font.Size);
+
+        builder.WithSize((int)Math.Ceiling(bounds.Width) + padding, (int)Math.Ceiling(bounds.Height) + padding)
+            .WithGraph(graph =>
+            {
+                graph.WithNodeOfType<VectorLayerNode>(out int id)
+                    .WithName(font.Family.Name)
+                    .WithAdditionalData(new Dictionary<string, object>() { { "ShapeData", textData } });
+                graph.WithOutputNode(id, "Output");
+            });
+    }
+}

+ 1 - 0
src/PixiEditor/Models/IO/Paths.cs

@@ -24,6 +24,7 @@ public static class Paths
     public static string TempRenderingPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor", "Rendering");
     
     public static string TempFilesPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor");
+    public static string TempResourcesPath { get; } = Path.Combine(Path.GetTempPath(), "PixiEditor", "Resources");
 
     public static string ParseSpecialPathOrDefault(string path)
     {

+ 4 - 0
src/PixiEditor/Models/Serialization/Factories/SerializationFactory.cs

@@ -1,4 +1,6 @@
 using MessagePack;
+using PixiEditor.Parser;
+using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Models.Serialization.Factories;
 
@@ -7,6 +9,8 @@ public abstract class SerializationFactory
     public abstract Type OriginalType { get; }
     public abstract string DeserializationId { get; } 
     public SerializationConfig Config { get; set; }
+    public ResourceStorage Storage { get; set; }
+    public ResourceStorageLocator? ResourceLocator { get; set; }
 
     public abstract object Serialize(object original);
     public abstract object Deserialize(object rawData, (string serializerName, string serializerVersion) serializerData);

+ 112 - 0
src/PixiEditor/Models/Serialization/Factories/TextSerializationFactory.cs

@@ -0,0 +1,112 @@
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.Models.Serialization.Factories;
+
+internal class TextSerializationFactory : VectorShapeSerializationFactory<TextVectorData>
+{
+    public override string DeserializationId { get; } = "PixiEditor.Text";
+
+    protected override void AddSpecificData(ByteBuilder builder, TextVectorData original)
+    {
+        builder.AddString(original.Text);
+        builder.AddVecD(original.Position);
+        builder.AddBool(original.AntiAlias);
+        builder.AddString(original.Font.Family.Name);
+        builder.AddBool(original.Font.Family.FontUri?.IsFile ?? false);
+        if (original.Font.Family.FontUri?.IsFile ?? false)
+        {
+            builder.AddInt(Storage.AddFromFilePath(original.Font.Family.FontUri.LocalPath));
+        }
+
+        builder.AddDouble(original.Font.Size);
+        builder.AddBool(original.Font.Bold);
+        builder.AddBool(original.Font.Italic);
+
+        builder.AddDouble(original.MaxWidth);
+        builder.AddDouble(original.Spacing ?? original.Font.Size);
+        builder.AddBool(original.Path != null);
+        if (original.Path != null)
+        {
+            builder.AddString(original.Path.ToSvgPathData());
+        }
+    }
+
+    protected override bool DeserializeVectorData(ByteExtractor extractor, Matrix3X3 matrix, Color strokeColor,
+        bool fill, Color fillColor,
+        float strokeWidth, (string serializerName, string serializerVersion) serializerData,
+        out TextVectorData original)
+    {
+        string text = extractor.GetString();
+        VecD position = extractor.GetVecD();
+        bool antiAlias = extractor.GetBool();
+        string fontFamily = extractor.GetString();
+        bool isFontFromFile = extractor.GetBool();
+        string fontPath = null;
+        if (isFontFromFile && ResourceLocator != null)
+        {
+            fontPath = Path.Combine(ResourceLocator.GetFilePath(extractor.GetInt()));
+        }
+
+        double fontSize = extractor.GetDouble();
+        bool bold = extractor.GetBool();
+        bool italic = extractor.GetBool();
+
+        double maxWidth = extractor.GetDouble();
+        double spacing = extractor.GetDouble();
+        bool hasPath = extractor.GetBool();
+        VectorPath path = null;
+        if (hasPath)
+        {
+            path = VectorPath.FromSvgPath(extractor.GetString());
+        }
+
+        FontFamilyName family =
+            new FontFamilyName(fontFamily) { FontUri = isFontFromFile ? new Uri(fontPath, UriKind.Absolute) : null };
+        Font font = Font.FromFontFamily(family);
+        FontFamilyName? missingFamily = null;
+
+        if (font == null)
+        {
+            font = Font.CreateDefault();
+            missingFamily = family;
+        }
+        else if (isFontFromFile)
+        {
+            FontLibrary.TryAddCustomFont(family);
+        }
+
+
+        font.Bold = bold;
+        font.Italic = italic;
+        font.Edging = antiAlias ? FontEdging.AntiAlias : FontEdging.Alias;
+        font.SubPixel = antiAlias;
+        font.Size = fontSize;
+
+        original = new TextVectorData(text)
+        {
+            TransformationMatrix = matrix,
+            StrokeColor = strokeColor,
+            Fill = fill,
+            FillColor = fillColor,
+            StrokeWidth = strokeWidth,
+            Position = position,
+            Font = font,
+            MaxWidth = maxWidth,
+            Spacing = spacing,
+            Path = path,
+            MissingFontFamily = missingFamily,
+            MissingFontText = new LocalizedString("MISSING_FONT"),
+            AntiAlias = antiAlias
+        };
+
+        return true;
+    }
+}

+ 4 - 0
src/PixiEditor/PixiEditor.csproj

@@ -139,6 +139,10 @@
       <DependentUpon>PercentSettingView.axaml</DependentUpon>
       <SubType>Code</SubType>
     </Compile>
+    <Compile Update="Views\Tools\ToolSettings\Settings\FontFamilySettingView.axaml.cs">
+      <DependentUpon>FontFamilySettingView.axaml</DependentUpon>
+      <SubType>Code</SubType>
+    </Compile>
   </ItemGroup>
 
   <ItemGroup>

+ 17 - 5
src/PixiEditor/ViewModels/Document/DocumentViewModel.Serialization.cs

@@ -59,9 +59,16 @@ internal partial class DocumentViewModel
         Dictionary<Guid, int> nodeIdMap = new();
         Dictionary<Guid, int> keyFrameIdMap = new();
 
+        ResourceStorage storage = new ResourceStorage();
+
         List<SerializationFactory> factories =
             ViewModelMain.Current.Services.GetServices<SerializationFactory>().ToList();
 
+        foreach (var factory in factories)
+        {
+            factory.Storage = storage;
+        }
+
         AddNodes(doc.NodeGraph, graph, nodeIdMap, keyFrameIdMap, serializationConfig, factories);
 
         var document = new PixiDocument
@@ -78,8 +85,13 @@ internal partial class DocumentViewModel
                 (TryRenderWholeImage(0).Value as Surface)?.DrawingSurface.Snapshot().Encode().AsSpan().ToArray(),
             ReferenceLayer = GetReferenceLayer(doc, serializationConfig),
             AnimationData = ToAnimationData(doc.AnimationData, doc.NodeGraph, nodeIdMap, keyFrameIdMap),
-            ImageEncoderUsed = encoder.EncodedFormatName
+            ImageEncoderUsed = encoder.EncodedFormatName,
+            Resources = storage
         };
+        foreach (var factory in factories)
+        {
+            factory.Storage = null;
+        }
 
         return document;
     }
@@ -160,10 +172,10 @@ internal partial class DocumentViewModel
 
             primitive.Fill.Unit = SvgColorUnit.FromRgba(data.FillColor.R, data.FillColor.G,
                 data.FillColor.B, data.Fill ? data.FillColor.A : 0);
-                
+
             primitive.Stroke.Unit = SvgColorUnit.FromRgba(data.StrokeColor.R, data.StrokeColor.G,
                 data.StrokeColor.B, data.StrokeColor.A);
-                
+
             primitive.StrokeWidth.Unit = SvgNumericUnit.FromUserUnits(data.StrokeWidth);
         }
 
@@ -231,7 +243,7 @@ internal partial class DocumentViewModel
                 PathFillType.InverseWinding => SvgFillRule.NonZero,
                 PathFillType.InverseEvenOdd => SvgFillRule.EvenOdd,
             };
-            
+
             path.FillRule.Unit = new SvgEnumUnit<SvgFillRule>(fillRule);
             path.StrokeLineJoin.Unit = new SvgEnumUnit<SvgStrokeLineJoin>(ToSvgLineJoin(data.StrokeLineJoin));
             path.StrokeLineCap.Unit = new SvgEnumUnit<SvgStrokeLineCap>((SvgStrokeLineCap)data.StrokeLineCap);
@@ -516,7 +528,7 @@ internal partial class DocumentViewModel
             NodeId = idMap[rasterKeyFrame.NodeId], KeyFrameId = keyFrameIds[rasterKeyFrame.Id],
         });
     }
-    
+
     private static SvgStrokeLineJoin ToSvgLineJoin(StrokeJoin strokeLineJoin)
     {
         return strokeLineJoin switch

+ 53 - 3
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -4,6 +4,7 @@ using System.Collections.Immutable;
 using System.Collections.ObjectModel;
 using System.IO;
 using System.Linq;
+using System.Text.Json;
 using Avalonia;
 using Avalonia.Media.Imaging;
 using Avalonia.Threading;
@@ -49,6 +50,8 @@ using PixiEditor.Models.Serialization.Factories;
 using PixiEditor.Models.Structures;
 using PixiEditor.Models.Tools;
 using Drawie.Numerics;
+using PixiEditor.Models.IO;
+using PixiEditor.Parser;
 using PixiEditor.Parser.Skia;
 using PixiEditor.ViewModels.Document.Nodes;
 using PixiEditor.ViewModels.Document.TransformOverlays;
@@ -70,6 +73,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
     private bool busy = false;
 
+
     public bool Busy
     {
         get => busy;
@@ -208,12 +212,14 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
     public ReferenceLayerViewModel ReferenceLayerViewModel { get; }
     public LineToolOverlayViewModel LineToolOverlayViewModel { get; }
     public AnimationDataViewModel AnimationDataViewModel { get; }
+    public TextOverlayViewModel TextOverlayViewModel { get; }
 
     public IReadOnlyCollection<IStructureMemberHandler> SoftSelectedStructureMembers => softSelectedStructureMembers;
     private DocumentInternalParts Internals { get; }
     INodeGraphHandler IDocument.NodeGraphHandler => NodeGraph;
     IDocumentOperations IDocument.Operations => Operations;
     ITransformHandler IDocument.TransformHandler => TransformViewModel;
+    ITextOverlayHandler IDocument.TextOverlayHandler => TextOverlayViewModel;
     IPathOverlayHandler IDocument.PathOverlayHandler => PathOverlayViewModel;
     ILineOverlayHandler IDocument.LineToolOverlayHandler => LineToolOverlayViewModel;
     IReferenceLayerHandler IDocument.ReferenceLayerHandler => ReferenceLayerViewModel;
@@ -250,6 +256,12 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         LineToolOverlayViewModel.LineMoved += (_, args) =>
             Internals.ChangeController.LineOverlayMovedInlet(args.Item1, args.Item2);
 
+        TextOverlayViewModel = new TextOverlayViewModel();
+        TextOverlayViewModel.TextChanged += text =>
+        {
+            Internals.ChangeController.TextOverlayTextChangedInlet(text);
+        };
+
         SnappingViewModel = new();
         SnappingViewModel.AddFromDocumentSize(SizeBindable);
         SizeChanged += (_, args) =>
@@ -294,6 +306,12 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         Dictionary<int, Guid> mappedNodeIds = new();
         Dictionary<int, Guid> mappedKeyFrameIds = new();
 
+        ResourceStorageLocator? resourceLocator = null;
+        if (builderInstance.DocumentResources != null)
+        {
+            resourceLocator = ExtractResources(builderInstance.DocumentResources);
+        }
+
         var viewModel = new DocumentViewModel();
         viewModel.Operations.ResizeCanvas(new VecI(builderInstance.Width, builderInstance.Height), ResizeAnchor.Center);
 
@@ -335,6 +353,11 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         List<SerializationFactory> allFactories =
             ViewModelMain.Current.Services.GetServices<SerializationFactory>().ToList();
 
+        foreach (var factory in allFactories)
+        {
+            factory.ResourceLocator = resourceLocator;
+        }
+
         AddNodes(builderInstance.Graph);
 
         if (builderInstance.Graph.AllNodes.Count == 0 || !builderInstance.Graph.AllNodes.Any(x => x is OutputNode))
@@ -351,6 +374,11 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
             viewModel.MarkAsSaved();
         }));
 
+        foreach (var factory in allFactories)
+        {
+            factory.ResourceLocator = null;
+        }
+
         return viewModel;
 
 
@@ -483,6 +511,28 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
                 return false;
             }
         }
+
+        ResourceStorageLocator ExtractResources(ResourceStorage? resources)
+        {
+            if (resources is null)
+                return null;
+
+            string resourcesPath = Paths.TempResourcesPath;
+            if (!Directory.Exists(resourcesPath))
+                Directory.CreateDirectory(resourcesPath);
+
+            Dictionary<int, string> mapping = new();
+
+            foreach (var resource in resources.Resources)
+            {
+                string formattedGuid = resource.CacheId.ToString("N");
+                string filePath = Path.Combine(resourcesPath, $"{formattedGuid}{Path.GetExtension(resource.FileName)}");
+                File.WriteAllBytes(filePath, resource.Data);
+                mapping.Add(resource.Handle, filePath);
+            }
+
+            return new ResourceStorageLocator(mapping, resourcesPath);
+        }
     }
 
     public void MarkAsSaved()
@@ -816,7 +866,7 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         List<Guid> layerGuids = new List<Guid>();
         if (SelectedStructureMember is not null)
             layerGuids.Add(SelectedStructureMember.Id);
-        
+
         foreach (var member in softSelectedStructureMembers)
         {
             if (member.Id != SelectedStructureMember?.Id)
@@ -840,9 +890,9 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
         var allLayers = StructureHelper.GetAllMembers();
         foreach (var member in allLayers)
         {
-            if(!selectedMembers.Contains(member.Id))
+            if (!selectedMembers.Contains(member.Id))
                 continue;
-            
+
             if (member is ILayerHandler)
             {
                 result.Add(member.Id);

+ 4 - 0
src/PixiEditor/ViewModels/Document/Nodes/ImageLayerNodeViewModel.cs

@@ -2,6 +2,7 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ViewModels.Tools.Tools;
 
 namespace PixiEditor.ViewModels.Document.Nodes;
 
@@ -14,6 +15,7 @@ internal class ImageLayerNodeViewModel : StructureMemberViewModel<ImageLayerNode
         this.lockTransparency = lockTransparency;
         OnPropertyChanged(nameof(LockTransparencyBindable));
     }
+    
     public bool LockTransparencyBindable
     {
         get => lockTransparency;
@@ -36,4 +38,6 @@ internal class ImageLayerNodeViewModel : StructureMemberViewModel<ImageLayerNode
             OnPropertyChanged(nameof(ShouldDrawOnMask));
         }
     }
+
+    public Type? QuickEditTool { get; } = typeof(PenToolViewModel);
 }

+ 9 - 0
src/PixiEditor/ViewModels/Document/Nodes/Shapes/TextNodeViewModel.cs

@@ -0,0 +1,9 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+using PixiEditor.ViewModels.Nodes;
+
+namespace PixiEditor.ViewModels.Document.Nodes.Shapes;
+
+[NodeViewModel("TEXT_NODE", "SHAPE", "\ue90d")]
+internal class TextNodeViewModel : NodeViewModel<TextNode>
+{
+}

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

@@ -1,15 +1,26 @@
 using PixiEditor.ChangeableDocument.Actions.Generated;
 using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces.Shapes;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ViewModels.Tools.Tools;
 
 namespace PixiEditor.ViewModels.Document.Nodes;
 
 [NodeViewModel("VECTOR_LAYER", "STRUCTURE", "\ue916")]
 internal class VectorLayerNodeViewModel : StructureMemberViewModel<VectorLayerNode>, IVectorLayerHandler
 {
+    private Dictionary<Type, Type> quickToolsMap = new Dictionary<Type, Type>()
+    {
+        { typeof(IReadOnlyEllipseData), typeof(VectorEllipseToolViewModel) },
+        { typeof(IReadOnlyRectangleData), typeof(VectorRectangleToolViewModel) },
+        { typeof(IReadOnlyLineData), typeof(VectorLineToolViewModel) },
+        { typeof(IReadOnlyTextData), typeof(TextToolViewModel) },
+        { typeof(IReadOnlyPathData), typeof(VectorPathToolViewModel) }
+    };
+    
     bool lockTransparency;
     public void SetLockTransparency(bool lockTransparency)
     {
@@ -39,6 +50,24 @@ internal class VectorLayerNodeViewModel : StructureMemberViewModel<VectorLayerNo
         }
     }
 
+    public Type? QuickEditTool
+    {
+        get
+        {
+            var shapeData = GetShapeData(Document.AnimationDataViewModel.ActiveFrameTime);
+            if (shapeData is null)
+                return null;
+
+            foreach (var tool in quickToolsMap)
+            {
+                if(shapeData.GetType().IsAssignableTo(tool.Key))
+                    return tool.Value;
+            }
+            
+            return null;
+        }
+    }
+
     public IReadOnlyShapeVectorData? GetShapeData(KeyFrameTime frameTime)
     {
         return ((IReadOnlyVectorNode)Internals.Tracker.Document.FindMember(Id))?.ShapeData;

+ 18 - 0
src/PixiEditor/ViewModels/Document/ResourceStorageLocator.cs

@@ -0,0 +1,18 @@
+namespace PixiEditor.ViewModels.Document;
+
+public class ResourceStorageLocator
+{
+    private Dictionary<int, string> mapper;
+    private string rootPath;
+
+    internal ResourceStorageLocator(Dictionary<int, string> mapper, string rootPath)
+    {
+        this.mapper = mapper;
+        this.rootPath = rootPath;
+    }
+
+    internal string GetFilePath(int handle)
+    {
+        return Path.Combine(rootPath, mapper[handle]);
+    }
+}

+ 124 - 0
src/PixiEditor/ViewModels/Document/TransformOverlays/TextOverlayViewModel.cs

@@ -0,0 +1,124 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+using PixiEditor.Helpers.UI;
+using PixiEditor.Models.Handlers;
+
+namespace PixiEditor.ViewModels.Document.TransformOverlays;
+
+internal class TextOverlayViewModel : ObservableObject, ITextOverlayHandler
+{
+    private bool isActive;
+    private string text;
+    private VecD position;
+    private Font font;
+    private ExecutionTrigger<string> requestEditTextTrigger;
+    private Matrix3X3 matrix = Matrix3X3.Identity;
+    private double? spacing;
+    private int cursorPosition;
+    private int selectionEnd;
+
+    public event Action<string>? TextChanged;
+
+    public bool IsActive
+    {
+        get => isActive;
+        set => SetProperty(ref isActive, value);
+    }
+
+    public string Text
+    {
+        get => text;
+        set
+        {
+            SetProperty(ref text, value);
+            if (IsActive)
+            {
+                TextChanged?.Invoke(value);
+            }
+        }
+    }
+
+    public VecD Position
+    {
+        get => position;
+        set => SetProperty(ref position, value);
+    }
+
+    public Font Font
+    {
+        get => font;
+        set => SetProperty(ref font, value);
+    }
+
+    public ExecutionTrigger<string> RequestEditTextTrigger
+    {
+        get => requestEditTextTrigger;
+        set => SetProperty(ref requestEditTextTrigger, value);
+    }
+    
+    public Matrix3X3 Matrix
+    {
+        get => matrix;
+        set => SetProperty(ref matrix, value);
+    }
+    
+    public double? Spacing
+    {
+        get => spacing;
+        set => SetProperty(ref spacing, value);
+    }
+    
+    public int CursorPosition
+    {
+        get => cursorPosition;
+        set => SetProperty(ref cursorPosition, value);
+    }
+
+    public int SelectionEnd
+    {
+        get => selectionEnd;
+        set => SetProperty(ref selectionEnd, value);
+    }
+
+    public void SetCursorPosition(VecD closestToPosition)
+    {
+        VecD mapped = Matrix.Invert().MapPoint(closestToPosition);
+        RichText richText = new(Text);
+        var positions = richText.GetGlyphPositions(Font);
+        int indexOfClosest = positions.Select((pos, index) => (pos, index))
+            .OrderBy(pos => ((pos.pos + Position - new VecD(0, Font.Size / 2f)) - mapped).LengthSquared)
+            .First().index;
+        
+        CursorPosition = indexOfClosest;
+        SelectionEnd = indexOfClosest;
+    }
+
+    public TextOverlayViewModel()
+    {
+        RequestEditTextTrigger = new ExecutionTrigger<string>();
+    }
+
+
+    public void Show(string text, VecD position, Font font, Matrix3X3 matrix, double? spacing = null)
+    {
+        Font = font;
+        Position = position;
+        Text = text;
+        Matrix = matrix;
+        Spacing = spacing;
+        IsActive = true;
+        RequestEditTextTrigger.Execute(this, text);
+    }
+
+    public void Hide()
+    {
+        IsActive = false;
+        Font = null!;
+        Position = default;
+        Text = string.Empty;
+        Matrix = Matrix3X3.Identity;
+        Spacing = null;
+    }
+}

+ 30 - 16
src/PixiEditor/ViewModels/SubViewModels/ToolsViewModel.cs

@@ -136,7 +136,7 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
         ActiveToolSet = toolSetHandler;
         ActiveToolSet.ApplyToolSetSettings();
         UpdateEnabledState();
-        
+
         ActiveTool?.OnToolSelected(false);
     }
 
@@ -146,7 +146,11 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
         {
             if (tool is ToolViewModel toolVm)
             {
-                toolVm.Shortcut = Owner.ShortcutController.GetToolShortcut(tool.GetType());
+                var combination = Owner.ShortcutController.GetToolShortcut(tool.GetType());
+                if (combination is not null)
+                {
+                    toolVm.Shortcut = combination.Value;
+                }
             }
         }
     }
@@ -386,8 +390,8 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
 
         if (ActiveTool is not { CanBeUsedOnActiveLayer: true })
         {
-            if(ActiveTool.LayerTypeToCreateOnEmptyUse == null) return;
-            
+            if (ActiveTool.LayerTypeToCreateOnEmptyUse == null) return;
+
             Guid? createdLayer = Owner.LayersSubViewModel.NewLayer(
                 ActiveTool.LayerTypeToCreateOnEmptyUse,
                 ActionSource.Automated,
@@ -423,22 +427,31 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
     {
         ActiveTool?.KeyChanged(args.IsCtrlDown, args.IsShiftDown, args.IsAltDown, args.Key);
     }
-    
+
     public void OnPostUndoInlet()
     {
-        ActiveTool?.OnPostUndo();
+        ActiveTool?.OnPostUndoInlet();
     }
-    
+
     public void OnPostRedoInlet()
     {
-        ActiveTool?.OnPostRedo();
+        ActiveTool?.OnPostRedoInlet();
     }
-    
+
     public void OnPreUndoInlet()
     {
         ActiveTool?.OnPreUndoInlet();
     }
-    
+
+    public void QuickToolSwitchInlet()
+    {
+        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (document is null)
+            return;
+
+        document.EventInlet.QuickToolSwitchInlet();
+    }
+
     public void OnPreRedoInlet()
     {
         ActiveTool?.OnPreRedoInlet();
@@ -458,7 +471,7 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
         foreach (ToolSetConfig toolSet in toolSetConfig)
         {
             var toolSetViewModel = new ToolSetViewModel(toolSet.Name);
-            
+
             foreach (var toolFromToolset in toolSet.Tools)
             {
                 IToolHandler? tool = allTools.FirstOrDefault(tool => tool.ToolName == toolFromToolset.ToolName);
@@ -468,7 +481,7 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
                 {
                     toolSetViewModel.IconOverwrites[tool] = toolFromToolset.Icon;
                 }
-                
+
                 if (tool is null)
                 {
 #if DEBUG
@@ -502,7 +515,7 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
             UpdateEnabledState();
         }
     }
-    
+
     private void ActiveFrameChanged(int oldFrame, int newFrame)
     {
         UpdateActiveFrame(newFrame);
@@ -515,17 +528,18 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
 
     private void DocumentOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
     {
-        if (e.PropertyName is nameof(DocumentViewModel.SelectedStructureMember) or nameof(DocumentViewModel.SoftSelectedStructureMembers))
+        if (e.PropertyName is nameof(DocumentViewModel.SelectedStructureMember)
+            or nameof(DocumentViewModel.SoftSelectedStructureMembers))
         {
             UpdateEnabledState();
         }
     }
-    
+
     private void UpdateActiveFrame(int newFrame)
     {
         ActiveTool?.OnActiveFrameChanged(newFrame);
     }
-    
+
     private void UpdateEnabledState()
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;

+ 1 - 0
src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/BoolSettingViewModel.cs

@@ -11,6 +11,7 @@ internal sealed class BoolSettingViewModel : Setting<bool>
     public BoolSettingViewModel(string name, string label = "")
         : this(name, false, label)
     {
+        AllowIconLabel = false;
     }
 
     public BoolSettingViewModel(string name, bool isChecked, string label = "")

+ 84 - 0
src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/FontFamilySettingViewModel.cs

@@ -0,0 +1,84 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Platform.Storage;
+using CommunityToolkit.Mvvm.Input;
+using Drawie.Backend.Core.Text;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.UserPreferences;
+
+namespace PixiEditor.ViewModels.Tools.ToolSettings.Settings;
+
+internal class FontFamilySettingViewModel : Setting<FontFamilyName>
+{
+    private int selectedIndex;
+
+
+    private ObservableCollection<FontFamilyName> _fonts;
+
+    public ObservableCollection<FontFamilyName> Fonts
+    {
+        get
+        {
+            return _fonts;
+        }
+        set
+        {
+            SetProperty(ref _fonts, value);
+        }
+    }
+
+
+    public int FontIndex
+    {
+        get
+        {
+            return selectedIndex;
+        }
+        set
+        {
+            SetProperty(ref selectedIndex, value);
+            Value = Fonts[value];
+        }
+    }
+
+    public AsyncRelayCommand UploadFontCommand { get; }
+
+    public FontFamilySettingViewModel(string name, string displayName) : base(name)
+    {
+        Label = displayName;
+        Fonts = new ObservableCollection<FontFamilyName>(FontLibrary.AllFonts);
+        FontLibrary.FontAdded += (font) => Fonts.Add(font); 
+        UploadFontCommand = new AsyncRelayCommand(UploadFont);
+    }
+
+    private async Task UploadFont()
+    {
+        FilePickerFileType[] filter =
+        [
+            new FilePickerFileType(new LocalizedString("FONT_FILES")) { Patterns = new List<string> { "*.ttf", "*.otf" } },
+            new FilePickerFileType("TrueType Font") { Patterns = new List<string> { "*.ttf" } },
+            new FilePickerFileType("OpenType Font") { Patterns = new List<string> { "*.otf" } },
+        ];
+        
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+        {
+            var dialog = await desktop.MainWindow.StorageProvider.OpenFilePickerAsync(
+                new FilePickerOpenOptions { FileTypeFilter = filter });
+
+            if (dialog.Count == 0)
+                return;
+
+            var fontPath = dialog[0];
+            FontFamilyName familyName = new FontFamilyName(fontPath.Path, Path.GetFileNameWithoutExtension(fontPath.Name));
+            FontLibrary.TryAddCustomFont(familyName);
+            
+            FontIndex = Fonts.IndexOf(familyName);
+        }
+    }
+}

+ 7 - 0
src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/Setting.cs

@@ -94,7 +94,14 @@ internal abstract class Setting : ObservableObject
 
     public LocalizedString Label { get; set; }
 
+    public string Icon { get; set; }
+
+    public string Tooltip { get; set; }
+
     public bool HasLabel => !string.IsNullOrEmpty(Label);
+    public bool HasIcon => !string.IsNullOrEmpty(Icon);
+
+    public bool AllowIconLabel { get; protected set; } = true;
 
     public object UserValue
     {

+ 17 - 5
src/PixiEditor/ViewModels/Tools/ToolSettings/Settings/SizeSettingViewModel.cs

@@ -12,16 +12,19 @@ internal sealed class SizeSettingViewModel : Setting<double>
     private double min;
     private double max;
     private int decimalPlaces;
+    private string unit = "px";
     
-    public SizeSettingViewModel(string name, string label = null, double min = 1, double max = double.PositiveInfinity, int decimalPlaces = 0)
+    public SizeSettingViewModel(string name, string label = null, double min = 1, double max = double.PositiveInfinity,
+        int decimalPlaces = 0, string unit = "px")
         : base(name)
     {
         Label = label;
         Value = 1;
-        
+
         this.min = min;
         this.max = max;
         this.decimalPlaces = decimalPlaces;
+        this.unit = unit;
     }
 
     public bool IsEnabled
@@ -32,7 +35,7 @@ internal sealed class SizeSettingViewModel : Setting<double>
             SetProperty(ref isEnabled, value);
         }
     }
-    
+
     public double Min
     {
         get => min;
@@ -41,7 +44,7 @@ internal sealed class SizeSettingViewModel : Setting<double>
             SetProperty(ref min, value);
         }
     }
-    
+
     public double Max
     {
         get => max;
@@ -50,7 +53,7 @@ internal sealed class SizeSettingViewModel : Setting<double>
             SetProperty(ref max, value);
         }
     }
-    
+
     public int DecimalPlaces
     {
         get => decimalPlaces;
@@ -59,4 +62,13 @@ internal sealed class SizeSettingViewModel : Setting<double>
             SetProperty(ref decimalPlaces, value);
         }
     }
+
+    public string Unit
+    {
+        get => unit;
+        set
+        {
+            SetProperty(ref unit, value);
+        }
+    }
 }

+ 4 - 1
src/PixiEditor/ViewModels/Tools/ToolSettings/Toolbars/ShapeToolbar.cs

@@ -1,5 +1,6 @@
 using Avalonia.Media;
 using PixiEditor.Models.Handlers.Toolbars;
+using PixiEditor.UI.Common.Fonts;
 using PixiEditor.ViewModels.Tools.ToolSettings.Settings;
 
 namespace PixiEditor.ViewModels.Tools.ToolSettings.Toolbars;
@@ -59,7 +60,9 @@ internal class ShapeToolbar : Toolbar, IShapeToolbar
             IsExposed = false, Value = false
         });
         AddSetting(
-            new BoolSettingViewModel(nameof(SyncWithPrimaryColor), "SYNC_WITH_PRIMARY_COLOR_LABEL") { Value = true });
+            new BoolSettingViewModel(nameof(SyncWithPrimaryColor))
+                { Value = true, Icon = PixiPerfectIcons.LinkedPipette,
+                    Tooltip = "SYNC_WITH_PRIMARY_COLOR_LABEL"});
         AddSetting(new ColorSettingViewModel(nameof(StrokeColor), "STROKE_COLOR_LABEL"));
     }
 }

+ 153 - 0
src/PixiEditor/ViewModels/Tools/ToolSettings/Toolbars/TextToolbar.cs

@@ -0,0 +1,153 @@
+using Drawie.Backend.Core.Text;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.Handlers.Toolbars;
+using PixiEditor.UI.Common.Fonts;
+using PixiEditor.ViewModels.Tools.ToolSettings.Settings;
+
+namespace PixiEditor.ViewModels.Tools.ToolSettings.Toolbars;
+
+internal class TextToolbar : FillableShapeToolbar, ITextToolbar
+{
+    public FontFamilyName FontFamily
+    {
+        get
+        {
+            return GetSetting<FontFamilySettingViewModel>(nameof(FontFamily)).Value;
+        }
+        set
+        {
+            int index = Array.IndexOf(FontLibrary.AllFonts, value);
+            if (index == -1)
+            {
+                index = 0;
+            }
+
+            GetSetting<FontFamilySettingViewModel>(nameof(FontFamily)).FontIndex = index;
+        }
+    }
+
+    public double FontSize
+    {
+        get
+        {
+            return GetSetting<SizeSettingViewModel>(nameof(FontSize)).Value;
+        }
+        set
+        {
+            GetSetting<SizeSettingViewModel>(nameof(FontSize)).Value = value;
+        }
+    }
+
+    public double Spacing
+    {
+        get
+        {
+            return GetSetting<SizeSettingViewModel>(nameof(Spacing)).Value;
+        }
+        set
+        {
+            GetSetting<SizeSettingViewModel>(nameof(Spacing)).Value = value;
+        }
+    }
+    
+    public bool ForceLowDpiRendering
+    {
+        get
+        {
+            return GetSetting<BoolSettingViewModel>(nameof(ForceLowDpiRendering)).Value;
+        }
+        set
+        {
+            GetSetting<BoolSettingViewModel>(nameof(ForceLowDpiRendering)).Value = value;
+        }
+    }
+
+    public bool Bold
+    {
+        get
+        {
+            return GetSetting<BoolSettingViewModel>(nameof(Bold)).Value;
+        }
+        set
+        {
+            GetSetting<BoolSettingViewModel>(nameof(Bold)).Value = value;
+        }
+    }
+
+    public bool Italic
+    {
+        get
+        {
+            return GetSetting<BoolSettingViewModel>(nameof(Italic)).Value;
+        }
+        set
+        {
+            GetSetting<BoolSettingViewModel>(nameof(Italic)).Value = value;
+        }
+    }
+
+    public TextToolbar()
+    {
+        AddSetting(new FontFamilySettingViewModel(nameof(FontFamily), ""));
+        FontFamily = FontLibrary.DefaultFontFamily;
+        
+        var sizeSetting =
+            new SizeSettingViewModel(nameof(FontSize), "FONT_SIZE_LABEL", unit: new LocalizedString("UNIT_PT"))
+            {
+                Value = 12
+            };
+        AddSetting(sizeSetting);
+        var spacingSetting =
+            new SizeSettingViewModel(nameof(Spacing), unit: new LocalizedString("UNIT_PT"))
+            {
+                Tooltip = "SPACING_LABEL",
+                Icon = PixiPerfectIcons.LineHeight
+            };
+        spacingSetting.Value = 12;
+
+        sizeSetting.ValueChanged += (sender, args) =>
+        {
+            double delta = args.NewValue - args.OldValue;
+            spacingSetting.Value += delta;
+        };
+
+        AddSetting(spacingSetting);
+
+        AddSetting(new BoolSettingViewModel(nameof(Bold))
+        {
+            Icon = PixiPerfectIcons.Bold, Tooltip = "BOLD_TOOLTIP"
+        });
+
+        AddSetting(new BoolSettingViewModel(nameof(Italic))
+        {
+            Icon = PixiPerfectIcons.Italic, Tooltip = "ITALIC_TOOLTIP"
+        });
+
+        AddSetting(new BoolSettingViewModel(nameof(ForceLowDpiRendering), "__force_low_dpi_rendering")
+        {
+            IsExposed = false, Value = false
+        });
+    }
+
+    public Font ConstructFont()
+    {
+        Font font = null;
+        if (!string.IsNullOrEmpty(FontFamily.Name))
+        {
+            font = Font.FromFontFamily(FontFamily);
+        }
+
+        if (font is null)
+        {
+            font = Font.CreateDefault();
+        }
+
+        font.Size = (float)FontSize;
+        font.Edging = AntiAliasing ? FontEdging.AntiAlias : FontEdging.Alias;
+        font.Bold = Bold;
+        font.Italic = Italic;
+
+        return font;
+    }
+}

+ 7 - 2
src/PixiEditor/ViewModels/Tools/ToolViewModel.cs

@@ -185,12 +185,17 @@ internal abstract class ToolViewModel : ObservableObject, IToolHandler
     {
     }
 
-    public virtual void OnPostUndo() { }
-    public virtual void OnPostRedo() { }
+    public virtual void OnPostUndoInlet() { }
+    public virtual void OnPostRedoInlet() { }
     public virtual void OnActiveFrameChanged(int newFrame) { }
     public virtual void OnPreUndoInlet() { }
 
     public virtual void OnPreRedoInlet() { }
+    public virtual void QuickToolSwitchInlet()
+    {
+
+    }
+
     public void SetToolSetSettings(IToolSetHandler toolset, Dictionary<string, object>? settings)
     {
         if (settings == null || settings.Count == 0 || toolset == null)

+ 2 - 2
src/PixiEditor/ViewModels/Tools/Tools/MoveToolViewModel.cs

@@ -97,7 +97,7 @@ internal class MoveToolViewModel : ToolViewModel, IMoveToolHandler
         }
     }
 
-    public override void OnPostUndo()
+    public override void OnPostUndoInlet()
     {
         if (IsActive)
         {
@@ -105,7 +105,7 @@ internal class MoveToolViewModel : ToolViewModel, IMoveToolHandler
         }
     }
 
-    public override void OnPostRedo()
+    public override void OnPostRedoInlet()
     {
         if (IsActive)
         {

+ 77 - 0
src/PixiEditor/ViewModels/Tools/Tools/TextToolViewModel.cs

@@ -0,0 +1,77 @@
+using Avalonia.Input;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Handlers;
+using PixiEditor.Models.Handlers.Tools;
+using PixiEditor.UI.Common.Fonts;
+using PixiEditor.ViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.ViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.T)]
+internal class TextToolViewModel : ToolViewModel, ITextToolHandler
+{
+    public override string ToolNameLocalizationKey => "TEXT_TOOL";
+    public override Type[]? SupportedLayerTypes => [];
+    public override Type LayerTypeToCreateOnEmptyUse => typeof(VectorLayerNode);
+    public override LocalizedString Tooltip => new LocalizedString("TEXT_TOOL_TOOLTIP", Shortcut);
+
+    public override string DefaultIcon => PixiPerfectIcons.TextRound;
+
+    public override bool IsErasable => false;
+    public override bool StopsLinkedToolOnUse => false;
+
+    public string? DefaultNewLayerName { get; } = new LocalizedString("TEXT_LAYER_NAME");
+
+    [Settings.Inherited]
+    public double FontSize
+    {
+        get => GetValue<double>();
+    }
+
+    public TextToolViewModel()
+    {
+        Toolbar = ToolbarFactory.Create<TextToolViewModel, TextToolbar>(this);
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseTextTool();
+    }
+
+    protected override void OnSelected(bool restoring)
+    {
+        if (!restoring)
+        {
+            ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseTextTool();
+        }
+    }
+
+    protected override void OnDeselecting(bool transient)
+    {
+        if (!transient)
+        {
+            ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Operations.TryStopToolLinkedExecutor();
+        }
+    }
+
+    protected override void OnSelectedLayersChanged(IStructureMemberHandler[] layers)
+    {
+        OnDeselecting(false);
+        OnSelected(false);
+    }
+
+    public override void OnPostUndoInlet()
+    {
+        OnDeselecting(false);
+        OnSelected(false);
+    }
+
+    public override void OnPostRedoInlet()
+    {
+        OnDeselecting(false);
+        OnSelected(false);
+    }
+}

+ 2 - 2
src/PixiEditor/ViewModels/Tools/Tools/VectorEllipseToolViewModel.cs

@@ -65,7 +65,7 @@ internal class VectorEllipseToolViewModel : ShapeTool, IVectorEllipseToolHandler
         ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseVectorEllipseTool();
     }
 
-    public override void OnPostUndo()
+    public override void OnPostUndoInlet()
     {
         if (IsActive)
         {
@@ -73,7 +73,7 @@ internal class VectorEllipseToolViewModel : ShapeTool, IVectorEllipseToolHandler
         }
     }
 
-    public override void OnPostRedo()
+    public override void OnPostRedoInlet()
     {
         if (IsActive)
         {

+ 2 - 2
src/PixiEditor/ViewModels/Tools/Tools/VectorLineToolViewModel.cs

@@ -72,7 +72,7 @@ internal class VectorLineToolViewModel : ShapeTool, IVectorLineToolHandler
         document.Tools.UseVectorLineTool();
     }
 
-    public override void OnPostUndo()
+    public override void OnPostUndoInlet()
     {
         if (IsActive)
         {
@@ -80,7 +80,7 @@ internal class VectorLineToolViewModel : ShapeTool, IVectorLineToolHandler
         }
     }
 
-    public override void OnPostRedo()
+    public override void OnPostRedoInlet()
     {
         if (IsActive)
         {

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

@@ -128,7 +128,7 @@ internal class VectorPathToolViewModel : ShapeTool, IVectorPathToolHandler
         }
     }
 
-    public override void OnPostUndo()
+    public override void OnPostUndoInlet()
     {
         if (isActivated)
         {
@@ -136,7 +136,7 @@ internal class VectorPathToolViewModel : ShapeTool, IVectorPathToolHandler
         }
     }
 
-    public override void OnPostRedo()
+    public override void OnPostRedoInlet()
     {
         if (isActivated)
         {

+ 2 - 2
src/PixiEditor/ViewModels/Tools/Tools/VectorRectangleToolViewModel.cs

@@ -62,7 +62,7 @@ internal class VectorRectangleToolViewModel : ShapeTool, IVectorRectangleToolHan
         ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseVectorRectangleTool();
     }
 
-    public override void OnPostUndo()
+    public override void OnPostUndoInlet()
     {
         if (IsActive)
         {
@@ -70,7 +70,7 @@ internal class VectorRectangleToolViewModel : ShapeTool, IVectorRectangleToolHan
         }
     }
 
-    public override void OnPostRedo()
+    public override void OnPostRedoInlet()
     {
         if (IsActive)
         {

+ 19 - 1
src/PixiEditor/Views/Main/Tools/Toolbar.axaml

@@ -6,6 +6,7 @@
              xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
              xmlns:decorators="clr-namespace:PixiEditor.Views.Decorators"
              xmlns:settings="clr-namespace:PixiEditor.ViewModels.Tools.ToolSettings.Settings"
+             xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.Views.Main.Tools.Toolbar">
     <Border CornerRadius="{DynamicResource ControlCornerRadius}"
@@ -55,7 +56,8 @@
                        ui:Translator.Key="{Binding ToolsSubViewModel.ActiveTool.DisplayName.Key}"
                        ui:Translator.TooltipLocalizedString="{Binding ToolsSubViewModel.ActiveTool.ActionDisplay}" />
             </StackPanel>
-            <ItemsControl VerticalAlignment="Center" Grid.Column="1" IsVisible="{Binding ElementName=CollapseButton, Path=!IsChecked}"
+            <ItemsControl VerticalAlignment="Center" Grid.Column="1"
+                          IsVisible="{Binding ElementName=CollapseButton, Path=!IsChecked}"
                           ItemsSource="{Binding ToolsSubViewModel.ActiveTool.Toolbar.Settings}">
                 <ItemsControl.ItemsPanel>
                     <ItemsPanelTemplate>
@@ -69,7 +71,23 @@
                             <Label
                                 IsVisible="{Binding HasLabel}" VerticalAlignment="Center"
                                 Foreground="{DynamicResource ThemeForegroundBrush}"
+                                ui:Translator.TooltipKey="{Binding Tooltip}"
                                 ui:Translator.Key="{Binding Label.Key}" />
+                            <TextBlock
+                                VerticalAlignment="Center"
+                                Foreground="{DynamicResource ThemeForegroundBrush}"
+                                ui:Translator.TooltipKey="{Binding Tooltip}"
+                                Classes="pixi-icon"
+                                FontSize="18"
+                                Padding="5 0"
+                                Text="{Binding Icon}">
+                                <TextBlock.IsVisible>
+                                    <MultiBinding Converter="{converters:AllTrueConverter}">
+                                        <Binding Path="HasIcon" />
+                                        <Binding Path="AllowIconLabel" />
+                                    </MultiBinding>
+                                </TextBlock.IsVisible>
+                            </TextBlock>
                             <ContentControl VerticalAlignment="Center" Content="{Binding }" />
                         </StackPanel>
                     </DataTemplate>

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

@@ -443,7 +443,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
         var pos = e.GetPosition(Scene);
         VecD scenePos = Scene.ToZoomboxSpace(new VecD(pos.X, pos.Y));
-        MouseOnCanvasEventArgs? parameter = new MouseOnCanvasEventArgs(mouseButton, scenePos, e.KeyModifiers);
+        MouseOnCanvasEventArgs? parameter = new MouseOnCanvasEventArgs(mouseButton, scenePos, e.KeyModifiers, e.ClickCount);
 
         if (MouseDownCommand.CanExecute(parameter))
             MouseDownCommand.Execute(parameter);
@@ -458,7 +458,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
         MouseButton mouseButton = e.GetMouseButton(this);
 
-        MouseOnCanvasEventArgs parameter = new(mouseButton, conv, e.KeyModifiers);
+        MouseOnCanvasEventArgs parameter = new(mouseButton, conv, e.KeyModifiers, 0);
 
         if (MouseMoveCommand.CanExecute(parameter))
             MouseMoveCommand.Execute(parameter);
@@ -471,7 +471,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
         Point pos = e.GetPosition(Scene);
         VecD conv = Scene.ToZoomboxSpace(new VecD(pos.X, pos.Y));
-        MouseOnCanvasEventArgs parameter = new(e.InitialPressMouseButton, conv, e.KeyModifiers);
+        MouseOnCanvasEventArgs parameter = new(e.InitialPressMouseButton, conv, e.KeyModifiers, 0);
         if (MouseUpCommand.CanExecute(parameter))
             MouseUpCommand.Execute(parameter);
     }

+ 65 - 0
src/PixiEditor/Views/Main/ViewportControls/ViewportOverlays.cs

@@ -7,6 +7,7 @@ using PixiEditor.Views.Visuals;
 using PixiEditor.Helpers.Converters;
 using PixiEditor.Models.Commands.XAML;
 using PixiEditor.ViewModels;
+using PixiEditor.ViewModels.Document.TransformOverlays;
 using PixiEditor.Views.Overlays;
 using PixiEditor.Views.Overlays.BrushShapeOverlay;
 using PixiEditor.Views.Overlays.LineToolOverlay;
@@ -14,6 +15,7 @@ using PixiEditor.Views.Overlays.PathOverlay;
 using PixiEditor.Views.Overlays.Pointers;
 using PixiEditor.Views.Overlays.SelectionOverlay;
 using PixiEditor.Views.Overlays.SymmetryOverlay;
+using PixiEditor.Views.Overlays.TextOverlay;
 using PixiEditor.Views.Overlays.TransformOverlay;
 
 namespace PixiEditor.Views.Main.ViewportControls;
@@ -31,6 +33,7 @@ internal class ViewportOverlays
     private SnappingOverlay snappingOverlay;
     private BrushShapeOverlay brushShapeOverlay;
     private VectorPathOverlay vectorPathOverlay;
+    private TextOverlay textOverlay;
 
     public void Init(Viewport viewport)
     {
@@ -62,6 +65,9 @@ internal class ViewportOverlays
         vectorPathOverlay = new VectorPathOverlay();
         vectorPathOverlay.IsVisible = false;
         BindVectorPathOverlay();
+        
+        textOverlay = new TextOverlay();
+        BindTextOverlay();
 
         Viewport.ActiveOverlays.Add(gridLinesOverlay);
         Viewport.ActiveOverlays.Add(referenceLayerOverlay);
@@ -72,6 +78,7 @@ internal class ViewportOverlays
         Viewport.ActiveOverlays.Add(vectorPathOverlay);
         Viewport.ActiveOverlays.Add(snappingOverlay);
         Viewport.ActiveOverlays.Add(brushShapeOverlay);
+        Viewport.ActiveOverlays.Add(textOverlay);
     }
 
     private void BindReferenceLayerOverlay()
@@ -443,5 +450,63 @@ internal class ViewportOverlays
         brushShapeOverlay.Bind(BrushShapeOverlay.BrushSizeProperty, brushSizeBinding);
         brushShapeOverlay.Bind(BrushShapeOverlay.BrushShapeProperty, brushShapeBinding); 
     }
+
+    private void BindTextOverlay()
+    {
+        Binding isVisibleBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.IsActive", Mode = BindingMode.OneWay
+        };
+
+        Binding textBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.Text", Mode = BindingMode.TwoWay
+        };
+
+        Binding positionBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.Position", Mode = BindingMode.OneWay
+        };
+
+        Binding fontBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.Font", Mode = BindingMode.OneWay
+        };
+        
+        Binding requestEditTextBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.RequestEditTextTrigger", Mode = BindingMode.OneWay
+        };
+        
+        Binding matrixBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.Matrix", Mode = BindingMode.OneWay
+        };
+        
+        Binding spacingBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.Spacing", Mode = BindingMode.OneWay
+        };
+
+        Binding cursorPositionBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.CursorPosition", Mode = BindingMode.TwoWay
+        };
+
+        Binding selectionEndBinding = new()
+        {
+            Source = Viewport, Path = "Document.TextOverlayViewModel.SelectionEnd", Mode = BindingMode.TwoWay
+        };
+
+        textOverlay.Bind(Visual.IsVisibleProperty, isVisibleBinding);
+        textOverlay.Bind(TextOverlay.TextProperty, textBinding);
+        textOverlay.Bind(TextOverlay.PositionProperty, positionBinding);
+        textOverlay.Bind(TextOverlay.FontProperty, fontBinding);
+        textOverlay.Bind(TextOverlay.RequestEditTextProperty, requestEditTextBinding);
+        textOverlay.Bind(TextOverlay.MatrixProperty, matrixBinding);
+        textOverlay.Bind(TextOverlay.SpacingProperty, spacingBinding);
+        textOverlay.Bind(TextOverlay.CursorPositionProperty, cursorPositionBinding);
+        textOverlay.Bind(TextOverlay.SelectionEndProperty, selectionEndBinding);
+    }
 }
 

+ 8 - 2
src/PixiEditor/Views/Overlays/Overlay.cs

@@ -44,6 +44,7 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
     }
 
     public event Action? RefreshRequested;
+    public event Action? FocusRequested;
     public event Action? RefreshCursorRequested;
     public event PointerEvent? PointerEnteredOverlay;
     public event PointerEvent? PointerExitedOverlay;
@@ -80,6 +81,11 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
         InvalidateVisual(); // For elements in visual tree
     }
 
+    public void FocusOverlay()
+    {
+        FocusRequested?.Invoke();
+    }
+
     public void ForceRefreshCursor()
     {
         RefreshCursorRequested?.Invoke();
@@ -146,7 +152,7 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
     
     public void KeyPressed(KeyEventArgs args)
     {
-        OnKeyPressed(args.Key, args.KeyModifiers);
+        OnKeyPressed(args.Key, args.KeyModifiers, args.KeySymbol);
         KeyPressedOverlay?.Invoke(args.Key, args.KeyModifiers);
     }
 
@@ -276,7 +282,7 @@ public abstract class Overlay : Decorator, IOverlay // TODO: Maybe make it not a
     {
     }
     
-    protected virtual void OnKeyPressed(Key key, KeyModifiers keyModifiers)
+    protected virtual void OnKeyPressed(Key key, KeyModifiers keyModifiers, string? keySymbol)
     {
     }
     

+ 1 - 1
src/PixiEditor/Views/Overlays/PathOverlay/VectorPathOverlay.cs

@@ -357,7 +357,7 @@ public class VectorPathOverlay : Overlay
         args.Handled = true;
     }
 
-    protected override void OnKeyPressed(Key key, KeyModifiers keyModifiers)
+    protected override void OnKeyPressed(Key key, KeyModifiers keyModifiers, string? symbol)
     {
         if (key == Key.Delete)
         {

+ 73 - 0
src/PixiEditor/Views/Overlays/TextOverlay/Caret.cs

@@ -0,0 +1,73 @@
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+
+namespace PixiEditor.Views.Overlays.TextOverlay;
+
+internal class Caret : IDisposable
+{
+    public int CaretPosition
+    {
+        get => _caretPosition;
+        set
+        {
+            if (_caretPosition != value)
+            {
+                _caretPosition = value;
+                visible = true;
+                lastUpdate = DateTime.Now;
+            }
+        }
+    }
+
+    public double FontSize { get; set; }
+    public VecF[] GlyphPositions { get; set; }
+    public VecD Offset { get; set; }
+    public float[] GlyphWidths { get; set; }
+    public float CaretWidth { get; set; } = 1;
+
+    private Paint paint = new Paint() { Color = Colors.White, Style = PaintStyle.StrokeAndFill, StrokeWidth = 3 };
+
+    private bool visible;
+    private DateTime lastUpdate = DateTime.Now;
+    private int _caretPosition;
+
+    public void Render(Canvas canvas)
+    {
+        if (GlyphPositions.Length == 0)
+        {
+            return;
+        }
+
+        int clampedBlinkerPosition = Math.Clamp(CaretPosition, 0, GlyphPositions.Length - 1);
+
+        var glyphPosition = GlyphPositions[clampedBlinkerPosition];
+
+        var glyphHeight = FontSize;
+
+        var x = glyphPosition.X + Offset.X;
+        var y = glyphPosition.Y + Offset.Y;
+
+        paint.StrokeWidth = CaretWidth;
+
+        VecD from = new VecD(x, y + glyphHeight / 4f);
+        VecD to = new VecD(x, y - glyphHeight);
+
+        if (DateTime.Now - lastUpdate > TimeSpan.FromMilliseconds(500))
+        {
+            visible = !visible;
+            lastUpdate = DateTime.Now;
+        }
+
+        paint.Color = new Color(Colors.White.R, Colors.White.G, Colors.White.B, (byte)(visible ? 255 : 0));
+
+        canvas.DrawLine(from, to, paint);
+    }
+
+    public void Dispose()
+    {
+        paint.Dispose();
+    }
+}

+ 746 - 0
src/PixiEditor/Views/Overlays/TextOverlay/TextOverlay.cs

@@ -0,0 +1,746 @@
+using Avalonia;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+using PixiEditor.Extensions.UI.Overlays;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.UI;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.Input;
+using PixiEditor.OperatingSystem;
+using Canvas = Drawie.Backend.Core.Surfaces.Canvas;
+
+namespace PixiEditor.Views.Overlays.TextOverlay;
+
+internal class TextOverlay : Overlay
+{
+    public static readonly StyledProperty<string> TextProperty = AvaloniaProperty.Register<TextOverlay, string>(
+        nameof(Text));
+
+    public string Text
+    {
+        get => GetValue(TextProperty);
+        set => SetValue(TextProperty, value);
+    }
+
+    public static readonly StyledProperty<VecD> PositionProperty = AvaloniaProperty.Register<TextOverlay, VecD>(
+        nameof(Position));
+
+    public VecD Position
+    {
+        get => GetValue(PositionProperty);
+        set => SetValue(PositionProperty, value);
+    }
+
+    public static readonly StyledProperty<Font> FontProperty = AvaloniaProperty.Register<TextOverlay, Font>(
+        nameof(Font));
+
+    public Font Font
+    {
+        get => GetValue(FontProperty);
+        set => SetValue(FontProperty, value);
+    }
+
+    public static readonly StyledProperty<int> CursorPositionProperty = AvaloniaProperty.Register<TextOverlay, int>(
+        nameof(CursorPosition), coerce: ClampValue);
+
+    public int CursorPosition
+    {
+        get => GetValue(CursorPositionProperty);
+        set => SetValue(CursorPositionProperty, value);
+    }
+
+    public static readonly StyledProperty<int> SelectionEndProperty = AvaloniaProperty.Register<TextOverlay, int>(
+        nameof(SelectionEnd), coerce: ClampValue);
+
+    public int SelectionEnd
+    {
+        get => GetValue(SelectionEndProperty);
+        set => SetValue(SelectionEndProperty, value);
+    }
+
+    public static readonly StyledProperty<Matrix3X3> MatrixProperty = AvaloniaProperty.Register<TextOverlay, Matrix3X3>(
+        nameof(Matrix), Matrix3X3.Identity);
+
+    public Matrix3X3 Matrix
+    {
+        get => GetValue(MatrixProperty);
+        set => SetValue(MatrixProperty, value);
+    }
+
+    public static readonly StyledProperty<ExecutionTrigger<string>> RequestEditTextProperty =
+        AvaloniaProperty.Register<TextOverlay, ExecutionTrigger<string>>(
+            nameof(RequestEditText));
+
+    public ExecutionTrigger<string> RequestEditText
+    {
+        get => GetValue(RequestEditTextProperty);
+        set => SetValue(RequestEditTextProperty, value);
+    }
+
+    public static readonly StyledProperty<bool> IsEditingProperty = AvaloniaProperty.Register<TextOverlay, bool>(
+        nameof(IsEditing));
+
+    public bool IsEditing
+    {
+        get => GetValue(IsEditingProperty);
+        set => SetValue(IsEditingProperty, value);
+    }
+
+    public static readonly StyledProperty<double?> SpacingProperty = AvaloniaProperty.Register<TextOverlay, double?>(
+        nameof(Spacing));
+
+    public double? Spacing
+    {
+        get => GetValue(SpacingProperty);
+        set => SetValue(SpacingProperty, value);
+    }
+
+    private Dictionary<KeyCombination, Action> shortcuts;
+
+    private Caret caret = new Caret();
+    private VecF[] glyphPositions;
+    private float[] glyphWidths;
+    private RichText richText;
+    private VecD movedDistance;
+    private VecD initialPos;
+    private bool isLmbPressed;
+    private bool clickHandled;
+
+    private Paint selectionPaint;
+    private Paint opacityPaint;
+
+    private int lastXMovementCursorIndex;
+
+    static TextOverlay()
+    {
+        IsVisibleProperty.Changed.Subscribe(IsVisibleChanged);
+        RequestEditTextProperty.Changed.Subscribe(RequestEditTextChanged);
+        IsEditingProperty.Changed.Subscribe(IsEditingChanged);
+        TextProperty.Changed.Subscribe(TextChanged);
+        FontProperty.Changed.Subscribe(FontChanged);
+        SpacingProperty.Changed.Subscribe(SpaceChanged);
+
+        AffectsOverlayRender(FontProperty, TextProperty, CursorPositionProperty, SelectionEndProperty,
+            IsEditingProperty,
+            MatrixProperty, SpacingProperty);
+    }
+
+    public TextOverlay()
+    {
+        shortcuts = new Dictionary<KeyCombination, Action>
+        {
+            { new KeyCombination(Key.C, KeyModifiers.Control), () => CopyText() },
+            { new KeyCombination(Key.C, KeyModifiers.Control | KeyModifiers.Shift), () => CopyText(true) },
+            { new KeyCombination(Key.X, KeyModifiers.Control), CutText },
+            { new KeyCombination(Key.V, KeyModifiers.Control), PasteText },
+            { new KeyCombination(Key.Delete, KeyModifiers.None), () => DeleteChar(0) },
+            { new KeyCombination(Key.Back, KeyModifiers.None), () => DeleteChar(-1) },
+            { new KeyCombination(Key.Left, KeyModifiers.None), () => MoveCursorBy(new VecI(-1, 0)) },
+            { new KeyCombination(Key.Right, KeyModifiers.None), () => MoveCursorBy(new VecI(1, 0)) },
+            { new KeyCombination(Key.Up, KeyModifiers.None), () => MoveCursorBy(new VecI(0, -1)) },
+            { new KeyCombination(Key.Down, KeyModifiers.None), () => MoveCursorBy(new VecI(0, 1)) },
+            { new KeyCombination(Key.Left, KeyModifiers.Shift), () => MoveCursorBy(new VecI(-1, 0), false) },
+            { new KeyCombination(Key.Right, KeyModifiers.Shift), () => MoveCursorBy(new VecI(1, 0), false) },
+            { new KeyCombination(Key.Up, KeyModifiers.Shift), () => MoveCursorBy(new VecI(0, -1), false) },
+            { new KeyCombination(Key.Down, KeyModifiers.Shift), () => MoveCursorBy(new VecI(0, 1), false) },
+            {
+                new KeyCombination(Key.Left, KeyModifiers.Control),
+                () => MoveCursorBy(new VecI(-1, 0), mode: MoveMode.Words)
+            },
+            {
+                new KeyCombination(Key.Right, KeyModifiers.Control),
+                () => MoveCursorBy(new VecI(1, 0), mode: MoveMode.Words)
+            },
+            {
+                new KeyCombination(Key.Up, KeyModifiers.Control),
+                () => MoveCursorBy(new VecI(0, -1), mode: MoveMode.Words)
+            },
+            {
+                new KeyCombination(Key.Down, KeyModifiers.Control),
+                () => MoveCursorBy(new VecI(0, 1), mode: MoveMode.Words)
+            },
+            {
+                new KeyCombination(Key.Left, KeyModifiers.Control | KeyModifiers.Shift),
+                () => MoveCursorBy(new VecI(-1, 0), false, mode: MoveMode.Words)
+            },
+            {
+                new KeyCombination(Key.Right, KeyModifiers.Control | KeyModifiers.Shift),
+                () => MoveCursorBy(new VecI(1, 0), false, mode: MoveMode.Words)
+            },
+            {
+                new KeyCombination(Key.Up, KeyModifiers.Control | KeyModifiers.Shift),
+                () => MoveCursorBy(new VecI(0, -1), false, mode: MoveMode.Words)
+            },
+            {
+                new KeyCombination(Key.Down, KeyModifiers.Control | KeyModifiers.Shift),
+                () => MoveCursorBy(new VecI(0, 1), false, mode: MoveMode.Words)
+            },
+            { new KeyCombination(Key.Escape, KeyModifiers.None), () => IsEditing = false },
+            { new KeyCombination(Key.A, KeyModifiers.Control), SelectAll },
+            { new KeyCombination(Key.Home, KeyModifiers.None), () => GoToStartOfLine(true) },
+            { new KeyCombination(Key.Home, KeyModifiers.Shift), () => GoToStartOfLine(false) },
+            { new KeyCombination(Key.End, KeyModifiers.None), () => GoToEndOfLine(true) },
+            { new KeyCombination(Key.End, KeyModifiers.Shift), () => GoToEndOfLine(false) },
+        };
+
+        AdjustShortcutsForOS();
+
+        selectionPaint = new Paint()
+        {
+            Color = ThemeResources.SelectionFillColor.WithAlpha(255), Style = PaintStyle.Fill
+        };
+
+        opacityPaint = new Paint() { Color = Colors.White.WithAlpha(ThemeResources.SelectionFillColor.A) };
+    }
+
+
+    public override void RenderOverlay(Canvas context, RectD canvasBounds)
+    {
+        if (!IsEditing) return;
+
+        int saved = context.Save();
+
+        context.SetMatrix(context.TotalMatrix.Concat(Matrix));
+
+        RenderCaret(context);
+        RenderSelection(context);
+
+        context.RestoreToCount(saved);
+        Refresh();
+    }
+
+    private void RenderCaret(Canvas context)
+    {
+        caret.CaretPosition = CursorPosition;
+        caret.FontSize = Font.Size;
+        caret.GlyphPositions = glyphPositions;
+        caret.GlyphWidths = glyphWidths;
+        caret.Offset = Position;
+
+        caret.CaretWidth = 3f / (float)ZoomScale;
+        caret.Render(context);
+    }
+
+    private void RenderSelection(Canvas context)
+    {
+        if (CursorPosition == SelectionEnd) return;
+
+        int begin = Math.Min(CursorPosition, SelectionEnd);
+        int end = Math.Max(CursorPosition, SelectionEnd);
+
+        richText.IndexOnLine(CursorPosition, out int lineStart);
+
+        RectD? currentLineBounds = null;
+        int lastLine = lineStart;
+        int saved = context.SaveLayer(opacityPaint);
+
+        for (int i = begin; i <= end; i++)
+        {
+            richText.IndexOnLine(i, out int line);
+
+            if (line != lastLine || i == end)
+            {
+                if (currentLineBounds != null)
+                {
+                    context.DrawRect(currentLineBounds.Value, selectionPaint);
+                }
+
+                currentLineBounds = null;
+            }
+
+            lastLine = line;
+
+            double x = glyphPositions[i].X;
+            double width = glyphWidths[i];
+            VecD lineOffset = richText.GetLineOffset(line, Font);
+            RectD selectionBounds =
+                new RectD(new VecD(x, -Font.Size + lineOffset.Y), new VecD(width, Font.Size * 1.25f)).Offset(Position);
+            if (currentLineBounds == null)
+            {
+                currentLineBounds = selectionBounds;
+            }
+            else
+            {
+                currentLineBounds = currentLineBounds.Value.Union(selectionBounds);
+            }
+        }
+
+        context.RestoreToCount(saved);
+    }
+
+    public override bool TestHit(VecD point)
+    {
+        VecD mapped = Matrix.Invert().MapPoint(point);
+        return richText != null && richText.MeasureBounds(Font).Offset(Position).Inflate(2).ContainsInclusive(mapped);
+    }
+
+    protected override void OnOverlayPointerPressed(OverlayPointerArgs args)
+    {
+        args.Handled = true;
+        clickHandled = args.ClickCount == 2;
+        if (args.ClickCount == 2)
+        {
+            SelectWordAtPosition(args.Point);
+        }
+
+        movedDistance = VecD.Zero;
+        initialPos = args.Point;
+        isLmbPressed = args.PointerButton == MouseButton.Left;
+        args.Pointer.Capture(this);
+    }
+
+    protected override void OnOverlayPointerMoved(OverlayPointerArgs args)
+    {
+        movedDistance = args.Point - initialPos;
+        if (isLmbPressed && !clickHandled)
+        {
+            if (movedDistance.Length > 2)
+            {
+                SetCursorPosToPosition(args.Point);
+                SetSelectionEndToPosition(initialPos);
+            }
+        }
+    }
+
+    protected override void OnOverlayPointerReleased(OverlayPointerArgs args)
+    {
+        if (movedDistance.Length < 2 && !clickHandled)
+        {
+            if (args.InitialPressMouseButton == MouseButton.Left)
+            {
+                if (!IsEditing)
+                {
+                    IsEditing = true;
+                }
+
+                SetCursorPosToPosition(args.Point);
+            }
+        }
+
+        isLmbPressed = false;
+    }
+
+    protected override void OnOverlayPointerEntered(OverlayPointerArgs args)
+    {
+        Cursor = new Cursor(StandardCursorType.Ibeam);
+    }
+
+    protected override void OnOverlayPointerExited(OverlayPointerArgs args)
+    {
+        Cursor = new Cursor(StandardCursorType.Arrow);
+    }
+
+    private void SetCursorPosToPosition(VecD point)
+    {
+        var indexOfClosest = GetClosestCharacterIndex(point);
+
+        CursorPosition = indexOfClosest;
+        SelectionEnd = indexOfClosest;
+    }
+
+    private void SetSelectionEndToPosition(VecD point)
+    {
+        var indexOfClosest = GetClosestCharacterIndex(point);
+        SelectionEnd = indexOfClosest;
+    }
+
+    private void SelectAll()
+    {
+        CursorPosition = 0;
+        SelectionEnd = Text.Length;
+    }
+
+    private void GoToStartOfLine(bool updateSelection)
+    {
+        richText.IndexOnLine(CursorPosition, out int lineIndex);
+        int lineStart = richText.GetLineStartEnd(lineIndex).lineStart;
+        CursorPosition = lineStart;
+        if (updateSelection)
+        {
+            SelectionEnd = CursorPosition;
+        }
+    }
+
+    private void GoToEndOfLine(bool updateSelection)
+    {
+        richText.IndexOnLine(CursorPosition, out int lineIndex);
+        int lineEnd = richText.GetLineStartEnd(lineIndex).lineEnd - 1;
+        CursorPosition = lineEnd;
+        if (updateSelection)
+        {
+            SelectionEnd = CursorPosition;
+        }
+    }
+
+    private void SelectWordAtPosition(VecD point)
+    {
+        var indexOfClosest = GetClosestCharacterIndex(point);
+        int start = indexOfClosest;
+        int end = indexOfClosest;
+
+        while (start > 0 && !char.IsWhiteSpace(Text[start - 1]))
+        {
+            start--;
+        }
+
+        while (end < Text.Length - 1 && !char.IsWhiteSpace(Text[end + 1]))
+        {
+            end++;
+        }
+
+        CursorPosition = start;
+        SelectionEnd = end + 1;
+    }
+
+    private void CopyText(bool asUnicode = false)
+    {
+        if (CursorPosition == SelectionEnd) return;
+        string selectedText = Text.Substring(
+            Math.Min(CursorPosition, SelectionEnd),
+            Math.Abs(CursorPosition - SelectionEnd));
+
+        if (asUnicode)
+        {
+            selectedText = string.Join(" ", selectedText.Select(c => $"U+{((int)c):X4}"));
+        }
+
+        ClipboardController.Clipboard.SetTextAsync(selectedText);
+    }
+
+    private void CutText()
+    {
+        CopyText();
+        DeleteChar(0);
+    }
+
+    private int GetClosestCharacterIndex(VecD point)
+    {
+        VecD mapped = Matrix.Invert().MapPoint(point);
+        var positions = richText.GetGlyphPositions(Font);
+        int indexOfClosest = positions.Select((pos, index) => (pos, index))
+            .OrderBy(pos => ((pos.pos + Position - new VecD(0, Font.Size / 2f)) - mapped).LengthSquared)
+            .First().index;
+        return indexOfClosest;
+    }
+
+    protected override void OnKeyPressed(Key key, KeyModifiers keyModifiers, string? keySymbol)
+    {
+        if (!IsEditing) return;
+
+        ShortcutController.BlockShortcutExecution(nameof(TextOverlay));
+
+        if (IsUndoRedoShortcut(key, keyModifiers))
+        {
+            ShortcutController.UnblockShortcutExecution(nameof(TextOverlay));
+            return;
+        }
+
+        if (IsShortcut(key, keyModifiers))
+        {
+            ExecuteShortcut(key, keyModifiers);
+            return;
+        }
+
+        InsertChar(key, keySymbol);
+    }
+
+    private bool IsUndoRedoShortcut(Key key, KeyModifiers keyModifiers)
+    {
+        return key == Key.Z && keyModifiers == KeyModifiers.Control ||
+               key == Key.Y && keyModifiers == KeyModifiers.Control;
+    }
+
+    private void InsertChar(Key key, string symbol)
+    {
+        if (key == Key.Enter)
+        {
+            InsertTextAtCursor("\n");
+        }
+        else if (key == Key.Space)
+        {
+            InsertTextAtCursor(" ");
+        }
+        else
+        {
+            if (symbol is { Length: 1 })
+            {
+                char symbolChar = symbol[0];
+                if (char.IsControl(symbolChar)) return;
+                InsertTextAtCursor(symbol);
+            }
+        }
+    }
+
+    private void InsertTextAtCursor(string toAdd)
+    {
+        if (CursorPosition == SelectionEnd)
+        {
+            Text = Text.Insert(CursorPosition, toAdd);
+            CursorPosition += toAdd.Length;
+            SelectionEnd += toAdd.Length;
+        }
+        else
+        {
+            string newText = Text.Remove(Math.Min(CursorPosition, SelectionEnd),
+                Math.Abs(CursorPosition - SelectionEnd));
+            Text = newText.Insert(Math.Min(CursorPosition, SelectionEnd), toAdd);
+            CursorPosition = Math.Min(CursorPosition, SelectionEnd) + toAdd.Length;
+            SelectionEnd = CursorPosition;
+        }
+
+        lastXMovementCursorIndex = CursorPosition;
+    }
+
+    private bool IsShortcut(Key key, KeyModifiers keyModifiers)
+    {
+        return shortcuts.ContainsKey(new KeyCombination(key, keyModifiers));
+    }
+
+    private void ExecuteShortcut(Key key, KeyModifiers keyModifiers)
+    {
+        KeyCombination shortcut = new(key, keyModifiers);
+        if (shortcuts.ContainsKey(shortcut))
+        {
+            shortcuts[shortcut].Invoke();
+        }
+    }
+
+    private void PasteText()
+    {
+        ClipboardController.GetTextFromClipboard().ContinueWith(
+            t =>
+            {
+                Dispatcher.UIThread.Invoke(() => InsertTextAtCursor(t.Result));
+            }, TaskContinuationOptions.OnlyOnRanToCompletion);
+    }
+
+    private void DeleteChar(int direction)
+    {
+        if (SelectionEnd != CursorPosition)
+        {
+            Text = Text.Remove(Math.Min(CursorPosition, SelectionEnd),
+                Math.Abs(CursorPosition - SelectionEnd));
+            CursorPosition = Math.Min(CursorPosition, SelectionEnd);
+            SelectionEnd = CursorPosition;
+        }
+        else if (Text.Length > 0 && CursorPosition + direction >= 0 && CursorPosition + direction < Text.Length)
+        {
+            Text = Text.Remove(CursorPosition + direction, 1);
+            CursorPosition += direction;
+            SelectionEnd = CursorPosition;
+        }
+
+        lastXMovementCursorIndex = CursorPosition;
+    }
+
+    private void MoveCursorBy(VecI direction, bool updateSelection = true, MoveMode mode = MoveMode.Characters)
+    {
+        int moveBy = direction.X;
+        if (direction.X != 0)
+        {
+            lastXMovementCursorIndex = Math.Clamp(CursorPosition + direction.X, 0, Text.Length);
+
+            if (mode == MoveMode.Words)
+            {
+                string[] words = richText.FormattedText.Split(' ');
+                int i = 0;
+                int cursorPosInWord = 0;
+                int wordIndex = 0;
+
+                for (var index = 0; index < words.Length; index++)
+                {
+                    var word = words[index];
+                    if (CursorPosition >= i && CursorPosition <= i + word.Length)
+                    {
+                        cursorPosInWord = CursorPosition - i;
+                        wordIndex = index;
+                        break;
+                    }
+
+                    i += word.Length + 1;
+                }
+
+                if (cursorPosInWord > 0 && cursorPosInWord < words[wordIndex].Length)
+                {
+                    if (moveBy < 0)
+                    {
+                        moveBy = -cursorPosInWord;
+                    }
+                    else
+                    {
+                        moveBy = words[wordIndex].Length - cursorPosInWord;
+                    }
+                }
+                else
+                {
+                    int wordLength = words[wordIndex].Length;
+                    if (wordLength > 0)
+                    {
+                        if (moveBy > 0 && cursorPosInWord == 0)
+                        {
+                            moveBy += wordLength - 1;
+                        }
+                        else if (moveBy < 0 && cursorPosInWord == wordLength)
+                        {
+                            moveBy -= words[wordIndex].Length - 1;
+                        }
+                    }
+                }
+            }
+        }
+
+        if (direction.Y != 0)
+        {
+            richText.IndexOnLine(CursorPosition, out int lineIndex);
+
+            int clampedDesiredLineIndex = Math.Clamp(lineIndex + direction.Y, 0, richText.Lines.Length - 1);
+
+            VecF position = glyphPositions[lastXMovementCursorIndex];
+            (int lineStart, int lineEnd) = richText.GetLineStartEnd(clampedDesiredLineIndex);
+            VecF[] lineGlyphPositions = glyphPositions[lineStart..lineEnd];
+            int closestIndex = lineGlyphPositions.Select((pos, i) => (i, pos))
+                .OrderBy(pos => Math.Abs(pos.pos.X - position.X)).First().i;
+            moveBy = richText.GetIndexOnLine(clampedDesiredLineIndex, closestIndex) - CursorPosition;
+        }
+
+        CursorPosition += moveBy;
+        if (updateSelection)
+        {
+            SelectionEnd = CursorPosition;
+        }
+    }
+
+    private void RequestEditTextTriggered(object? sender, string e)
+    {
+        IsEditing = true;
+        CursorPosition = glyphPositions.Length;
+        SelectionEnd = CursorPosition;
+    }
+
+    private void UpdateGlyphs()
+    {
+        if (Font == null) return;
+
+        richText = new(Text);
+        richText.Spacing = Spacing;
+        glyphPositions = richText.GetGlyphPositions(Font);
+        glyphWidths = richText.GetGlyphWidths(Font);
+    }
+
+    private void AdjustShortcutsForOS()
+    {
+        if (IOperatingSystem.Current.IsMacOs)
+        {
+            Dictionary<KeyCombination, Action> newShortcuts = new();
+            foreach (var shortcut in shortcuts)
+            {
+                if (shortcut.Key.Modifiers.HasFlag(KeyModifiers.Control))
+                {
+                    KeyModifiers newModifiers = shortcut.Key.Modifiers & ~KeyModifiers.Control;
+                    newModifiers |= KeyModifiers.Meta;
+                    newShortcuts.Add(new KeyCombination(shortcut.Key.Key, newModifiers), shortcut.Value);
+                }
+                else
+                {
+                    newShortcuts.Add(shortcut.Key, shortcut.Value);
+                }
+            }
+
+            shortcuts = newShortcuts;
+        }
+    }
+
+    private static void IsVisibleChanged(AvaloniaPropertyChangedEventArgs<bool> args)
+    {
+        TextOverlay sender = args.Sender as TextOverlay;
+        if (sender == null) return;
+
+        if (!args.NewValue.Value)
+        {
+            sender.IsEditing = false;
+        }
+    }
+
+    private static void RequestEditTextChanged(AvaloniaPropertyChangedEventArgs<ExecutionTrigger<string>> args)
+    {
+        var sender = args.Sender as TextOverlay;
+        if (args.OldValue.Value != null)
+        {
+            args.OldValue.Value.Triggered -= sender.RequestEditTextTriggered;
+        }
+
+        if (args.NewValue.Value != null)
+        {
+            args.NewValue.Value.Triggered += sender.RequestEditTextTriggered;
+        }
+    }
+
+    private static void IsEditingChanged(AvaloniaPropertyChangedEventArgs<bool> args)
+    {
+        if (args.NewValue.Value)
+        {
+            ShortcutController.BlockShortcutExecution(nameof(TextOverlay));
+            TextOverlay sender = args.Sender as TextOverlay;
+            sender.UpdateGlyphs();
+
+            if (sender.CursorPosition > sender.glyphPositions.Length)
+            {
+                sender.CursorPosition = sender.glyphPositions.Length;
+            }
+
+            if (sender.SelectionEnd > sender.glyphPositions.Length)
+            {
+                sender.SelectionEnd = sender.glyphPositions.Length;
+            }
+
+            sender.lastXMovementCursorIndex = sender.CursorPosition;
+
+            sender.FocusOverlay();
+        }
+        else
+        {
+            ShortcutController.UnblockShortcutExecution(nameof(TextOverlay));
+        }
+    }
+
+    private static void TextChanged(AvaloniaPropertyChangedEventArgs<string> args)
+    {
+        TextOverlay sender = args.Sender as TextOverlay;
+        sender.UpdateGlyphs();
+    }
+
+    private static void FontChanged(AvaloniaPropertyChangedEventArgs<Font> args)
+    {
+        TextOverlay sender = args.Sender as TextOverlay;
+        sender.UpdateGlyphs();
+    }
+
+    private static void SpaceChanged(AvaloniaPropertyChangedEventArgs<double?> args)
+    {
+        TextOverlay sender = args.Sender as TextOverlay;
+        sender.UpdateGlyphs();
+    }
+
+    private static int ClampValue(AvaloniaObject sender, int newPos)
+    {
+        TextOverlay textOverlay = sender as TextOverlay;
+        if (textOverlay == null) return newPos;
+        if (textOverlay.Text == null) return 0;
+
+        if (textOverlay.glyphPositions == null) return 0;
+
+        return Math.Clamp(newPos, 0, textOverlay.glyphPositions.Length - 1);
+    }
+}
+
+public enum MoveMode
+{
+    Characters,
+    Words,
+}

+ 13 - 2
src/PixiEditor/Views/Overlays/TransformOverlay/TransformOverlay.cs

@@ -293,6 +293,8 @@ internal class TransformOverlay : Overlay
     private VecD lastSize;
     private bool actuallyMoved = false;
     private bool isShearing = false;
+    private int lastClickCount = 0;
+    private bool pressedWithinBounds = false;
 
     public TransformOverlay()
     {
@@ -549,9 +551,13 @@ internal class TransformOverlay : Overlay
     {
         if (args.PointerButton != MouseButton.Left)
             return;
+        
+        lastClickCount = args.ClickCount;
 
         if (Handles.Any(x => x.IsWithinHandle(x.Position, args.Point, ZoomScale))) return;
 
+        pressedWithinBounds = TestHit(args.Point);
+
         if (CanShear(args.Point, out var side))
         {
             StartShearing(args, side);
@@ -648,12 +654,16 @@ internal class TransformOverlay : Overlay
     protected override void OnOverlayPointerReleased(OverlayPointerArgs e)
     {
         if (e.InitialPressMouseButton != MouseButton.Left)
+        {
+            pressedWithinBounds = false;
             return;
+        }
 
-        if (!isRotating && !actuallyMoved)
+        if (!isRotating && !actuallyMoved && pressedWithinBounds)
         {
-            MouseOnCanvasEventArgs args = new(MouseButton.Left, e.Point, e.Modifiers);
+            MouseOnCanvasEventArgs args = new(MouseButton.Left, e.Point, e.Modifiers, lastClickCount);
             PassthroughPointerPressedCommand?.Execute(args);
+            lastClickCount = 0;
         }
 
         if (isRotating)
@@ -678,6 +688,7 @@ internal class TransformOverlay : Overlay
         StopMoving();
         IsSizeBoxEnabled = false;
         capturedAnchor = null;
+        pressedWithinBounds = false;
     }
 
     public override bool TestHit(VecD point)

+ 9 - 0
src/PixiEditor/Views/Rendering/Scene.cs

@@ -492,9 +492,16 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
             InitialPressMouseButton = e is PointerReleasedEventArgs released
                 ? released.InitialPressMouseButton
                 : MouseButton.None,
+            ClickCount = e is PointerPressedEventArgs pressed ? pressed.ClickCount : 0,
+            
         };
     }
 
+    private void FocusOverlay()
+    {
+        Focus();
+    }
+
     private VecD ToCanvasSpace(Point scenePosition)
     {
         Matrix transform = CalculateTransformMatrix();
@@ -546,6 +553,7 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
             {
                 overlay.RefreshRequested -= QueueRender;
                 overlay.RefreshCursorRequested -= RefreshCursor;
+                overlay.FocusRequested -= FocusOverlay;
             }
         }
 
@@ -555,6 +563,7 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
             {
                 overlay.RefreshRequested += QueueRender;
                 overlay.RefreshCursorRequested += RefreshCursor;
+                overlay.FocusRequested += FocusOverlay;
             }
         }
     }

+ 28 - 4
src/PixiEditor/Views/Tools/ToolSettings/Settings/BoolSettingView.axaml

@@ -3,11 +3,35 @@
              xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              xmlns:settings="clr-namespace:PixiEditor.ViewModels.Tools.ToolSettings.Settings"
+             xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
+             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.Views.Tools.ToolSettings.Settings.BoolSettingView">
     <Design.DataContext>
-        <settings:BoolSettingViewModel/>
+        <settings:BoolSettingViewModel />
     </Design.DataContext>
-    
-    <CheckBox VerticalAlignment="Center" Focusable="False" IsChecked="{Binding Value, Mode=TwoWay}"/>
-</UserControl>
+
+    <Grid>
+        <CheckBox VerticalAlignment="Center" Focusable="False" IsChecked="{Binding Value, Mode=TwoWay}">
+           <CheckBox.IsVisible>
+               <MultiBinding Converter="{converters:AllTrueConverter}">
+                   <Binding Path="HasLabel"/>
+                   <Binding Path="!HasIcon"/>
+               </MultiBinding>
+           </CheckBox.IsVisible>
+        </CheckBox>
+        <ToggleButton VerticalAlignment="Center" Focusable="False"
+                      ui:Translator.TooltipKey="{Binding Tooltip}"
+                      IsChecked="{Binding Value, Mode=TwoWay}"
+                      FontSize="18"
+                      Padding="5"
+                      Classes="pixi-icon" Content="{Binding Icon}">
+            <ToggleButton.IsVisible>
+                <MultiBinding Converter="{converters:AllTrueConverter}">
+                    <Binding Path="HasIcon"/>
+                    <Binding Path="!HasLabel"/>
+                </MultiBinding>
+            </ToggleButton.IsVisible>
+        </ToggleButton>
+    </Grid>
+</UserControl>

+ 31 - 0
src/PixiEditor/Views/Tools/ToolSettings/Settings/FontFamilySettingView.axaml

@@ -0,0 +1,31 @@
+<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:settings="clr-namespace:PixiEditor.ViewModels.Tools.ToolSettings.Settings"
+             xmlns:enums="clr-namespace:PixiEditor.ChangeableDocument.Enums;assembly=PixiEditor.ChangeableDocument"
+             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+             xmlns:helpers="clr-namespace:PixiEditor.Helpers"
+             xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="PixiEditor.Views.Tools.ToolSettings.Settings.FontFamilySettingView">
+    <Design.DataContext>
+        <settings:FontFamilySettingViewModel />
+    </Design.DataContext>
+
+    <StackPanel Orientation="Horizontal" Spacing="5">
+        <Button Classes="pixi-icon" FontSize="16"
+                ui:Translator.TooltipKey="CUSTOM_FONT"
+                Command="{Binding UploadFontCommand}" Content="{DynamicResource icon-upload}"/>
+        <ComboBox VerticalAlignment="Center"
+                  MinWidth="85"
+                  ItemsSource="{Binding Fonts}"
+                  SelectedIndex="{Binding FontIndex, Mode=TwoWay}">
+            <ComboBox.ItemTemplate>
+                <DataTemplate>
+                    <TextBlock Text="{Binding Name}" FontFamily="{Binding Converter={converters:FontFamilyNameToAvaloniaFontFamily}}" />
+                </DataTemplate>
+            </ComboBox.ItemTemplate>
+        </ComboBox>
+    </StackPanel>
+</UserControl>

+ 15 - 0
src/PixiEditor/Views/Tools/ToolSettings/Settings/FontFamilySettingView.axaml.cs

@@ -0,0 +1,15 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.Views.Tools.ToolSettings.Settings;
+
+public partial class FontFamilySettingView : UserControl
+{
+    public FontFamilySettingView()
+    {
+        InitializeComponent();
+    }
+}
+

+ 1 - 0
src/PixiEditor/Views/Tools/ToolSettings/Settings/SizeSettingView.axaml

@@ -14,6 +14,7 @@
                      MaxSize="{Binding Max}" 
                      MinSize="{Binding Min}"
                      Decimals="{Binding DecimalPlaces}"
+                     Unit="{Binding Unit}"
                      IsEnabled="{Binding IsEnabled}" FocusNext="False"
                      Size="{Binding Value, Mode=TwoWay}"/>
 </UserControl>

+ 1 - 1
src/PixiParser

@@ -1 +1 @@
-Subproject commit 357930a8fcc6669df3a4fb6411bdbf7ad3f4e7e6
+Subproject commit 5b632b4c649f8e88ec8616a383fac56afd728e34