Browse Source

More files ported

Krzysztof Krysiński 2 years ago
parent
commit
459813aee9
100 changed files with 8266 additions and 114 deletions
  1. 43 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Collections/ActionDisplayList.cs
  2. 80 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ColorHelper.cs
  3. 7 3
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/DocumentViewModelBuilder.cs
  4. 54 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/LockedFramebufferExtensions.cs
  5. 124 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/PixiParserDocumentEx.cs
  6. 35 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ProcessHelper.cs
  7. 176 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SerializableDocumentEx.cs
  8. 2 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ServiceCollectionHelpers.cs
  9. 27 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/StringHelpers.cs
  10. 0 15
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/StructureHelpers.cs
  11. 58 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SurfaceHelpers.cs
  12. 35 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/WriteableBitmapHelpers.cs
  13. 1 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Initialization/ClassicDesktopEntry.cs
  14. 16 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/LayersChangedEventArgs.cs
  15. 19 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentSizeChangedEventArgs.cs
  16. 5 5
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentUpdater.cs
  17. 1 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentOperationsModule.cs
  18. 3 3
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentToolsModule.cs
  19. 14 12
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocument.cs
  20. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocumentOperations.cs
  21. 3 3
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ILineOverlayHandler.cs
  22. 4 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IReferenceLayerHandler.cs
  23. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IStructureMemberHandler.cs
  24. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/Toolbars/IBasicShapeToolbar.cs
  25. 1 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/Tools/IRectangleToolHandler.cs
  26. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Layers/LayerAction.cs
  27. 13 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Preferences/RightClickMode.cs
  28. 4 4
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/MemberPreviewUpdater.cs
  29. 54 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/UserData/RecentlyOpenedCollection.cs
  30. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/PixiEditor.Avalonia.csproj
  31. 16 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentChangedEventArgs.cs
  32. 181 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentManagerViewModel.cs
  33. 164 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentViewModel.Serialization.cs
  34. 623 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentViewModel.cs
  35. 11 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/FolderViewModel.cs
  36. 41 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/LayerViewModel.cs
  37. 160 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/ReferenceLayerViewModel.cs
  38. 175 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/StructureMemberViewModel.cs
  39. 251 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/DocumentTransformViewModel.cs
  40. 121 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/LineToolOverlayViewModel.cs
  41. 28 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/TransformOverlayActionType.cs
  42. 76 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/TransformOverlayUndoStack.cs
  43. 0 49
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/MainViewModel.cs
  44. 180 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ClipboardViewModel.cs
  45. 369 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ColorsViewModel.cs
  46. 264 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/DebugViewModel.cs
  47. 231 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/DiscordViewModel.cs
  48. 414 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/FileViewModel.cs
  49. 301 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/IoViewModel.cs
  50. 424 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/LayersViewModel.cs
  51. 37 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/MiscViewModel.cs
  52. 89 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/SelectionViewModel.cs
  53. 266 7
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ToolsViewModel.cs
  54. 70 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/UndoViewModel.cs
  55. 260 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/UpdateViewModel.cs
  56. 37 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ViewOptionsViewModel.cs
  57. 16 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/SelectedToolEventArgs.cs
  58. 21 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ShapeTool.cs
  59. 14 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/SettingValueChangedEventArgs.cs
  60. 46 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/BoolSetting.cs
  61. 53 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/ColorSetting.cs
  62. 107 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/EnumSetting.cs
  63. 50 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/FloatSetting.cs
  64. 89 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/Setting.cs
  65. 42 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/SizeSetting.cs
  66. 18 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/BasicShapeToolbar.cs
  67. 27 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/BasicToolbar.cs
  68. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/EmptyToolbar.cs
  69. 87 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/SettingAttributes.cs
  70. 92 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/Toolbar.cs
  71. 104 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/ToolbarFactory.cs
  72. 105 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolViewModel.cs
  73. 63 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/BrightnessToolViewModel.cs
  74. 166 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/ColorPickerToolViewModel.cs
  75. 44 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/EllipseToolViewModel.cs
  76. 37 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/EraserToolViewModel.cs
  77. 50 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/FloodFillToolViewModel.cs
  78. 57 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/LassoToolViewModel.cs
  79. 48 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/LineToolViewModel.cs
  80. 37 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/MagicWandToolViewModel.cs
  81. 76 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/MoveToolViewModel.cs
  82. 26 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/MoveViewportToolViewModel.cs
  83. 104 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/PenToolViewModel.cs
  84. 47 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/RectangleToolViewModel.cs
  85. 31 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/RotateViewportToolViewModel.cs
  86. 65 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/SelectToolViewModel.cs
  87. 47 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/ZoomToolViewModel.cs
  88. 301 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/ViewModelMain.cs
  89. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Dialogs/ConfirmationType.cs
  90. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainView.axaml
  91. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainWindow.axaml.cs
  92. 13 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/BrushShapeOverlay/BrushShape.cs
  93. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/Anchor.cs
  94. 9 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformCornerFreedom.cs
  95. 303 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformHelper.cs
  96. 10 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformSideFreedom.cs
  97. 22 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformState.cs
  98. 221 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformUpdateHelper.cs
  99. 2 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Root/ReferenceLayerChangeInfos/SetReferenceLayer_ChangeInfo.cs
  100. 4 0
      src/PixiEditor.OperatingSystem/IOperatingSystem.cs

+ 43 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Collections/ActionDisplayList.cs

@@ -0,0 +1,43 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.Helpers.Collections;
+
+public class ActionDisplayList : IEnumerable<KeyValuePair<string, LocalizedString>>
+{
+    private Dictionary<string, LocalizedString> _dictionary = new();
+    private Action notifyUpdate;
+
+    public ActionDisplayList(Action notifyUpdate)
+    {
+        this.notifyUpdate = notifyUpdate;
+    }
+
+    public LocalizedString? this[string key]
+    {
+        get => _dictionary[key];
+        set
+        {
+            if (value == null)
+            {
+                _dictionary.Remove(key);
+                notifyUpdate();
+                return;
+            }
+
+            _dictionary[key] = value.Value;
+            notifyUpdate();
+        }
+    }
+
+    public LocalizedString GetActive() => _dictionary.Last().Value;
+
+    public bool HasActive() => _dictionary.Count != 0;
+
+    public IEnumerator<KeyValuePair<string, LocalizedString>> GetEnumerator() => _dictionary.GetEnumerator();
+
+    IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator();
+}

+ 80 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ColorHelper.cs

@@ -0,0 +1,80 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+using System.Windows;
+using Avalonia.Input;
+
+namespace PixiEditor.Helpers;
+
+public class ColorHelper
+{
+    public static bool ParseAnyFormat(IDataObject data, [NotNullWhen(true)] out DrawingApi.Core.ColorsImpl.Color? result) => 
+        ParseAnyFormat(((DataObject)data).GetText().Trim(), out result);
+    
+    public static bool ParseAnyFormatList(IDataObject data, [NotNullWhen(true)] out List<DrawingApi.Core.ColorsImpl.Color> result) => 
+        ParseAnyFormatList(((DataObject)data).GetText().Trim(), out result);
+
+    public static bool ParseAnyFormat(string value, [NotNullWhen(true)] out DrawingApi.Core.ColorsImpl.Color? result)
+    {
+        bool hex = Regex.IsMatch(value, "^#?([a-fA-F0-9]{8}|[a-fA-F0-9]{6}|[a-fA-F0-9]{3})$");
+
+        if (hex)
+        {
+            result = DrawingApi.Core.ColorsImpl.Color.Parse(value);
+            return true;
+        }
+
+        var match = Regex.Match(value, @"(?:rgba?\(?)? *(?<r>\d{1,3})(?:, *| +)(?<g>\d{1,3})(?:, *| +)(?<b>\d{1,3})(?:(?:, *| +)(?<a>\d{0,3}))?\)?");
+
+        if (!match.Success)
+        {
+            result = null;
+            return false;
+        }
+
+        byte r = byte.Parse(match.Groups["r"].ValueSpan);
+        byte g = byte.Parse(match.Groups["g"].ValueSpan);
+        byte b = byte.Parse(match.Groups["b"].ValueSpan);
+        byte a = match.Groups["a"].Success ? byte.Parse(match.Groups["a"].ValueSpan) : (byte)255;
+
+        result = new DrawingApi.Core.ColorsImpl.Color(r, g, b, a);
+        return true;
+    }
+    
+    public static bool ParseAnyFormatList(string value, [NotNullWhen(true)] out List<DrawingApi.Core.ColorsImpl.Color> result)
+    {
+        result = new List<DrawingApi.Core.ColorsImpl.Color>();
+
+        // Regex patterns for hex and RGB(A) formats
+        const string hexPattern = @"#?([a-fA-F0-9]{8}|[a-fA-F0-9]{6}|[a-fA-F0-9]{3})";
+        const string rgbaPattern = @"(?:rgba?\(?)? *(?<r>\d{1,3})(?:, *| +)(?<g>\d{1,3})(?:, *| +)(?<b>\d{1,3})(?:(?:, *| +)(?<a>\d{0,3}))?\)?";
+
+        // Combined pattern for both hex and RGB(A) formats
+        const string combinedPattern = $@"({hexPattern})|({rgbaPattern})";
+        var matches = Regex.Matches(value, combinedPattern);
+
+        if (matches.Count == 0)
+        {
+            return false;
+        }
+
+        foreach (Match match in matches)
+        {
+            if (Regex.IsMatch(match.Value, $"^{hexPattern}$"))
+            {
+                result.Add(DrawingApi.Core.ColorsImpl.Color.Parse(match.Value));
+            }
+            else if (match.Groups["r"].Success && match.Groups["g"].Success && match.Groups["b"].Success)
+            {
+                byte r = byte.Parse(match.Groups["r"].ValueSpan);
+                byte g = byte.Parse(match.Groups["g"].ValueSpan);
+                byte b = byte.Parse(match.Groups["b"].ValueSpan);
+                byte a = match.Groups["a"].Success ? byte.Parse(match.Groups["a"].ValueSpan) : (byte)255;
+
+                result.Add(new DrawingApi.Core.ColorsImpl.Color(r, g, b, a));
+            }
+        }
+
+        return result.Count > 0;
+    }
+}

+ 7 - 3
src/PixiEditor/Helpers/DocumentViewModelBuilder.cs → src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/DocumentViewModelBuilder.cs

@@ -1,4 +1,6 @@
-using System.Diagnostics.CodeAnalysis;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
 using System.Runtime.InteropServices;
 using System.Runtime.InteropServices;
 using ChunkyImageLib;
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.DataHolders;
@@ -6,6 +8,7 @@ using PixiEditor.DrawingApi.Core.ColorsImpl;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.DrawingApi.Core.Surface;
 using PixiEditor.DrawingApi.Core.Surface;
 using PixiEditor.Extensions.Palettes;
 using PixiEditor.Extensions.Palettes;
+using PixiEditor.Helpers.Extensions;
 using PixiEditor.Parser;
 using PixiEditor.Parser;
 using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
 using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
 
 
@@ -342,8 +345,9 @@ internal class DocumentViewModelBuilder : ChildrenBuilder
         public ReferenceLayerBuilder WithSurface(Surface surface)
         public ReferenceLayerBuilder WithSurface(Surface surface)
         {
         {
             var writeableBitmap = surface.ToWriteableBitmap();
             var writeableBitmap = surface.ToWriteableBitmap();
-            byte[] bytes = new byte[writeableBitmap.PixelHeight * writeableBitmap.BackBufferStride];
-            Marshal.Copy(surface.ToWriteableBitmap().BackBuffer, bytes, 0, bytes.Length);
+            using var frameBuffer = writeableBitmap.Lock();
+            byte[] bytes = new byte[writeableBitmap.PixelSize.Height * frameBuffer.RowBytes];
+            Marshal.Copy(frameBuffer.Address, bytes, 0, bytes.Length);
 
 
             WithImage(surface.Size, bytes);
             WithImage(surface.Size, bytes);
             
             

+ 54 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/LockedFramebufferExtensions.cs

@@ -0,0 +1,54 @@
+using Avalonia.Media;
+using Avalonia.Platform;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Avalonia.Helpers.Extensions;
+
+public static class LockedFramebufferExtensions
+{
+    public static Span<byte> GetPixels(this ILockedFramebuffer framebuffer)
+    {
+        unsafe
+        {
+            return new Span<byte>((byte*)framebuffer.Address, framebuffer.RowBytes * framebuffer.Size.Height);
+        }
+    }
+
+    public static Color GetPixel(this ILockedFramebuffer framebuffer, int x, int y)
+    {
+        unsafe
+        {
+            var bytesPerPixel = framebuffer.Format.BitsPerPixel / 8; //TODO: check if bits per pixel is correct
+            var zero = (byte*)framebuffer.Address;
+            var offset = framebuffer.RowBytes * y + bytesPerPixel * x;
+            return Color.FromArgb(255, zero[offset + 2], zero[offset + 1], zero[offset]);
+        }
+    }
+
+    public static void WritePixels(this ILockedFramebuffer framebuffer, RectI rectI, byte[] pbgra8888Bytes)
+    {
+        //TODO: Idk if this is correct
+        Span<byte> pixels = framebuffer.GetPixels();
+        int rowBytes = framebuffer.RowBytes;
+        int width = framebuffer.Size.Width;
+
+        int startX = Math.Max(0, rectI.X);
+        int endX = Math.Min(width, rectI.X + rectI.Width);
+
+        int startY = Math.Max(0, rectI.Y);
+        int endY = Math.Min(framebuffer.Size.Height, rectI.Y + rectI.Height);
+
+        int bytePerPixel = 4; // BGRA8888 has 4 bytes per pixel
+
+        for (int y = startY; y < endY; y++)
+        {
+            int rowIndex = y * rowBytes;
+            int startOffset = rowIndex + startX * bytePerPixel;
+            int endOffset = rowIndex + endX * bytePerPixel;
+
+            int srcRowStartIndex = (y - rectI.Y) * rectI.Width * bytePerPixel;
+
+            pbgra8888Bytes.AsSpan(srcRowStartIndex, endOffset - startOffset).CopyTo(pixels.Slice(startOffset));
+        }
+    }
+}

+ 124 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/PixiParserDocumentEx.cs

@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using ChunkyImageLib;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Parser;
+using PixiEditor.Parser.Deprecated;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.Helpers.Extensions;
+
+internal static class PixiParserDocumentEx
+{
+    public static VecD ToVecD(this Vector2 vec)
+    {
+        return new VecD(vec.X, vec.Y);
+    }
+    
+    public static DocumentViewModel ToDocument(this Document document)
+    {
+        return DocumentViewModel.Build(b =>
+        {
+            b.WithSize(document.Width, document.Height)
+                .WithPalette(document.Palette, x => new PaletteColor(x.R, x.G, x.B))
+                .WithSwatches(document.Swatches, x => new(x.R, x.G, x.B))
+                .WithReferenceLayer(document.ReferenceLayer, (r, builder) => builder
+                    .WithIsVisible(r.Enabled)
+                    .WithShape(r.Corners)
+                    .WithSurface(Surface.Load(r.ImageBytes)));
+
+            BuildChildren(b, document.RootFolder.Children);
+        });
+
+        void BuildChildren(ChildrenBuilder builder, IEnumerable<IStructureMember> members)
+        {
+            foreach (var member in members)
+            {
+                if (member is Folder folder)
+                {
+                    builder.WithFolder(x => BuildFolder(x, folder));
+                }
+                else if (member is ImageLayer layer)
+                {
+                    builder.WithLayer(x => BuildLayer(x, layer));
+                }
+                else
+                {
+                    throw new NotImplementedException($"StructureMember of type '{member.GetType().FullName}' has not been implemented");
+                }
+            }
+        }
+
+        void BuildFolder(DocumentViewModelBuilder.FolderBuilder builder, Folder folder) => builder
+            .WithName(folder.Name)
+            .WithVisibility(folder.Enabled)
+            .WithOpacity(folder.Opacity)
+            .WithBlendMode((PixiEditor.ChangeableDocument.Enums.BlendMode)(int)folder.BlendMode)
+            .WithChildren(x => BuildChildren(x, folder.Children))
+            .WithClipToBelow(folder.ClipToMemberBelow)
+            .WithMask(folder.Mask, (x, m) => x.WithVisibility(m.Enabled).WithSurface(m.Width, m.Height, x => x.WithImage(m.ImageBytes, m.OffsetX, m.OffsetY)));
+
+        void BuildLayer(DocumentViewModelBuilder.LayerBuilder builder, ImageLayer layer)
+        {
+            builder
+                .WithName(layer.Name)
+                .WithVisibility(layer.Enabled)
+                .WithOpacity(layer.Opacity)
+                .WithBlendMode((PixiEditor.ChangeableDocument.Enums.BlendMode)(int)layer.BlendMode)
+                .WithRect(layer.Width, layer.Height, layer.OffsetX, layer.OffsetY)
+                .WithClipToBelow(layer.ClipToMemberBelow)
+                .WithLockAlpha(layer.LockAlpha)
+                .WithMask(layer.Mask,
+                    (x, m) => x.WithVisibility(m.Enabled).WithSurface(m.Width, m.Height,
+                        x => x.WithImage(m.ImageBytes, m.OffsetX, m.OffsetY)));
+
+            if (layer.Width > 0 && layer.Height > 0)
+            {
+                builder.WithSurface(x => x.WithImage(layer.ImageBytes, 0, 0));
+            }
+        }
+    }
+    
+    public static SKBitmap RenderOldDocument(this SerializableDocument document)
+    {
+        SKImageInfo info = new(document.Width, document.Height, SKColorType.RgbaF32, SKAlphaType.Unpremul, SKColorSpace.CreateSrgb());
+        using SKSurface surface = SKSurface.Create(info);
+        SKCanvas canvas = surface.Canvas;
+        using SKPaint paint = new();
+
+        foreach (var layer in document)
+        {
+            if (layer.PngBytes == null || layer.PngBytes.Length == 0)
+            {
+                continue;
+            }
+
+            bool visible = document.Layers.GetFinalLayerVisibilty(layer);
+
+            if (!visible)
+            {
+                continue;
+            }
+
+            double opacity = document.Layers.GetFinalLayerOpacity(layer);
+
+            if (opacity == 0)
+            {
+                continue;
+            }
+
+            using SKColorFilter filter = SKColorFilter.CreateBlendMode(SKColors.White.WithAlpha((byte)(opacity * 255)), SKBlendMode.DstIn);
+            paint.ColorFilter = filter;
+
+            using var image = SKImage.FromEncodedData(layer.PngBytes);
+            
+            canvas.DrawImage(image, layer.OffsetX, layer.OffsetY, paint);
+        }
+
+        SKBitmap bitmap = new(info);
+
+        surface.ReadPixels(info, bitmap.GetPixels(), info.RowBytes, 0, 0);
+
+        return bitmap;
+    }
+}

+ 35 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ProcessHelper.cs

@@ -0,0 +1,35 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Security.Principal;
+using System.Windows.Input;
+using Hardware.Info;
+using PixiEditor.OperatingSystem;
+
+namespace PixiEditor.Helpers;
+
+internal static class ProcessHelper
+{
+    public static Process RunAsAdmin(string path)
+    {
+        return IOperatingSystem.Current.ProcessUtility.RunAsAdmin(path);
+    }
+
+    public static bool IsRunningAsAdministrator()
+    {
+        return IOperatingSystem.Current.ProcessUtility.IsRunningAsAdministrator();
+    }
+
+    public static void OpenInExplorer(string path)
+    {
+        try
+        {
+            string fixedPath = Path.GetFullPath(path);
+            var process = Process.Start("explorer.exe", $"/select,\"{fixedPath}\"");
+
+            // Explorer might need a second to show up
+            process.WaitForExit(500);
+        }
+        finally{}
+    }
+}

+ 176 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SerializableDocumentEx.cs

@@ -0,0 +1,176 @@
+using System.Collections.Generic;
+using System.Linq;
+using ChunkyImageLib;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Parser;
+using PixiEditor.Parser.Collections.Deprecated;
+using PixiEditor.Parser.Deprecated;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.Helpers.Extensions;
+
+internal static class SerializableDocumentEx
+{
+    public static Vector2 ToVector2(this VecD serializableVector2)
+    {
+        return new Vector2 { X = serializableVector2.X, Y = serializableVector2.Y };
+    }
+    public static Image ToImage(this SerializableLayer serializableLayer)
+    {
+        if (serializableLayer.PngBytes == null)
+        {
+            return null;
+        }
+
+        return Image.FromEncodedData(serializableLayer.PngBytes);
+    }
+
+    public static DocumentViewModel ToDocument(this SerializableDocument serializableDocument)
+    {
+        List<SerializableLayer> builtLayers = new List<SerializableLayer>();
+        DocumentViewModel vm = DocumentViewModel.Build(builder =>
+        {
+            builder
+                .WithSize(serializableDocument.Width, serializableDocument.Height)
+                .WithPalette(serializableDocument.Palette.Select(x => new PaletteColor(x.R, x.G, x.B)).ToList())
+                .WithSwatches(serializableDocument.Swatches.Select(x => new PaletteColor(x.R, x.G, x.B)).ToList());
+
+            if (serializableDocument.Groups != null)
+            {
+                foreach (var group in serializableDocument.Groups)
+                {
+                    builder.WithFolder(folderBuilder =>
+                    {
+                        builtLayers.AddRange(BuildFolder(
+                            folderBuilder,
+                            group,
+                            GatherFolderLayers(group, serializableDocument.Layers),
+                            serializableDocument));
+                    });
+                }
+            }
+
+            BuildLayers(serializableDocument.Layers.Where(x => !builtLayers.Contains(x)), builder, serializableDocument);
+            SortMembersRecursively(builder.Children);
+        });
+
+        return vm;
+    }
+
+    /// <summary>
+    ///     Builds folder and its children.
+    /// </summary>
+    /// <param name="folderBuilder">Folder to build.</param>
+    /// <param name="group">Serialized folder (group), which will be used to build.</param>
+    /// <param name="layers">Layers only in this folder.</param>
+    /// <param name="doc">Document which contains all the serialized data.</param>
+    /// <returns>List of layers which were built.</returns>
+    private static List<SerializableLayer> BuildFolder(DocumentViewModelBuilder.FolderBuilder folderBuilder, SerializableGroup group, List<SerializableLayer> layers, SerializableDocument doc)
+    {
+        List<SerializableLayer> builtLayers = new List<SerializableLayer>(layers);
+        folderBuilder
+            .WithName(group.Name)
+            .WithOpacity(group.Opacity)
+            .WithVisibility(group.IsVisible)
+            .WithOrderInStructure(group.StartLayer);
+
+        folderBuilder.WithChildren(childrenBuilder =>
+            {
+                if (group.Subgroups != null)
+                {
+                    foreach (var subGroup in group.Subgroups)
+                    {
+                        childrenBuilder.WithFolder(subFolderBuilder =>
+                        {
+                            builtLayers.AddRange(BuildFolder(
+                                subFolderBuilder,
+                                subGroup,
+                                GatherFolderLayers(subGroup, doc.Layers),
+                                doc));
+                        });
+                    }
+                }
+
+                BuildLayers(layers, childrenBuilder, doc);
+            });
+
+        return builtLayers;
+    }
+
+    private static void BuildLayers(IEnumerable<SerializableLayer> layers, ChildrenBuilder builder, SerializableDocument document)
+    {
+        if (layers != null)
+        {
+            foreach (var layer in layers)
+            {
+                builder.WithLayer((layerBuilder) =>
+                {
+                    layerBuilder
+                        .WithSize(layer.Width, layer.Height)
+                        .WithName(layer.Name)
+                        .WithOpacity(layer.Opacity)
+                        .WithVisibility(layer.IsVisible)
+                        .WithRect(layer.Width, layer.Height, layer.OffsetX, layer.OffsetY)
+                        .WithSurface((surfaceBuilder) =>
+                        {
+                            if (layer.PngBytes is { Length: > 0 })
+                            {
+                                surfaceBuilder.WithImage(layer.PngBytes);
+                            }
+                            else
+                            {
+                                surfaceBuilder.Surface = new Surface(new VecI(1, 1));
+                            }
+                        })
+                        .WithOrderInStructure(document.Layers.IndexOf(layer));
+                });
+            }
+        }
+    }
+
+    /// <summary>
+    ///     Gathers all layers which are in the folder. Excludes layers which are in subfolders.
+    /// </summary>
+    /// <param name="group">Group which contains folder data.</param>
+    /// <param name="serializableDocumentLayers">All layers in document.</param>
+    /// <returns>List of layers in folder, excluding layers in nested folders.</returns>
+    private static List<SerializableLayer> GatherFolderLayers(SerializableGroup group, LayerCollection serializableDocumentLayers)
+    {
+        List<SerializableLayer> layers = new List<SerializableLayer>();
+
+        for (int i = group.StartLayer; i <= group.EndLayer; i++)
+        {
+            layers.Add(serializableDocumentLayers[i]);
+        }
+
+        if (group.Subgroups is { Count: > 0 })
+        {
+            foreach (var subGroup in group.Subgroups)
+            {
+                var nestedGroupLayers = GatherFolderLayers(subGroup, serializableDocumentLayers);
+                layers.RemoveAll(x => nestedGroupLayers.Contains(x));
+            }
+        }
+
+        return layers;
+    }
+
+    /// <summary>
+    /// Sorts StructureMemberBuilder by its OrderInStructure property.
+    /// </summary>
+    /// <param name="builderChildren">Structure to sort</param>
+    private static void SortMembersRecursively(List<DocumentViewModelBuilder.StructureMemberBuilder> builderChildren)
+    {
+        builderChildren.Sort(Comparer<DocumentViewModelBuilder.StructureMemberBuilder>.Create((a, b) => a.OrderInStructure - b.OrderInStructure));
+        
+        foreach (var child in builderChildren)
+        {
+            if (child is not DocumentViewModelBuilder.FolderBuilder folderBuilder)
+                continue;
+            SortMembersRecursively(folderBuilder.Children);
+        }
+    }
+}

+ 2 - 1
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ServiceCollectionHelpers.cs

@@ -12,6 +12,7 @@ using PixiEditor.Models.Localization;
 using PixiEditor.Models.Preferences;
 using PixiEditor.Models.Preferences;
 using PixiEditor.ViewModels.SubViewModels;
 using PixiEditor.ViewModels.SubViewModels;
 using PixiEditor.ViewModels.SubViewModels.AdditionalContent;
 using PixiEditor.ViewModels.SubViewModels.AdditionalContent;
+using PixiEditor.ViewModels.SubViewModels.Main;
 
 
 namespace PixiEditor.Helpers.Extensions;
 namespace PixiEditor.Helpers.Extensions;
 
 
@@ -22,7 +23,7 @@ internal static class ServiceCollectionHelpers
     /// </summary>
     /// </summary>
     public static IServiceCollection
     public static IServiceCollection
         AddPixiEditor(this IServiceCollection collection, ExtensionLoader extensionLoader) => collection
         AddPixiEditor(this IServiceCollection collection, ExtensionLoader extensionLoader) => collection
-        .AddSingleton<MainViewModel>()
+        .AddSingleton<ViewModelMain>()
         .AddSingleton<IPreferences, PreferencesSettings>()
         .AddSingleton<IPreferences, PreferencesSettings>()
         .AddSingleton<ILocalizationProvider, LocalizationProvider>(x => new LocalizationProvider(extensionLoader))
         .AddSingleton<ILocalizationProvider, LocalizationProvider>(x => new LocalizationProvider(extensionLoader))
 
 

+ 27 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/StringHelpers.cs

@@ -0,0 +1,27 @@
+using System.Text;
+
+namespace PixiEditor.Helpers.Extensions;
+
+internal static class StringHelpers
+{
+    public static string AddSpacesBeforeUppercaseLetters(this string text)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+            return "";
+
+        StringBuilder newText = new StringBuilder(text.Length * 2);
+        newText.Append(text[0]);
+        for (int i = 1; i < text.Length; i++)
+        {
+            if (char.IsUpper(text[i]) && text[i - 1] != ' ')
+                newText.Append(' ');
+            newText.Append(text[i]);
+        }
+        return newText.ToString();
+    }
+
+    public static string Limit(this string value, int maxLenght)
+    {
+        return value.Length > maxLenght ? value.Substring(0, maxLenght) : value;
+    }
+}

+ 0 - 15
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/StructureHelpers.cs

@@ -21,19 +21,4 @@ public static class StructureHelpers
             new VecI(Math.Max((int)Math.Round(prSize / proportions), 1), prSize) :
             new VecI(Math.Max((int)Math.Round(prSize / proportions), 1), prSize) :
             new VecI(prSize, Math.Max((int)Math.Round(prSize * proportions), 1));
             new VecI(prSize, Math.Max((int)Math.Round(prSize * proportions), 1));
     }
     }
-
-    public static WriteableBitmap CreateBitmap(VecI size)
-    {
-        return new WriteableBitmap(new PixelSize(Math.Max(size.X, 1), Math.Max(size.Y, 1)), new Vector(96, 96), PixelFormats.Bgra8888, AlphaFormat.Premul);
-    }
-
-    public static DrawingSurface CreateDrawingSurface(WriteableBitmap bitmap)
-    {
-        using var frameBuffer = bitmap.Lock();
-        return DrawingSurface.Create(
-            new ImageInfo(bitmap.PixelSize.Width, bitmap.PixelSize.Height, ColorType.Bgra8888, AlphaType.Premul,
-                ColorSpace.CreateSrgb()),
-            frameBuffer.Address,
-            frameBuffer.RowBytes);
-    }
 }
 }

+ 58 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SurfaceHelpers.cs

@@ -0,0 +1,58 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using ChunkyImageLib;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.Avalonia.Helpers.Extensions;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+
+namespace PixiEditor.Helpers;
+
+public static class SurfaceHelpers
+{
+    /*public static Surface FromBitmapSource(BitmapSource original)
+    {
+        ColorType color = original.Format.ToColorType(out AlphaType alpha);
+        if (original.PixelWidth <= 0 || original.PixelHeight <= 0)
+            throw new ArgumentException("Surface dimensions must be non-zero");
+
+        int stride = (original.PixelWidth * original.Format.BitsPerPixel + 7) / 8;
+        byte[] pixels = new byte[stride * original.PixelHeight];
+        original.CopyPixels(pixels, stride, 0);
+
+        Surface surface = new Surface(new VecI(original.PixelWidth, original.PixelHeight));
+        surface.DrawBytes(surface.Size, pixels, color, alpha);
+        return surface;
+    }*/
+
+    public static WriteableBitmap ToWriteableBitmap(this Surface surface)
+    {
+        WriteableBitmap result = WriteableBitmapHelpers.CreateBitmap(surface.Size);
+        using var framebuffer = result.Lock();
+        var dirty = new RectI(0, 0, surface.Size.X, surface.Size.Y);
+        framebuffer.WritePixels(dirty, ToByteArray(surface));
+        //result.AddDirtyRect(dirty);
+        return result;
+    }
+
+    private static unsafe byte[] ToByteArray(Surface surface, ColorType colorType = ColorType.Bgra8888, AlphaType alphaType = AlphaType.Premul)
+    {
+        int width = surface.Size.X;
+        int height = surface.Size.Y;
+        var imageInfo = new ImageInfo(width, height, colorType, alphaType, ColorSpace.CreateSrgb());
+
+        byte[] buffer = new byte[width * height * imageInfo.BytesPerPixel];
+        fixed (void* pointer = buffer)
+        {
+            if (!surface.DrawingSurface.ReadPixels(imageInfo, new IntPtr(pointer), imageInfo.RowBytes, 0, 0))
+            {
+                throw new InvalidOperationException("Could not read surface into buffer");
+            }
+        }
+
+        return buffer;
+    }
+}

+ 35 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/WriteableBitmapHelpers.cs

@@ -0,0 +1,35 @@
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using PixiEditor.Avalonia.Helpers.Extensions;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+
+namespace PixiEditor.Avalonia.Helpers;
+
+internal static class WriteableBitmapHelpers
+{
+    public static WriteableBitmap FromPbgra8888Array(byte[] pbgra8888, VecI size)
+    {
+        WriteableBitmap bitmap = new WriteableBitmap(new PixelSize(size.X, size.Y), new Vector(96, 96), PixelFormats.Bgra8888, AlphaFormat.Premul);
+        using var frameBuffer = bitmap.Lock();
+        frameBuffer.WritePixels(new RectI(0, 0, size.X, size.Y), pbgra8888);
+        return bitmap;
+    }
+
+    public static WriteableBitmap CreateBitmap(VecI size)
+    {
+        return new WriteableBitmap(new PixelSize(Math.Max(size.X, 1), Math.Max(size.Y, 1)), new Vector(96, 96), PixelFormats.Bgra8888, AlphaFormat.Premul);
+    }
+
+    public static DrawingSurface CreateDrawingSurface(WriteableBitmap bitmap)
+    {
+        using var frameBuffer = bitmap.Lock();
+        return DrawingSurface.Create(
+            new ImageInfo(bitmap.PixelSize.Width, bitmap.PixelSize.Height, ColorType.Bgra8888, AlphaType.Premul,
+                ColorSpace.CreateSrgb()),
+            frameBuffer.Address,
+            frameBuffer.RowBytes);
+    }
+}

+ 1 - 1
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Initialization/ClassicDesktopEntry.cs

@@ -122,7 +122,7 @@ internal class ClassicDesktopEntry
 
 
                                     StartupArgs.Args = args;
                                     StartupArgs.Args = args;
                                     StartupArgs.Args.Add("--openedInExisting");
                                     StartupArgs.Args.Add("--openedInExisting");
-                                    MainViewModel viewModel = (MainViewModel)mainWindow.DataContext;
+                                    ViewModelMain viewModel = (ViewModelMain)mainWindow.DataContext;
                                     viewModel.StartupCommand.Execute(null);
                                     viewModel.StartupCommand.Execute(null);
                                 }
                                 }
                             }));
                             }));

+ 16 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/LayersChangedEventArgs.cs

@@ -0,0 +1,16 @@
+using PixiEditor.Models.Enums;
+
+namespace PixiEditor.Models.Controllers;
+
+internal class LayersChangedEventArgs : EventArgs
+{
+    public LayersChangedEventArgs(Guid layerAffectedGuid, LayerAction layerChangeType)
+    {
+        LayerAffectedGuid = layerAffectedGuid;
+        LayerChangeType = layerChangeType;
+    }
+
+    public Guid LayerAffectedGuid { get; set; }
+
+    public LayerAction LayerChangeType { get; set; }
+}

+ 19 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentSizeChangedEventArgs.cs

@@ -0,0 +1,19 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.Models.DataHolders;
+
+internal class DocumentSizeChangedEventArgs
+{
+    public DocumentSizeChangedEventArgs(DocumentViewModel document, VecI oldSize, VecI newSize)
+    {
+        Document = document;
+        OldSize = oldSize;
+        NewSize = newSize;
+    }
+
+    public VecI OldSize { get; }
+    public VecI NewSize { get; }
+    public DocumentViewModel Document { get; }
+}

+ 5 - 5
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentUpdater.cs

@@ -146,7 +146,7 @@ internal class DocumentUpdater
 
 
     private void ProcessSetReferenceLayer(SetReferenceLayer_ChangeInfo info)
     private void ProcessSetReferenceLayer(SetReferenceLayer_ChangeInfo info)
     {
     {
-        doc.ReferenceLayerHandler.SetReferenceLayer(info.ImagePbgra32Bytes, info.ImageSize, info.Shape);
+        doc.ReferenceLayerHandler.SetReferenceLayer(info.ImagePbgra8888Bytes, info.ImageSize, info.Shape);
     }
     }
     
     
     private void ProcessReferenceLayerTopMost(ReferenceLayerTopMost_ChangeInfo info)
     private void ProcessReferenceLayerTopMost(ReferenceLayerTopMost_ChangeInfo info)
@@ -276,8 +276,8 @@ internal class DocumentUpdater
         foreach ((ChunkResolution res, DrawingSurface surf) in doc.Surfaces)
         foreach ((ChunkResolution res, DrawingSurface surf) in doc.Surfaces)
         {
         {
             surf.Dispose();
             surf.Dispose();
-            newBitmaps[res] = StructureHelpers.CreateBitmap((VecI)(info.Size * res.Multiplier()));
-            doc.Surfaces[res] = StructureHelpers.CreateDrawingSurface(newBitmaps[res]);
+            newBitmaps[res] = WriteableBitmapHelpers.CreateBitmap((VecI)(info.Size * res.Multiplier()));
+            doc.Surfaces[res] = WriteableBitmapHelpers.CreateDrawingSurface(newBitmaps[res]);
         }
         }
 
 
         doc.LazyBitmaps = newBitmaps;
         doc.LazyBitmaps = newBitmaps;
@@ -288,8 +288,8 @@ internal class DocumentUpdater
 
 
         VecI documentPreviewSize = StructureHelpers.CalculatePreviewSize(info.Size);
         VecI documentPreviewSize = StructureHelpers.CalculatePreviewSize(info.Size);
         doc.PreviewSurface.Dispose();
         doc.PreviewSurface.Dispose();
-        doc.PreviewBitmap = StructureHelpers.CreateBitmap(documentPreviewSize);
-        doc.PreviewSurface = StructureHelpers.CreateDrawingSurface(doc.PreviewBitmap);
+        doc.PreviewBitmap = WriteableBitmapHelpers.CreateBitmap(documentPreviewSize);
+        doc.PreviewSurface = WriteableBitmapHelpers.CreateDrawingSurface(doc.PreviewBitmap);
 
 
         // TODO: Make sure property changed events are raised internally
         // TODO: Make sure property changed events are raised internally
         /*doc.RaisePropertyChanged(nameof(doc.LazyBitmaps));
         /*doc.RaisePropertyChanged(nameof(doc.LazyBitmaps));

+ 1 - 1
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -19,7 +19,7 @@ using PixiEditor.Models.Position;
 
 
 namespace PixiEditor.Models.DocumentModels.Public;
 namespace PixiEditor.Models.DocumentModels.Public;
 #nullable enable
 #nullable enable
-internal class DocumentOperationsModule
+internal class DocumentOperationsModule : IDocumentOperations
 {
 {
     private IDocument Document { get; }
     private IDocument Document { get; }
     private DocumentInternalParts Internals { get; }
     private DocumentInternalParts Internals { get; }

+ 3 - 3
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentToolsModule.cs

@@ -7,12 +7,12 @@ using PixiEditor.Models.Enums;
 namespace Models.DocumentModels.Public;
 namespace Models.DocumentModels.Public;
 internal class DocumentToolsModule
 internal class DocumentToolsModule
 {
 {
-    private IDocumentManagerHandler DocumentManager { get; set; }
+    private IDocument Document { get; set; }
     private DocumentInternalParts Internals { get; set; }
     private DocumentInternalParts Internals { get; set; }
 
 
-    public DocumentToolsModule(IDocumentManagerHandler doc, DocumentInternalParts internals)
+    public DocumentToolsModule(IDocument doc, DocumentInternalParts internals)
     {
     {
-        this.DocumentManager = doc;
+        this.Document = doc;
         this.Internals = internals;
         this.Internals = internals;
     }
     }
 
 

+ 14 - 12
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocument.cs

@@ -1,4 +1,5 @@
 using System.Collections.Generic;
 using System.Collections.Generic;
+using System.Collections.ObjectModel;
 using Avalonia.Media.Imaging;
 using Avalonia.Media.Imaging;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.DataHolders;
 using PixiEditor.Avalonia.Helpers;
 using PixiEditor.Avalonia.Helpers;
@@ -15,29 +16,30 @@ namespace PixiEditor.Models.Containers;
 
 
 internal interface IDocument : IHandler
 internal interface IDocument : IHandler
 {
 {
-    public List<PaletteColor> Palette { get; set; }
-    public VecI SizeBindable { get; set; }
-    public IStructureMemberHandler? SelectedStructureMember { get; protected set; }
+    public ObservableCollection<PaletteColor> Palette { get; set; }
+    public VecI SizeBindable { get; }
+    public IStructureMemberHandler? SelectedStructureMember { get; }
     public IReferenceLayerHandler ReferenceLayerHandler { get; }
     public IReferenceLayerHandler ReferenceLayerHandler { get; }
     public VectorPath SelectionPathBindable { get; }
     public VectorPath SelectionPathBindable { get; }
-    public IFolderHandler StructureRoot { get; set; }
+    public IFolderHandler StructureRoot { get; }
     public Dictionary<ChunkResolution, DrawingSurface> Surfaces { get; set; }
     public Dictionary<ChunkResolution, DrawingSurface> Surfaces { get; set; }
     public DocumentStructureModule StructureHelper { get; }
     public DocumentStructureModule StructureHelper { get; }
     public DrawingSurface PreviewSurface { get; set; }
     public DrawingSurface PreviewSurface { get; set; }
-    public bool AllChangesSaved { get; set; }
+    public bool AllChangesSaved { get; }
     public string CoordinatesString { get; set; }
     public string CoordinatesString { get; set; }
-    public IReadOnlyCollection<IStructureMemberHandler?> SoftSelectedStructureMembers { get; set; }
+    public IReadOnlyCollection<IStructureMemberHandler> SoftSelectedStructureMembers { get; }
     public Dictionary<ChunkResolution, WriteableBitmap> LazyBitmaps { get; set; }
     public Dictionary<ChunkResolution, WriteableBitmap> LazyBitmaps { get; set; }
     public WriteableBitmap PreviewBitmap { get; set; }
     public WriteableBitmap PreviewBitmap { get; set; }
     public ILayerHandlerFactory LayerHandlerFactory { get; }
     public ILayerHandlerFactory LayerHandlerFactory { get; }
-    public IFolderHandlerFactory FolderHandlerFactory { get; set; }
+    public IFolderHandlerFactory FolderHandlerFactory { get; }
     public ITransformHandler TransformHandler { get; }
     public ITransformHandler TransformHandler { get; }
     public bool Busy { get; set; }
     public bool Busy { get; set; }
-    public ILineOverlayHandler LineToolOverlayHandler { get; set; }
-    public bool HorizontalSymmetryAxisEnabledBindable { get; set; }
-    public bool VerticalSymmetryAxisEnabledBindable { get; set; }
-    public double HorizontalSymmetryAxisYBindable { get; set; }
-    public double VerticalSymmetryAxisXBindable { get; set; }
+    public ILineOverlayHandler LineToolOverlayHandler { get; }
+    public bool HorizontalSymmetryAxisEnabledBindable { get; }
+    public bool VerticalSymmetryAxisEnabledBindable { get; }
+    public double HorizontalSymmetryAxisYBindable { get; }
+    public double VerticalSymmetryAxisXBindable { get; }
+    public IDocumentOperations Operations { get; }
     public void RemoveSoftSelectedMember(IStructureMemberHandler member);
     public void RemoveSoftSelectedMember(IStructureMemberHandler member);
     public void ClearSoftSelectedMembers();
     public void ClearSoftSelectedMembers();
     public void AddSoftSelectedMember(IStructureMemberHandler member);
     public void AddSoftSelectedMember(IStructureMemberHandler member);

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocumentOperations.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.Containers;
+
+public interface IDocumentOperations
+{
+    public void DeleteStructureMember(Guid memberGuidValue);
+    public void DuplicateLayer(Guid memberGuidValue);
+}

+ 3 - 3
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ILineOverlayHandler.cs

@@ -5,8 +5,8 @@ namespace PixiEditor.Models.Containers;
 internal interface ILineOverlayHandler
 internal interface ILineOverlayHandler
 {
 {
     public void Hide();
     public void Hide();
-    public void Nudge(VecI distance);
-    public void Undo();
-    public void Redo();
+    public bool Nudge(VecD distance);
+    public bool Undo();
+    public bool Redo();
     public void Show(VecD startPos, VecD curPos);
     public void Show(VecD startPos, VecD curPos);
 }
 }

+ 4 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IReferenceLayerHandler.cs

@@ -1,4 +1,5 @@
 using System.Collections.Immutable;
 using System.Collections.Immutable;
+using Avalonia;
 using Avalonia.Media.Imaging;
 using Avalonia.Media.Imaging;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.DataHolders;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.DrawingApi.Core.Numerics;
@@ -7,13 +8,14 @@ namespace PixiEditor.Models.Containers;
 
 
 public interface IReferenceLayerHandler : IHandler
 public interface IReferenceLayerHandler : IHandler
 {
 {
-    public WriteableBitmap? ReferenceBitmap { get; protected set; }
+    public WriteableBitmap? ReferenceBitmap { get; }
     public ShapeCorners ReferenceShapeBindable { get; set; }
     public ShapeCorners ReferenceShapeBindable { get; set; }
     public bool IsTopMost { get; set; }
     public bool IsTopMost { get; set; }
     public bool IsTransforming { get; set; }
     public bool IsTransforming { get; set; }
+    public Matrix ReferenceTransformMatrix { get; }
     public void SetReferenceLayerIsVisible(bool infoIsVisible);
     public void SetReferenceLayerIsVisible(bool infoIsVisible);
     public void TransformReferenceLayer(ShapeCorners infoCorners);
     public void TransformReferenceLayer(ShapeCorners infoCorners);
     public void DeleteReferenceLayer();
     public void DeleteReferenceLayer();
-    public void SetReferenceLayer(ImmutableArray<byte> infoImagePbgra32Bytes, VecI infoImageSize, ShapeCorners infoShape);
+    public void SetReferenceLayer(ImmutableArray<byte> imagePbgra8888Bytes, VecI infoImageSize, ShapeCorners infoShape);
     public void SetReferenceLayerTopMost(bool infoIsTopMost);
     public void SetReferenceLayerTopMost(bool infoIsTopMost);
 }
 }

+ 1 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IStructureMemberHandler.cs

@@ -17,6 +17,7 @@ internal interface IStructureMemberHandler : IHandler
     public bool MaskIsVisibleBindable { get; set; }
     public bool MaskIsVisibleBindable { get; set; }
     public StructureMemberSelectionType Selection { get; set; }
     public StructureMemberSelectionType Selection { get; set; }
     public float OpacityBindable { get; set; }
     public float OpacityBindable { get; set; }
+    public IDocument Document { get; }
     public void SetMaskIsVisible(bool infoIsVisible);
     public void SetMaskIsVisible(bool infoIsVisible);
     public void SetClipToMemberBelowEnabled(bool infoClipToMemberBelow);
     public void SetClipToMemberBelowEnabled(bool infoClipToMemberBelow);
     public void SetBlendMode(BlendMode infoBlendMode);
     public void SetBlendMode(BlendMode infoBlendMode);

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/Toolbars/IBasicShapeToolbar.cs

@@ -4,6 +4,6 @@ namespace PixiEditor.Models.Containers.Toolbars;
 
 
 public interface IBasicShapeToolbar : IBasicToolbar
 public interface IBasicShapeToolbar : IBasicToolbar
 {
 {
-    public bool Fill { get; set; }
-    public Color FillColor { get; set; }
+    public bool Fill { get; }
+    public Color FillColor { get; }
 }
 }

+ 1 - 1
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/Tools/IRectangleToolHandler.cs

@@ -2,5 +2,5 @@
 
 
 internal interface IRectangleToolHandler : IShapeToolHandler
 internal interface IRectangleToolHandler : IShapeToolHandler
 {
 {
-    public bool DrawSquare { get; set; }
+    public bool DrawSquare { get; }
 }
 }

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Layers/LayerAction.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.Enums;
+
+public enum LayerAction
+{
+    Add,
+    Remove,
+    Move
+}

+ 13 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Preferences/RightClickMode.cs

@@ -0,0 +1,13 @@
+using System.ComponentModel;
+
+namespace PixiEditor.Models.Enums;
+
+public enum RightClickMode
+{
+    [Description("USE_SECONDARY_COLOR")]
+    SecondaryColor,
+    [Description("SHOW_CONTEXT_MENU")]
+    ContextMenu,
+    [Description("ERASE")]
+    Erase
+}

+ 4 - 4
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/MemberPreviewUpdater.cs

@@ -222,8 +222,8 @@ internal class MemberPreviewUpdater
                 else
                 else
                 {
                 {
                     member.PreviewSurface?.Dispose();
                     member.PreviewSurface?.Dispose();
-                    member.PreviewBitmap = StructureHelpers.CreateBitmap(newSize.Value.previewSize);
-                    member.PreviewSurface = StructureHelpers.CreateDrawingSurface(member.PreviewBitmap);
+                    member.PreviewBitmap = WriteableBitmapHelpers.CreateBitmap(newSize.Value.previewSize);
+                    member.PreviewSurface = WriteableBitmapHelpers.CreateDrawingSurface(member.PreviewBitmap);
                 }
                 }
             }
             }
 
 
@@ -244,8 +244,8 @@ internal class MemberPreviewUpdater
             }
             }
             else
             else
             {
             {
-                member.MaskPreviewBitmap = StructureHelpers.CreateBitmap(newSize.Value.previewSize);
-                member.MaskPreviewSurface = StructureHelpers.CreateDrawingSurface(member.MaskPreviewBitmap);
+                member.MaskPreviewBitmap = WriteableBitmapHelpers.CreateBitmap(newSize.Value.previewSize);
+                member.MaskPreviewSurface = WriteableBitmapHelpers.CreateDrawingSurface(member.MaskPreviewBitmap);
             }
             }
 
 
             //TODO: Make sure MaskPreviewBitmap implementation raises PropertyChanged
             //TODO: Make sure MaskPreviewBitmap implementation raises PropertyChanged

+ 54 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/UserData/RecentlyOpenedCollection.cs

@@ -0,0 +1,54 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PixiEditor.Models.DataHolders;
+
+internal class RecentlyOpenedCollection : System.Collections.ObjectModel.ObservableCollection<RecentlyOpenedDocument>
+{
+    public RecentlyOpenedDocument this[string path]
+    {
+        get
+        {
+            return Get(path);
+        }
+    }
+
+    public RecentlyOpenedCollection()
+    {
+    }
+
+    public RecentlyOpenedCollection(IEnumerable<RecentlyOpenedDocument> documents)
+        : base(documents)
+    {
+    }
+
+    public void Add(string path)
+    {
+        if (string.IsNullOrWhiteSpace(path))
+        {
+            return;
+        }
+
+        Add(Create(path));
+    }
+
+    public bool Contains(string path) => Get(path) is not null;
+
+    public void Remove(string path) => Remove(Get(path));
+
+    public int IndexOf(string path) => IndexOf(Get(path));
+
+    public void Insert(int index, string path)
+    {
+        if (string.IsNullOrWhiteSpace(path))
+        {
+            return;
+        }
+
+        Insert(index, Create(path));
+    }
+
+    private static RecentlyOpenedDocument Create(string path) => new(path);
+
+    private RecentlyOpenedDocument Get(string path) => this.FirstOrDefault(x => x.FilePath == path);
+}

+ 1 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/PixiEditor.Avalonia.csproj

@@ -21,6 +21,7 @@
         <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)" />
         <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)" />
         <PackageReference Include="ByteSize" Version="2.1.1" />
         <PackageReference Include="ByteSize" Version="2.1.1" />
         <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
         <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
+        <PackageReference Include="DiscordRichPresence" Version="1.1.3.18" />
         <PackageReference Include="Dock.Avalonia" Version="11.0.0" />
         <PackageReference Include="Dock.Avalonia" Version="11.0.0" />
         <PackageReference Include="Dock.Model.Avalonia" Version="11.0.0" />
         <PackageReference Include="Dock.Model.Avalonia" Version="11.0.0" />
         <PackageReference Include="Hardware.Info" Version="11.0.0" />
         <PackageReference Include="Hardware.Info" Version="11.0.0" />

+ 16 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentChangedEventArgs.cs

@@ -0,0 +1,16 @@
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.Models.Events;
+
+internal class DocumentChangedEventArgs
+{
+    public DocumentChangedEventArgs(DocumentViewModel newDocument, DocumentViewModel oldDocument)
+    {
+        NewDocument = newDocument;
+        OldDocument = oldDocument;
+    }
+
+    public DocumentViewModel OldDocument { get; set; }
+
+    public DocumentViewModel NewDocument { get; set; }
+}

+ 181 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentManagerViewModel.cs

@@ -0,0 +1,181 @@
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+using ChunkyImageLib.Operations;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Events;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.Views.UserControls.SymmetryOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document;
+#nullable enable
+[Command.Group("PixiEditor.Document", "IMAGE")]
+internal class DocumentManagerViewModel : SubViewModel<ViewModelMain>, IDocumentManagerHandler
+{
+    public ObservableCollection<DocumentViewModel> Documents { get; } = new ObservableCollection<DocumentViewModel>();
+    public event EventHandler<DocumentChangedEventArgs>? ActiveDocumentChanged;
+
+    private DocumentViewModel? activeDocument;
+    public DocumentViewModel? ActiveDocument
+    {
+        get => activeDocument;
+        // Use WindowSubViewModel.MakeDocumentViewportActive(document);
+        private set
+        {
+            if (activeDocument == value)
+                return;
+            DocumentViewModel? prevDoc = activeDocument;
+            activeDocument = value;
+            OnPropertyChanged(nameof(ActiveDocument));
+            ActiveDocumentChanged?.Invoke(this, new(value, prevDoc));
+            
+            if (ViewModelMain.Current.ToolsSubViewModel.ActiveTool == null)
+            {
+                ViewModelMain.Current.ToolsSubViewModel.SetActiveTool<PenToolViewModel>(false);
+            }
+        }
+    }
+
+    IDocument? IDocumentManagerHandler.ActiveDocument
+    {
+        get => ActiveDocument;
+        set => ActiveDocument = (DocumentViewModel)value;
+    }
+
+    public bool HasActiveDocument => ActiveDocument != null;
+
+    public DocumentManagerViewModel(ViewModelMain owner) : base(owner)
+    {
+        owner.WindowSubViewModel.ActiveViewportChanged += (_, args) => ActiveDocument = args.Document;
+    }
+
+    public void MakeActiveDocumentNull() => ActiveDocument = null;
+
+    [Evaluator.CanExecute("PixiEditor.HasDocument")]
+    public bool DocumentNotNull() => ActiveDocument != null;
+
+    [Command.Basic("PixiEditor.Document.ClipCanvas", "CLIP_CANVAS", "CLIP_CANVAS", CanExecute = "PixiEditor.HasDocument", IconPath = "crop.png")]
+    public void ClipCanvas() => ActiveDocument?.Operations.ClipCanvas();
+
+    [Command.Basic("PixiEditor.Document.FlipImageHorizontal", FlipType.Horizontal, "FLIP_IMG_HORIZONTALLY", "FLIP_IMG_HORIZONTALLY", CanExecute = "PixiEditor.HasDocument")]
+    [Command.Basic("PixiEditor.Document.FlipImageVertical", FlipType.Vertical, "FLIP_IMG_VERTICALLY", "FLIP_IMG_VERTICALLY", CanExecute = "PixiEditor.HasDocument")]
+    public void FlipImage(FlipType type) => ActiveDocument?.Operations.FlipImage(type);
+
+    [Command.Basic("PixiEditor.Document.FlipLayersHorizontal", FlipType.Horizontal, "FLIP_LAYERS_HORIZONTALLY", "FLIP_LAYERS_HORIZONTALLY", CanExecute = "PixiEditor.HasDocument")]
+    [Command.Basic("PixiEditor.Document.FlipLayersVertical", FlipType.Vertical, "FLIP_LAYERS_VERTICALLY", "FLIP_LAYERS_VERTICALLY", CanExecute = "PixiEditor.HasDocument")]
+    public void FlipLayers(FlipType type)
+    {
+        if (ActiveDocument?.SelectedStructureMember == null)
+            return;
+
+        ActiveDocument?.Operations.FlipImage(type, ActiveDocument.GetSelectedMembers());
+    }
+
+    [Command.Basic("PixiEditor.Document.Rotate90Deg", "ROT_IMG_90",
+        "ROT_IMG_90", CanExecute = "PixiEditor.HasDocument", Parameter = RotationAngle.D90)]
+    [Command.Basic("PixiEditor.Document.Rotate180Deg", "ROT_IMG_180",
+        "ROT_IMG_180", CanExecute = "PixiEditor.HasDocument", Parameter = RotationAngle.D180)]
+    [Command.Basic("PixiEditor.Document.Rotate270Deg", "ROT_IMG_-90",
+        "ROT_IMG_-90", CanExecute = "PixiEditor.HasDocument", Parameter = RotationAngle.D270)]
+    public void RotateImage(RotationAngle angle) => ActiveDocument?.Operations.RotateImage(angle);
+
+    [Command.Basic("PixiEditor.Document.Rotate90DegLayers", "ROT_LAYERS_90",
+        "ROT_LAYERS_90", CanExecute = "PixiEditor.HasDocument", Parameter = RotationAngle.D90)]
+    [Command.Basic("PixiEditor.Document.Rotate180DegLayers", "ROT_LAYERS_180",
+        "ROT_LAYERS_180", CanExecute = "PixiEditor.HasDocument", Parameter = RotationAngle.D180)]
+    [Command.Basic("PixiEditor.Document.Rotate270DegLayers", "ROT_LAYERS_-90",
+        "ROT_LAYERS_-90", CanExecute = "PixiEditor.HasDocument", Parameter = RotationAngle.D270)]
+    public void RotateLayers(RotationAngle angle)
+    {
+        if (ActiveDocument?.SelectedStructureMember == null)
+            return;
+        
+        ActiveDocument?.Operations.RotateImage(angle, ActiveDocument.GetSelectedMembers());
+    }
+
+    [Command.Basic("PixiEditor.Document.ToggleVerticalSymmetryAxis", "TOGGLE_VERT_SYMMETRY_AXIS", "TOGGLE_VERT_SYMMETRY_AXIS", CanExecute = "PixiEditor.HasDocument", IconPath = "SymmetryVertical.png")]
+    public void ToggleVerticalSymmetryAxis()
+    {
+        if (ActiveDocument is null)
+            return;
+        ActiveDocument.VerticalSymmetryAxisEnabledBindable ^= true;
+    }
+
+    [Command.Basic("PixiEditor.Document.ToggleHorizontalSymmetryAxis", "TOGGLE_HOR_SYMMETRY_AXIS", "TOGGLE_HOR_SYMMETRY_AXIS", CanExecute = "PixiEditor.HasDocument", IconPath = "SymmetryHorizontal.png")]
+    public void ToggleHorizontalSymmetryAxis()
+    {
+        if (ActiveDocument is null)
+            return;
+        ActiveDocument.HorizontalSymmetryAxisEnabledBindable ^= true;
+    }
+
+    [Command.Internal("PixiEditor.Document.DragSymmetry", CanExecute = "PixiEditor.HasDocument")]
+    public void DragSymmetry(SymmetryAxisDragInfo info)
+    {
+        if (ActiveDocument is null)
+            return;
+        ActiveDocument.EventInlet.OnSymmetryDragged(info);
+    }
+
+    [Command.Internal("PixiEditor.Document.StartDragSymmetry", CanExecute = "PixiEditor.HasDocument")]
+    public void StartDragSymmetry(SymmetryAxisDirection dir)
+    {
+        if (ActiveDocument is null)
+            return;
+        ActiveDocument.EventInlet.OnSymmetryDragStarted(dir);
+        ActiveDocument.Tools.UseSymmetry(dir);
+    }
+
+    [Command.Internal("PixiEditor.Document.EndDragSymmetry", CanExecute = "PixiEditor.HasDocument")]
+    public void EndDragSymmetry(SymmetryAxisDirection dir)
+    {
+        if (ActiveDocument is null)
+            return;
+        ActiveDocument.EventInlet.OnSymmetryDragEnded(dir);
+    }
+
+    [Command.Basic("PixiEditor.Document.DeletePixels", "DELETE_PIXELS", "DELETE_PIXELS_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.Delete, IconPath = "Tools/EraserImage.png")]
+    public void DeletePixels()
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.DeleteSelectedPixels();
+    }
+
+
+    [Command.Basic("PixiEditor.Document.ResizeDocument", false, "RESIZE_DOCUMENT", "RESIZE_DOCUMENT", CanExecute = "PixiEditor.HasDocument", Key = Key.I, Modifiers = ModifierKeys.Control | ModifierKeys.Shift)]
+    [Command.Basic("PixiEditor.Document.ResizeCanvas", true, "RESIZE_CANVAS", "RESIZE_CANVAS", CanExecute = "PixiEditor.HasDocument", Key = Key.C, Modifiers = ModifierKeys.Control | ModifierKeys.Shift)]
+    public void OpenResizePopup(bool canvas)
+    {
+        DocumentViewModel? doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        ResizeDocumentDialog dialog = new ResizeDocumentDialog(
+            doc.Width,
+            doc.Height,
+            canvas);
+        if (dialog.ShowDialog())
+        {
+            if (canvas)
+            {
+                doc.Operations.ResizeCanvas(new(dialog.Width, dialog.Height), dialog.ResizeAnchor);
+            }
+            else
+            {
+                doc.Operations.ResizeImage(new(dialog.Width, dialog.Height), ResamplingMethod.NearestNeighbor);
+            }
+        }
+    }
+
+    [Command.Basic("PixiEditor.Document.CenterContent", "CENTER_CONTENT", "CENTER_CONTENT", CanExecute = "PixiEditor.HasDocument")]
+    public void CenterContent()
+    {
+        if(ActiveDocument?.SelectedStructureMember == null)
+            return;
+        
+        ActiveDocument.Operations.CenterContent(ActiveDocument.GetSelectedMembers());
+    }
+}

+ 164 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentViewModel.Serialization.cs

@@ -0,0 +1,164 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Drawing;
+using System.IO;
+using System.Linq;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.DrawingApi.Core.Bridge;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Parser;
+using PixiEditor.Parser.Collections;
+using BlendMode = PixiEditor.Parser.BlendMode;
+using PixiDocument = PixiEditor.Parser.Document;
+using PixiColor = PixiEditor.DrawingApi.Core.ColorsImpl.Color;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document;
+
+internal partial class DocumentViewModel
+{
+    public PixiDocument ToSerializable()
+    {
+        var root = new Folder();
+        
+        var doc = Internals.Tracker.Document;
+
+        AddMembers(doc.StructureRoot.Children, doc, root);
+
+        var document = new PixiDocument
+        {
+            Width = Width, Height = Height,
+            Swatches = ToCollection(Swatches), Palette = ToCollection(Palette),
+            RootFolder = root, PreviewImage = (MaybeRenderWholeImage().Value as Surface)?.DrawingSurface.Snapshot().Encode().AsSpan().ToArray(),
+            ReferenceLayer = GetReferenceLayer(doc)
+        };
+
+        return document;
+    }
+
+    private static ReferenceLayer GetReferenceLayer(IReadOnlyDocument document)
+    {
+        if (document.ReferenceLayer == null)
+        {
+            return null;
+        }
+
+        var layer = document.ReferenceLayer!;
+
+        var surface = new Surface(new VecI(layer.ImageSize.X, layer.ImageSize.Y));
+        
+        surface.DrawBytes(surface.Size, layer.ImagePbgra32Bytes.ToArray(), ColorType.Bgra8888, AlphaType.Premul);
+
+        var encoder = new PngBitmapEncoder();
+
+        using var stream = new MemoryStream();
+        
+        encoder.Frames.Add(BitmapFrame.Create(surface.ToWriteableBitmap()));
+        encoder.Save(stream);
+
+        stream.Position = 0;
+
+        return new ReferenceLayer
+        {
+            Enabled = layer.IsVisible,
+            Width = (float)layer.Shape.RectSize.X,
+            Height = (float)layer.Shape.RectSize.Y,
+            OffsetX = (float)layer.Shape.TopLeft.X,
+            OffsetY = (float)layer.Shape.TopLeft.Y,
+            Corners = new Corners
+            {
+                TopLeft = layer.Shape.TopLeft.ToVector2(), 
+                TopRight = layer.Shape.TopRight.ToVector2(), 
+                BottomLeft = layer.Shape.BottomLeft.ToVector2(), 
+                BottomRight = layer.Shape.BottomRight.ToVector2()
+            },
+            Opacity = 1,
+            ImageBytes = stream.ToArray()
+        };
+    }
+
+    private static void AddMembers(IEnumerable<IReadOnlyStructureMember> members, IReadOnlyDocument document, Folder parent)
+    {
+        foreach (var member in members)
+        {
+            if (member is IReadOnlyFolder readOnlyFolder)
+            {
+                var folder = ToSerializable(readOnlyFolder);
+
+                AddMembers(readOnlyFolder.Children, document, folder);
+
+                parent.Children.Add(folder);
+            }
+            else if (member is IReadOnlyLayer readOnlyLayer)
+            {
+                parent.Children.Add(ToSerializable(readOnlyLayer, document));
+            }
+        }
+    }
+    
+    private static Folder ToSerializable(IReadOnlyFolder folder)
+    {
+        return new Folder
+        {
+            Name = folder.Name,
+            BlendMode = (BlendMode)(int)folder.BlendMode,
+            Enabled = folder.IsVisible,
+            Opacity = folder.Opacity,
+            ClipToMemberBelow = folder.ClipToMemberBelow,
+            Mask = GetMask(folder.Mask, folder.MaskIsVisible)
+        };
+    }
+    
+    private static ImageLayer ToSerializable(IReadOnlyLayer layer, IReadOnlyDocument document)
+    {
+        var result = document.GetLayerImage(layer.GuidValue);
+
+        var tightBounds = document.GetChunkAlignedLayerBounds(layer.GuidValue);
+        using var data = result?.DrawingSurface.Snapshot().Encode();
+        byte[] bytes = data?.AsSpan().ToArray();
+        var serializable = new ImageLayer
+        {
+            Width = result?.Size.X ?? 0, Height = result?.Size.Y ?? 0, OffsetX = tightBounds?.X ?? 0, OffsetY = tightBounds?.Y ?? 0,
+            Enabled = layer.IsVisible, BlendMode = (BlendMode)(int)layer.BlendMode, ImageBytes = bytes,
+            ClipToMemberBelow = layer.ClipToMemberBelow, Name = layer.Name,
+            LockAlpha = layer.LockTransparency,
+            Opacity = layer.Opacity, Mask = GetMask(layer.Mask, layer.MaskIsVisible)
+        };
+
+        return serializable;
+    }
+
+    private static Mask GetMask(IReadOnlyChunkyImage mask, bool maskVisible)
+    {
+        if (mask == null) 
+            return null;
+        
+        var maskBound = mask.FindChunkAlignedMostUpToDateBounds();
+
+        if (maskBound == null)
+        {
+            return new Mask();
+        }
+        
+        var surface = DrawingBackendApi.Current.SurfaceImplementation.Create(new ImageInfo(
+            maskBound.Value.Width,
+            maskBound.Value.Height));
+                
+        mask.DrawMostUpToDateRegionOn(new RectI(0, 0, maskBound.Value.Width, maskBound.Value.Height), ChunkResolution.Full, surface, new VecI(0, 0));
+
+        return new Mask
+        {
+            Width = maskBound.Value.Width, Height = maskBound.Value.Height,
+            OffsetX = maskBound.Value.X, OffsetY = maskBound.Value.Y,
+            Enabled = maskVisible, ImageBytes = surface.Snapshot().Encode().AsSpan().ToArray()
+        };
+    }
+
+    private ColorCollection ToCollection(ObservableCollection<PaletteColor> collection) =>
+        new(collection.Select(x => Color.FromArgb(255, x.R, x.G, x.B)));
+}

+ 623 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentViewModel.cs

@@ -0,0 +1,623 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using Models.DocumentModels.Public;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.Avalonia.Helpers.Extensions;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Actions.Undo;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.ChangeableDocument.Rendering;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.Vector;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Collections;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.DocumentModels.Public;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+using PixiEditor.Views.UserControls.SymmetryOverlay;
+using Color = PixiEditor.DrawingApi.Core.ColorsImpl.Color;
+using Colors = PixiEditor.DrawingApi.Core.ColorsImpl.Colors;
+using Point = Avalonia.Point;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document;
+
+#nullable enable
+internal partial class DocumentViewModel : ObservableObject, IDocument
+{
+    public event EventHandler<LayersChangedEventArgs>? LayersChanged;
+    public event EventHandler<DocumentSizeChangedEventArgs>? SizeChanged;
+
+    private bool busy = false;
+
+    public bool Busy
+    {
+        get => busy;
+        set => SetProperty(ref busy, value);
+    }
+
+
+    private string coordinatesString = "";
+    public string CoordinatesString
+    {
+        get => coordinatesString;
+        set => SetProperty(ref coordinatesString, value);
+    }
+
+    private string? fullFilePath = null;
+    public string? FullFilePath
+    {
+        get => fullFilePath;
+        set
+        {
+            SetProperty(ref fullFilePath, value);
+            OnPropertyChanged(nameof(FileName));
+        }
+    }
+    
+    public string FileName
+    {
+        get => fullFilePath is null ? new LocalizedString("UNNAMED") : Path.GetFileName(fullFilePath);
+    }
+
+    private Guid? lastChangeOnSave = null;
+    public bool AllChangesSaved
+    {
+        get
+        {
+            return Internals.Tracker.LastChangeGuid == lastChangeOnSave;
+        }
+    }
+
+    public DateTime OpenedUTC { get; } = DateTime.UtcNow;
+
+    private bool horizontalSymmetryAxisEnabled;
+    public bool HorizontalSymmetryAxisEnabledBindable
+    {
+        get => horizontalSymmetryAxisEnabled;
+        set
+        {
+            if (!Internals.ChangeController.IsChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new SymmetryAxisState_Action(SymmetryAxisDirection.Horizontal, value));
+        }
+    }
+
+    private bool verticalSymmetryAxisEnabled;
+    public bool VerticalSymmetryAxisEnabledBindable
+    {
+        get => verticalSymmetryAxisEnabled;
+        set
+        {
+            if (!Internals.ChangeController.IsChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new SymmetryAxisState_Action(SymmetryAxisDirection.Vertical, value));
+        }
+    }
+
+    private VecI size = new VecI(64, 64);
+    public int Width => size.X;
+    public int Height => size.Y;
+    public VecI SizeBindable => size;
+
+    private double horizontalSymmetryAxisY;
+    public double HorizontalSymmetryAxisYBindable => horizontalSymmetryAxisY;
+
+    private double verticalSymmetryAxisX;
+    public double VerticalSymmetryAxisXBindable => verticalSymmetryAxisX;
+
+    private readonly HashSet<StructureMemberViewModel> softSelectedStructureMembers = new();
+
+    public bool UpdateableChangeActive => Internals.ChangeController.IsChangeActive;
+    public bool HasSavedUndo => Internals.Tracker.HasSavedUndo;
+    public bool HasSavedRedo => Internals.Tracker.HasSavedRedo;
+
+    public FolderViewModel StructureRoot { get; }
+    public DocumentStructureModule StructureHelper { get; }
+    public DocumentToolsModule Tools { get; }
+    public DocumentOperationsModule Operations { get; }
+    public DocumentEventsModule EventInlet { get; }
+    public ActionDisplayList ActionDisplays { get; } = new(() => ViewModelMain.Current.NotifyToolActionDisplayChanged());
+    public IStructureMemberHandler? SelectedStructureMember { get; private set; } = null;
+
+    public Dictionary<ChunkResolution, DrawingSurface> Surfaces { get; set; } = new();
+    public Dictionary<ChunkResolution, WriteableBitmap> LazyBitmaps { get; set; } = new()
+    {
+        [ChunkResolution.Full] = WriteableBitmapHelpers.CreateBitmap(new VecI(64, 64)),
+        [ChunkResolution.Half] = WriteableBitmapHelpers.CreateBitmap(new VecI(32, 32)),
+        [ChunkResolution.Quarter] = WriteableBitmapHelpers.CreateBitmap(new VecI(16, 16)),
+        [ChunkResolution.Eighth] = WriteableBitmapHelpers.CreateBitmap(new VecI(8, 8)),
+    };
+    public WriteableBitmap PreviewBitmap { get; set; }
+    public DrawingSurface PreviewSurface { get; set; }
+
+    private VectorPath selectionPath = new VectorPath();
+    public VectorPath SelectionPathBindable => selectionPath;
+    public ObservableCollection<PaletteColor> Swatches { get; set; } = new(); // TODO: Replaced WPFObservableCollection, make sure it works
+    public ObservableCollection<PaletteColor> Palette { get; set; } = new(); // TODO: Same
+    public DocumentTransformViewModel TransformViewModel { get; }
+    public ReferenceLayerViewModel ReferenceLayerViewModel { get; }
+    public LineToolOverlayViewModel LineToolOverlayViewModel { get; }
+
+    public IReadOnlyCollection<IStructureMemberHandler> SoftSelectedStructureMembers => softSelectedStructureMembers;
+    private DocumentInternalParts Internals { get; }
+    IFolderHandler IDocument.StructureRoot => StructureRoot;
+    IDocumentOperations IDocument.Operations => Operations;
+    ITransformHandler IDocument.TransformHandler { get; }
+    ILineOverlayHandler IDocument.LineToolOverlayHandler => LineToolOverlayViewModel;
+    ILayerHandlerFactory IDocument.LayerHandlerFactory { get; }
+    IFolderHandlerFactory IDocument.FolderHandlerFactory { get; }
+    IReferenceLayerHandler IDocument.ReferenceLayerHandler => ReferenceLayerViewModel;
+
+
+    private DocumentViewModel()
+    {
+        var allHandlers = ViewModelMain.Current.Services.GetServices<IHandler>().ToList();
+        Internals = new DocumentInternalParts(this, allHandlers);
+        Tools = new DocumentToolsModule(this, Internals);
+        StructureHelper = new DocumentStructureModule(this);
+        EventInlet = new DocumentEventsModule(this, Internals);
+        Operations = new DocumentOperationsModule(this, Internals);
+
+        StructureRoot = new FolderViewModel(this, Internals, Internals.Tracker.Document.StructureRoot.GuidValue);
+
+        TransformViewModel = new(this);
+        TransformViewModel.TransformMoved += (_, args) => Internals.ChangeController.TransformMovedInlet(args);
+
+        LineToolOverlayViewModel = new();
+        LineToolOverlayViewModel.LineMoved += (_, args) => Internals.ChangeController.LineOverlayMovedInlet(args.Item1, args.Item2);
+
+        foreach (KeyValuePair<ChunkResolution, WriteableBitmap> bitmap in LazyBitmaps)
+        {
+            DrawingSurface? surface = WriteableBitmapHelpers.CreateDrawingSurface(bitmap.Value);
+            Surfaces[bitmap.Key] = surface;
+        }
+
+        VecI previewSize = StructureMemberViewModel.CalculatePreviewSize(SizeBindable);
+        PreviewBitmap = WriteableBitmapHelpers.CreateBitmap(previewSize);
+        PreviewSurface = WriteableBitmapHelpers.CreateDrawingSurface(PreviewBitmap);
+
+        ReferenceLayerViewModel = new(this, Internals);
+    }
+
+    /// <summary>
+    /// Creates a new document using the <paramref name="builder"/>
+    /// </summary>
+    /// <returns>The created document</returns>
+    public static DocumentViewModel Build(Action<DocumentViewModelBuilder> builder)
+    {
+        var builderInstance = new DocumentViewModelBuilder();
+        builder(builderInstance);
+
+        var viewModel = new DocumentViewModel();
+        viewModel.Operations.ResizeCanvas(new VecI(builderInstance.Width, builderInstance.Height), ResizeAnchor.Center);
+
+        var acc = viewModel.Internals.ActionAccumulator;
+
+        viewModel.Internals.ChangeController.SymmetryDraggedInlet(new SymmetryAxisDragInfo(SymmetryAxisDirection.Horizontal, builderInstance.Height / 2));
+        viewModel.Internals.ChangeController.SymmetryDraggedInlet(new SymmetryAxisDragInfo(SymmetryAxisDirection.Vertical, builderInstance.Width / 2));
+
+        acc.AddActions(
+            new SymmetryAxisPosition_Action(SymmetryAxisDirection.Horizontal, (double)builderInstance.Height / 2),
+            new EndSymmetryAxisPosition_Action(),
+            new SymmetryAxisPosition_Action(SymmetryAxisDirection.Vertical, (double)builderInstance.Width / 2),
+            new EndSymmetryAxisPosition_Action());
+
+        if (builderInstance.ReferenceLayer is { } refLayer)
+        {
+            acc
+                .AddActions(new SetReferenceLayer_Action(refLayer.Shape, refLayer.ImagePbgra32Bytes.ToImmutableArray(), refLayer.ImageSize));
+        }
+
+        viewModel.Swatches = new ObservableCollection<PaletteColor>(builderInstance.Swatches);
+        viewModel.Palette = new ObservableCollection<PaletteColor>(builderInstance.Palette);
+
+        AddMembers(viewModel.StructureRoot.GuidValue, builderInstance.Children);
+
+        acc.AddFinishedActions(new DeleteRecordedChanges_Action());
+        viewModel.MarkAsSaved();
+
+        return viewModel;
+
+        void AddMember(Guid parentGuid, DocumentViewModelBuilder.StructureMemberBuilder member)
+        {
+            acc.AddActions(
+                new CreateStructureMember_Action(parentGuid, member.GuidValue, 0, member is DocumentViewModelBuilder.LayerBuilder ? StructureMemberType.Layer : StructureMemberType.Folder),
+                new StructureMemberName_Action(member.GuidValue, member.Name)
+            );
+
+            if (!member.IsVisible)
+                acc.AddActions(new StructureMemberIsVisible_Action(member.IsVisible, member.GuidValue));
+            
+            acc.AddActions(new StructureMemberBlendMode_Action(member.BlendMode, member.GuidValue));
+            
+            acc.AddActions(new StructureMemberClipToMemberBelow_Action(member.ClipToMemberBelow, member.GuidValue));
+
+            if (member is DocumentViewModelBuilder.LayerBuilder layerBuilder)
+            {
+                acc.AddActions(new LayerLockTransparency_Action(layerBuilder.GuidValue, layerBuilder.LockAlpha));
+            }
+
+            if (member is DocumentViewModelBuilder.LayerBuilder layer && layer.Surface is not null)
+            {
+                PasteImage(member.GuidValue, layer.Surface, layer.Width, layer.Height, layer.OffsetX, layer.OffsetY, false);
+            }
+            
+            acc.AddActions(
+                new StructureMemberOpacity_Action(member.GuidValue, member.Opacity),
+                new EndStructureMemberOpacity_Action());
+
+            if (member.HasMask)
+            {
+                var maskSurface = member.Mask.Surface.Surface;
+
+                acc.AddActions(new CreateStructureMemberMask_Action(member.GuidValue));
+
+                if (!member.Mask.IsVisible)
+                    acc.AddActions(new StructureMemberMaskIsVisible_Action(member.Mask.IsVisible, member.GuidValue));
+
+                PasteImage(member.GuidValue, member.Mask.Surface, maskSurface.Size.X, maskSurface.Size.Y, 0, 0, true);
+            }
+
+            acc.AddFinishedActions();
+
+            if (member is DocumentViewModelBuilder.FolderBuilder { Children: not null } folder)
+            {
+                AddMembers(member.GuidValue, folder.Children);
+            }
+        }
+
+        void PasteImage(Guid guid, DocumentViewModelBuilder.SurfaceBuilder surface, int width, int height, int offsetX, int offsetY, bool onMask)
+        {
+            acc.AddActions(
+                new PasteImage_Action(surface.Surface, new(new RectD(new VecD(offsetX, offsetY), new(width, height))), guid, true, onMask),
+                new EndPasteImage_Action());
+        }
+
+        void AddMembers(Guid parentGuid, IEnumerable<DocumentViewModelBuilder.StructureMemberBuilder> builders)
+        {
+            foreach (var child in builders.Reverse())
+            {
+                if (child.GuidValue == default)
+                {
+                    child.GuidValue = Guid.NewGuid();
+                }
+
+                AddMember(parentGuid, child);
+            }
+        }
+    }
+
+    public void MarkAsSaved()
+    {
+        lastChangeOnSave = Internals.Tracker.LastChangeGuid;
+        OnPropertyChanged(nameof(AllChangesSaved));
+    }
+
+    public void MarkAsUnsaved()
+    {
+        lastChangeOnSave = Guid.NewGuid();
+        OnPropertyChanged(nameof(AllChangesSaved));
+    }
+
+    /// <summary>
+    /// Tries rendering the whole document
+    /// </summary>
+    /// <returns><see cref="Error"/> if the ChunkyImage was disposed, otherwise a <see cref="Surface"/> of the rendered document</returns>
+    public OneOf<Error, Surface> MaybeRenderWholeImage()
+    {
+        try
+        {
+            Surface finalSurface = new Surface(SizeBindable);
+            VecI sizeInChunks = (VecI)((VecD)SizeBindable / ChunkyImage.FullChunkSize).Ceiling();
+            for (int i = 0; i < sizeInChunks.X; i++)
+            {
+                for (int j = 0; j < sizeInChunks.Y; j++)
+                {
+                    var maybeChunk = ChunkRenderer.MergeWholeStructure(new(i, j), ChunkResolution.Full, Internals.Tracker.Document.StructureRoot);
+                    if (maybeChunk.IsT1)
+                        continue;
+                    using Chunk chunk = maybeChunk.AsT0;
+                    finalSurface.DrawingSurface.Canvas.DrawSurface(chunk.Surface.DrawingSurface, i * ChunkyImage.FullChunkSize, j * ChunkyImage.FullChunkSize);
+                } 
+            }
+            return finalSurface;
+        }
+        catch (ObjectDisposedException)
+        {
+            return new Error();
+        }
+    }
+
+    /// <summary>
+    /// Takes the selected area and converts it into a surface
+    /// </summary>
+    /// <returns><see cref="Error"/> on error, <see cref="None"/> for empty <see cref="Surface"/>, <see cref="Surface"/> otherwise.</returns>
+    public OneOf<Error, None, (Surface, RectI)> MaybeExtractSelectedArea(IStructureMemberHandler? layerToExtractFrom = null)
+    {
+        layerToExtractFrom ??= SelectedStructureMember;
+        if (layerToExtractFrom is null || layerToExtractFrom is not LayerViewModel layerVm)
+            return new Error();
+        if (SelectionPathBindable.IsEmpty)
+            return new None();
+
+        IReadOnlyLayer? layer = (IReadOnlyLayer?)Internals.Tracker.Document.FindMember(layerVm.GuidValue);
+        if (layer is null)
+            return new Error();
+
+        RectI bounds = (RectI)SelectionPathBindable.TightBounds;
+        RectI? memberImageBounds;
+        try
+        {
+            memberImageBounds = layer.LayerImage.FindChunkAlignedMostUpToDateBounds();
+        }
+        catch (ObjectDisposedException)
+        {
+            return new Error();
+        }
+        if (memberImageBounds is null)
+            return new None();
+        bounds = bounds.Intersect(memberImageBounds.Value);
+        bounds = bounds.Intersect(new RectI(VecI.Zero, SizeBindable));
+        if (bounds.IsZeroOrNegativeArea)
+            return new None();
+
+        Surface output = new(bounds.Size);
+
+        VectorPath clipPath = new VectorPath(SelectionPathBindable) { FillType = PathFillType.EvenOdd };
+        clipPath.Transform(Matrix3X3.CreateTranslation(-bounds.X, -bounds.Y));
+        output.DrawingSurface.Canvas.Save();
+        output.DrawingSurface.Canvas.ClipPath(clipPath);
+        try
+        {
+            layer.LayerImage.DrawMostUpToDateRegionOn(bounds, ChunkResolution.Full, output.DrawingSurface, VecI.Zero);
+        }
+        catch (ObjectDisposedException)
+        {
+            output.Dispose();
+            return new Error();
+        }
+        output.DrawingSurface.Canvas.Restore();
+
+        return (output, bounds);
+    }
+
+    /// <summary>
+    /// Picks the color at <paramref name="pos"/>
+    /// </summary>
+    /// <param name="includeReference">Should the color be picked from the reference layer</param>
+    /// <param name="includeCanvas">Should the color be picked from the canvas</param>
+    /// <param name="referenceTopmost">Is the reference layer topmost. (Only affects the result is includeReference and includeCanvas are set.)</param>
+    public Color PickColor(VecD pos, DocumentScope scope, bool includeReference, bool includeCanvas, bool referenceTopmost = false)
+    {
+        if (scope == DocumentScope.SingleLayer && includeReference && includeCanvas)
+            includeReference = false;
+
+        if (includeCanvas && includeReference)
+        {
+            Color canvasColor = PickColorFromCanvas((VecI)pos, scope);
+            Color? potentialReferenceColor = PickColorFromReferenceLayer(pos);
+            if (potentialReferenceColor is not { } referenceColor)
+                return canvasColor;
+
+            if (!referenceTopmost)
+            {
+                return ColorHelpers.BlendColors(referenceColor, canvasColor);
+            }
+
+            byte referenceAlpha = canvasColor.A == 0 ? referenceColor.A : (byte)(referenceColor.A * ReferenceLayerViewModel.TopMostOpacity);
+
+            referenceColor = new Color(referenceColor.R, referenceColor.G, referenceColor.B, referenceAlpha);
+            return ColorHelpers.BlendColors(canvasColor, referenceColor);
+
+        }
+        if (includeCanvas)
+            return PickColorFromCanvas((VecI)pos, scope);
+        if (includeReference)
+            return PickColorFromReferenceLayer(pos) ?? Colors.Transparent;
+        return Colors.Transparent;
+    }
+
+    public Color? PickColorFromReferenceLayer(VecD pos)
+    {
+        WriteableBitmap? bitmap = ReferenceLayerViewModel.ReferenceBitmap; 
+        if (bitmap is null)
+            return null;
+        
+        Matrix matrix = ReferenceLayerViewModel.ReferenceTransformMatrix;
+        matrix.Invert();
+        var transformed = matrix.Transform(new Point(pos.X, pos.Y));
+
+        if (transformed.X < 0 || transformed.Y < 0 || transformed.X >= bitmap.PixelSize.Width || transformed.Y >= bitmap.PixelSize.Height)
+            return null;
+
+        using var frameBuffer = bitmap.Lock();
+        return frameBuffer.GetPixel((int)transformed.X, (int)transformed.Y).ToColor();
+    }
+
+    public Color PickColorFromCanvas(VecI pos, DocumentScope scope)
+    {
+        // there is a tiny chance that the image might get disposed by another thread
+        try
+        {
+            // it might've been a better idea to implement this function asynchronously
+            // via a passthrough action to avoid all the try catches
+            if (scope == DocumentScope.AllLayers)
+            {
+                VecI chunkPos = OperationHelper.GetChunkPos(pos, ChunkyImage.FullChunkSize);
+                return ChunkRenderer.MergeWholeStructure(chunkPos, ChunkResolution.Full, Internals.Tracker.Document.StructureRoot, new RectI(pos, VecI.One))
+                    .Match<Color>(
+                        (Chunk chunk) =>
+                        {
+                            VecI posOnChunk = pos - chunkPos * ChunkyImage.FullChunkSize;
+                            var color = chunk.Surface.GetSRGBPixel(posOnChunk);
+                            chunk.Dispose();
+                            return color;
+                        },
+                        _ => Colors.Transparent
+                    );
+            }
+
+            if (SelectedStructureMember is not LayerViewModel layerVm)
+                return Colors.Transparent;
+            IReadOnlyStructureMember? maybeMember = Internals.Tracker.Document.FindMember(layerVm.GuidValue);
+            if (maybeMember is not IReadOnlyLayer layer)
+                return Colors.Transparent;
+            return layer.LayerImage.GetMostUpToDatePixel(pos);
+        }
+        catch (ObjectDisposedException)
+        {
+            return Colors.Transparent;
+        }
+    }
+
+    #region Internal Methods
+    // these are intended to only be called from DocumentUpdater
+
+    public void RaiseLayersChanged(LayersChangedEventArgs args) => LayersChanged?.Invoke(this, args);
+
+    public void RaiseSizeChanged(DocumentSizeChangedEventArgs args) => SizeChanged?.Invoke(this, args);
+
+    public void ISetVerticalSymmetryAxisEnabled(bool verticalSymmetryAxisEnabled)
+    {
+        this.verticalSymmetryAxisEnabled = verticalSymmetryAxisEnabled;
+        OnPropertyChanged(nameof(VerticalSymmetryAxisEnabledBindable));
+    }
+
+    public void SetHorizontalSymmetryAxisEnabled(bool horizontalSymmetryAxisEnabled)
+    {
+        this.horizontalSymmetryAxisEnabled = horizontalSymmetryAxisEnabled;
+        OnPropertyChanged(nameof(HorizontalSymmetryAxisEnabledBindable));
+    }
+
+    public void SetVerticalSymmetryAxisEnabled(bool infoState)
+    {
+        throw new NotImplementedException();
+    }
+
+    public void SetVerticalSymmetryAxisX(double verticalSymmetryAxisX)
+    {
+        this.verticalSymmetryAxisX = verticalSymmetryAxisX;
+        OnPropertyChanged(nameof(VerticalSymmetryAxisXBindable));
+    }
+
+    public void SetSelectedMember(IStructureMemberHandler member)
+    {
+        throw new NotImplementedException();
+    }
+
+    public void SetHorizontalSymmetryAxisY(double horizontalSymmetryAxisY)
+    {
+        this.horizontalSymmetryAxisY = horizontalSymmetryAxisY;
+        OnPropertyChanged(nameof(HorizontalSymmetryAxisYBindable));
+    }
+
+    public void SetSize(VecI size)
+    {
+        this.size = size;
+        OnPropertyChanged(nameof(SizeBindable));
+        OnPropertyChanged(nameof(Width));
+        OnPropertyChanged(nameof(Height));
+    }
+
+    public void UpdateSelectionPath(VectorPath vectorPath)
+    {
+        (VectorPath? toDispose, this.selectionPath) = (this.selectionPath, vectorPath);
+        toDispose.Dispose();
+        OnPropertyChanged(nameof(SelectionPathBindable));
+    }
+
+    public void SetSelectedMember(StructureMemberViewModel? member)
+    {
+        SelectedStructureMember = member;
+        OnPropertyChanged(nameof(SelectedStructureMember));
+    }
+
+    public void RemoveSoftSelectedMember(IStructureMemberHandler member)
+    {
+        SelectedStructureMember = member;
+    }
+
+    public void ClearSoftSelectedMembers() => softSelectedStructureMembers.Clear();
+    public void AddSoftSelectedMember(IStructureMemberHandler member)
+    {
+        throw new NotImplementedException();
+    }
+
+    public void AddSoftSelectedMember(StructureMemberViewModel member) => softSelectedStructureMembers.Add(member);
+    public void RemoveSoftSelectedMember(StructureMemberViewModel member) => softSelectedStructureMembers.Remove(member);
+    #endregion
+
+    /// <summary>
+    /// Returns a list of all selected members (Hard and Soft selected)
+    /// </summary>
+    public List<Guid> GetSelectedMembers()
+    {
+        List<Guid> layerGuids = new List<Guid>();
+        if (SelectedStructureMember is not null)
+            layerGuids.Add(SelectedStructureMember.GuidValue);
+
+        layerGuids.AddRange(softSelectedStructureMembers.Select(x => x.GuidValue));
+        return layerGuids;
+    }
+
+    public List<Guid> ExtractSelectedLayers(bool includeFoldersWithMask = false)
+    {
+        var result = new List<Guid>();
+        List<Guid> selectedMembers = GetSelectedMembers();
+        foreach (var member in selectedMembers)
+        {
+            var foundMember = StructureHelper.Find(member);
+            if (foundMember != null)
+            {
+                if (foundMember is LayerViewModel layer && selectedMembers.Contains(foundMember.GuidValue) && !result.Contains(layer.GuidValue))
+                {
+                    result.Add(layer.GuidValue);
+                }
+                else if (foundMember is FolderViewModel folder && selectedMembers.Contains(foundMember.GuidValue))
+                {
+                    if (includeFoldersWithMask && folder.HasMaskBindable && !result.Contains(folder.GuidValue))
+                        result.Add(folder.GuidValue);
+                    ExtractSelectedLayers(folder, result, includeFoldersWithMask);
+                }
+            }
+        }
+        return result;
+    }
+
+    private void ExtractSelectedLayers(FolderViewModel folder, List<Guid> list,
+        bool includeFoldersWithMask)
+    {
+        foreach (var member in folder.Children)
+        {
+            if (member is LayerViewModel layer && !list.Contains(layer.GuidValue))
+            {
+                list.Add(layer.GuidValue);
+            }
+            else if (member is FolderViewModel childFolder)
+            {
+                if (includeFoldersWithMask && childFolder.HasMaskBindable && !list.Contains(childFolder.GuidValue))
+                    list.Add(childFolder.GuidValue);
+
+                ExtractSelectedLayers(childFolder, list, includeFoldersWithMask);
+            }
+        }
+    }
+}

+ 11 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/FolderViewModel.cs

@@ -0,0 +1,11 @@
+using System.Collections.ObjectModel;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document;
+#nullable enable
+internal class FolderViewModel : StructureMemberViewModel, IFolderHandler
+{
+    public ObservableCollection<IStructureMemberHandler> Children { get; } = new();
+    public FolderViewModel(DocumentViewModel doc, DocumentInternalParts internals, Guid guidValue) : base(doc, internals, guidValue) { }
+}

+ 41 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/LayerViewModel.cs

@@ -0,0 +1,41 @@
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document;
+#nullable enable
+internal class LayerViewModel : StructureMemberViewModel, ILayerHandler
+{
+    bool lockTransparency;
+    public void SetLockTransparency(bool lockTransparency)
+    {
+        this.lockTransparency = lockTransparency;
+        OnPropertyChanged(nameof(LockTransparencyBindable));
+    }
+    public bool LockTransparencyBindable
+    {
+        get => lockTransparency;
+        set
+        {
+            if (!Document.UpdateableChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new LayerLockTransparency_Action(GuidValue, value));
+        }
+    }
+
+    private bool shouldDrawOnMask = false;
+    public bool ShouldDrawOnMask
+    {
+        get => shouldDrawOnMask;
+        set
+        {
+            if (value == shouldDrawOnMask)
+                return;
+            shouldDrawOnMask = value;
+            OnPropertyChanged(nameof(ShouldDrawOnMask));
+        }
+    }
+
+    public LayerViewModel(DocumentViewModel doc, DocumentInternalParts internals, Guid guidValue) : base(doc, internals, guidValue)
+    {
+    }
+}

+ 160 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/ReferenceLayerViewModel.cs

@@ -0,0 +1,160 @@
+using System.Collections.Immutable;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document;
+
+#nullable enable
+internal class ReferenceLayerViewModel : ObservableObject, IReferenceLayerHandler
+{
+    private readonly DocumentViewModel doc;
+    private readonly DocumentInternalParts internals;
+
+    public const double TopMostOpacity = 0.6;
+    
+    public WriteableBitmap? ReferenceBitmap { get; private set; }
+
+    private ShapeCorners referenceShape;
+    public ShapeCorners ReferenceShapeBindable 
+    { 
+        get => referenceShape; 
+        set
+        {
+            if (!doc.UpdateableChangeActive)
+                internals.ActionAccumulator.AddFinishedActions(new TransformReferenceLayer_Action(value));
+        }
+    }
+    
+    public Matrix ReferenceTransformMatrix
+    {
+        get
+        {
+            if (ReferenceBitmap is null)
+                return Matrix.Identity;
+            Matrix3X3 skiaMatrix = OperationHelper.CreateMatrixFromPoints((ShapeCorners)ReferenceShapeBindable, new VecD(ReferenceBitmap.PixelSize.Width, ReferenceBitmap.PixelSize.Height));
+            return new Matrix(skiaMatrix.ScaleX, skiaMatrix.SkewY, skiaMatrix.SkewX, skiaMatrix.ScaleY, skiaMatrix.TransX, skiaMatrix.TransY);
+        }
+    }
+
+    private bool isVisible;
+    public bool IsVisibleBindable
+    {
+        get => isVisible;
+        set
+        {
+            if (!doc.UpdateableChangeActive)
+                internals.ActionAccumulator.AddFinishedActions(new ReferenceLayerIsVisible_Action(value));
+        }
+    }
+
+    private bool isTransforming;
+    public bool IsTransforming
+    {
+        get => isTransforming;
+        set
+        {
+            isTransforming = value;
+            OnPropertyChanged(nameof(IsTransforming));
+            OnPropertyChanged(nameof(ShowHighest));
+        }
+    }
+    
+    private bool isTopMost;
+    public bool IsTopMost
+    {
+        get => isTopMost;
+        set
+        {
+            if (!doc.UpdateableChangeActive)
+                internals.ActionAccumulator.AddFinishedActions(new ReferenceLayerTopMost_Action(value));
+        }
+    }
+    
+    public bool ShowHighest
+    {
+        get => (IsTopMost || IsTransforming) && !IsColorPickerSelected();
+    }
+
+    public ReferenceLayerViewModel(DocumentViewModel doc, DocumentInternalParts internals)
+    {
+        this.doc = doc;
+        this.internals = internals;
+    }
+
+    private bool IsColorPickerSelected()
+    {
+        var viewModel = ViewModelMain.Current.ToolsSubViewModel;
+        
+        if (viewModel.ActiveTool is ColorPickerToolViewModel colorPicker)
+        {
+            return colorPicker.PickFromReferenceLayer && !colorPicker.PickFromCanvas;
+        }
+
+        return false;
+    }
+
+    #region Internal methods
+
+    public void RaiseShowHighestChanged() => OnPropertyChanged(nameof(ShowHighest));
+    
+    public void SetReferenceLayer(ImmutableArray<byte> imagePbgra8888Bytes, VecI imageSize, ShapeCorners shape)
+    {
+        ReferenceBitmap = WriteableBitmapHelpers.FromPbgra8888Array(imagePbgra8888Bytes.ToArray(), imageSize);
+        referenceShape = shape;
+        isVisible = true;
+        isTransforming = false;
+        isTopMost = false;
+        OnPropertyChanged(nameof(ReferenceBitmap));
+        OnPropertyChanged(nameof(ReferenceShapeBindable));
+        OnPropertyChanged(nameof(ReferenceTransformMatrix));
+        OnPropertyChanged(nameof(IsVisibleBindable));
+        OnPropertyChanged(nameof(IsTransforming));
+        OnPropertyChanged(nameof(ShowHighest));
+    }
+
+    public void DeleteReferenceLayer()
+    {
+        ReferenceBitmap = null;
+        isVisible = false;
+        OnPropertyChanged(nameof(ReferenceBitmap));
+        OnPropertyChanged(nameof(ReferenceTransformMatrix));
+        OnPropertyChanged(nameof(IsVisibleBindable));
+    }
+    
+    public void TransformReferenceLayer(ShapeCorners shape)
+    {
+        referenceShape = shape;
+        OnPropertyChanged(nameof(ReferenceShapeBindable));
+        OnPropertyChanged(nameof(ReferenceTransformMatrix));
+    }
+
+    public void SetReferenceLayerIsVisible(bool isVisible)
+    {
+        this.isVisible = isVisible;
+        OnPropertyChanged(nameof(IsVisibleBindable));
+    }
+
+    public void SetReferenceLayerTopMost(bool isTopMost)
+    {
+        this.isTopMost = isTopMost;
+        OnPropertyChanged(nameof(IsTopMost));
+        OnPropertyChanged(nameof(ShowHighest));
+    }
+
+    #endregion
+}

+ 175 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/StructureMemberViewModel.cs

@@ -0,0 +1,175 @@
+using System.ComponentModel;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.Enums;
+using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document;
+#nullable enable
+internal abstract class StructureMemberViewModel : ObservableObject, IStructureMemberHandler
+{
+    public DocumentViewModel Document { get; }
+    protected DocumentInternalParts Internals { get; }
+
+
+    private string name = "";
+    public void SetName(string name)
+    {
+        this.name = name;
+        OnPropertyChanged(nameof(NameBindable));
+    }
+    public string NameBindable
+    {
+        get => name;
+        set
+        {
+            if (!Document.UpdateableChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new StructureMemberName_Action(GuidValue, value));
+        }
+    }
+
+    private bool isVisible;
+    public void SetIsVisible(bool isVisible)
+    {
+        this.isVisible = isVisible;
+        OnPropertyChanged(nameof(IsVisibleBindable));
+    }
+    public bool IsVisibleBindable
+    {
+        get => isVisible;
+        set
+        {
+            if (!Document.UpdateableChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new StructureMemberIsVisible_Action(value, GuidValue));
+        }
+    }
+
+    private bool maskIsVisible;
+    public void SetMaskIsVisible(bool maskIsVisible)
+    {
+        this.maskIsVisible = maskIsVisible;
+        OnPropertyChanged(nameof(MaskIsVisibleBindable));
+    }
+    public bool MaskIsVisibleBindable
+    {
+        get => maskIsVisible;
+        set
+        {
+            if (!Document.UpdateableChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new StructureMemberMaskIsVisible_Action(value, GuidValue));
+        }
+    }
+
+    private BlendMode blendMode;
+    public void SetBlendMode(BlendMode blendMode)
+    {
+        this.blendMode = blendMode;
+        OnPropertyChanged(nameof(BlendModeBindable));
+    }
+    public BlendMode BlendModeBindable
+    {
+        get => blendMode;
+        set
+        {
+            if (!Document.UpdateableChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new StructureMemberBlendMode_Action(value, GuidValue));
+        }
+    }
+
+    private bool clipToMemberBelowEnabled;
+    public void SetClipToMemberBelowEnabled(bool clipToMemberBelowEnabled)
+    {
+        this.clipToMemberBelowEnabled = clipToMemberBelowEnabled;
+        OnPropertyChanged(nameof(ClipToMemberBelowEnabledBindable));
+    }
+    public bool ClipToMemberBelowEnabledBindable
+    {
+        get => clipToMemberBelowEnabled;
+        set
+        {
+            if (!Document.UpdateableChangeActive)
+                Internals.ActionAccumulator.AddFinishedActions(new StructureMemberClipToMemberBelow_Action(value, GuidValue));
+        }
+    }
+
+    private bool hasMask;
+    public void SetHasMask(bool hasMask)
+    {
+        this.hasMask = hasMask;
+        OnPropertyChanged(nameof(HasMaskBindable));
+    }
+    public bool HasMaskBindable
+    {
+        get => hasMask;
+    }
+
+    private Guid guidValue;
+    public Guid GuidValue
+    {
+        get => guidValue;
+    }
+
+    private float opacity;
+
+    public void SetOpacity(float opacity)
+    {
+        this.opacity = opacity;
+        OnPropertyChanged(nameof(OpacityBindable));
+    }
+    public float OpacityBindable
+    {
+        get => opacity;
+        set
+        {
+            if (Document.UpdateableChangeActive)
+                return;
+            float newValue = Math.Clamp(value, 0, 1);
+            Internals.ActionAccumulator.AddFinishedActions(
+                new StructureMemberOpacity_Action(GuidValue, newValue),
+                new EndStructureMemberOpacity_Action());
+        }
+    }
+
+    public StructureMemberSelectionType Selection { get; set; }
+
+    public WriteableBitmap? PreviewBitmap { get; set; }
+    public DrawingSurface? PreviewSurface { get; set; }
+
+    public WriteableBitmap? MaskPreviewBitmap { get; set; }
+
+    public DrawingSurface? MaskPreviewSurface { get; set; }
+
+    IDocument IStructureMemberHandler.Document => Document;
+
+    /// <summary>
+    /// Calculates the size of a scaled-down preview for a given size of layer tight bounds.
+    /// </summary>
+    public static VecI CalculatePreviewSize(VecI tightBoundsSize)
+    {
+        double proportions = tightBoundsSize.Y / (double)tightBoundsSize.X;
+        const int prSize = StructureHelpers.PreviewSize;
+        return proportions > 1 ?
+            new VecI(Math.Max((int)Math.Round(prSize / proportions), 1), prSize) :
+            new VecI(prSize, Math.Max((int)Math.Round(prSize * proportions), 1));
+    }
+
+    public StructureMemberViewModel(DocumentViewModel doc, DocumentInternalParts internals, Guid guidValue)
+    {
+        Document = doc;
+        Internals = internals;
+
+        this.guidValue = guidValue;
+        PreviewBitmap = null;
+        PreviewSurface = null;
+    }
+}

+ 251 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/DocumentTransformViewModel.cs

@@ -0,0 +1,251 @@
+using System.Windows.Input;
+using ChunkyImageLib.DataHolders;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Helpers;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Localization;
+using PixiEditor.Views.UserControls.Overlays.TransformOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+#nullable enable
+internal class DocumentTransformViewModel : ObservableObject
+{
+    private DocumentViewModel document;
+    
+    private TransformOverlayUndoStack<(ShapeCorners, TransformState)>? undoStack = null;
+
+    private TransformState internalState;
+    public TransformState InternalState
+    {
+        get => internalState;
+        set => SetProperty(ref internalState, value);
+    }
+
+    private TransformCornerFreedom cornerFreedom;
+    public TransformCornerFreedom CornerFreedom
+    {
+        get => cornerFreedom;
+        set => SetProperty(ref cornerFreedom, value);
+    }
+
+    private TransformSideFreedom sideFreedom;
+    public TransformSideFreedom SideFreedom
+    {
+        get => sideFreedom;
+        set => SetProperty(ref sideFreedom, value);
+    }
+
+    private bool lockRotation;
+    public bool LockRotation
+    {
+        get => lockRotation;
+        set => SetProperty(ref lockRotation, value);
+    }
+
+    private bool snapToAngles;
+    public bool SnapToAngles
+    {
+        get => snapToAngles;
+        set => SetProperty(ref snapToAngles, value);
+    }
+
+    private bool transformActive;
+    public bool TransformActive
+    {
+        get => transformActive;
+        set
+        {
+            if (!SetProperty(ref transformActive, value))
+            {
+                return;
+            }
+
+            if (value)
+            {
+                document.ActionDisplays[nameof(DocumentTransformViewModel)] = new LocalizedString($"TRANSFORM_ACTION_DISPLAY_{activeTransformMode.GetDescription()}");
+            }
+            else
+            {
+                document.ActionDisplays[nameof(DocumentTransformViewModel)] = null;
+            }
+        }
+    }
+
+    private bool showTransformControls;
+    public bool ShowTransformControls
+    {
+        get => showTransformControls;
+        set => SetProperty(ref showTransformControls, value);
+    }
+
+    private bool coverWholeScreen;
+    public bool CoverWholeScreen
+    {
+        get => coverWholeScreen;
+        set => SetProperty(ref coverWholeScreen, value);
+    }
+
+    private ShapeCorners requestedCorners;
+    public ShapeCorners RequestedCorners
+    {
+        get => requestedCorners;
+        set
+        {
+            // The event must be raised even if the value hasn't changed, so I'm not using SetProperty
+            requestedCorners = value;
+            OnPropertyChanged(nameof(RequestedCorners));
+        }
+    }
+
+    private ShapeCorners corners;
+    public ShapeCorners Corners
+    {
+        get => corners;
+        set
+        {
+            SetProperty(ref corners, value);
+            TransformMoved?.Invoke(this, value);
+        }
+    }
+
+    private ICommand? actionCompletedCommand = null;
+    public ICommand? ActionCompletedCommand
+    {
+        get => actionCompletedCommand;
+        set => SetProperty(ref actionCompletedCommand, value);
+    }
+
+    public event EventHandler<ShapeCorners>? TransformMoved;
+
+    private DocumentTransformMode activeTransformMode = DocumentTransformMode.Scale_Rotate_NoShear_NoPerspective;
+
+    public DocumentTransformViewModel(DocumentViewModel document)
+    {
+        this.document = document;
+        ActionCompletedCommand = new RelayCommand(() =>
+        {
+            if (undoStack is null)
+                return;
+
+            var lastState = undoStack.PeekCurrent();
+            if (lastState is not null && lastState.Value.Item1.AlmostEquals(Corners) && lastState.Value.Item2.AlmostEquals(InternalState))
+                return;
+
+            undoStack.AddState((Corners, InternalState), TransformOverlayStateType.Move);
+        });
+    }
+
+    public bool Undo()
+    {
+        if (undoStack is null)
+            return false;
+        var state = undoStack.Undo();
+        if (state is null)
+            return false;
+        (Corners, InternalState) = state.Value;
+        return true;
+    }
+
+    public bool Redo()
+    {
+        if (undoStack is null)
+            return false;
+        var state = undoStack.Redo();
+        if (state is null)
+            return false;
+        (Corners, InternalState) = state.Value;
+        return true;
+    }
+
+    public bool Nudge(VecD distance)
+    {
+        if (undoStack is null)
+            return false;
+
+        InternalState = InternalState with { Origin = InternalState.Origin + distance };
+        Corners = Corners.AsTranslated(distance);
+        undoStack.AddState((Corners, InternalState), TransformOverlayStateType.Nudge);
+        return true;
+    }
+
+    public void HideTransform()
+    {
+        if (undoStack is null)
+            return;
+        undoStack = null;
+
+        TransformActive = false;
+        ShowTransformControls = false;
+    }
+
+    public void ShowTransform(DocumentTransformMode mode, bool coverWholeScreen, ShapeCorners initPos, bool showApplyButton)
+    {
+        if (undoStack is not null)
+            return;
+        undoStack = new();
+
+        activeTransformMode = mode;
+        CornerFreedom = TransformCornerFreedom.Scale;
+        SideFreedom = TransformSideFreedom.Stretch;
+        LockRotation = mode == DocumentTransformMode.Scale_NoRotate_NoShear_NoPerspective;
+        RequestedCorners = initPos;
+        CoverWholeScreen = coverWholeScreen;
+        TransformActive = true;
+        ShowTransformControls = showApplyButton;
+
+        undoStack.AddState((Corners, InternalState), TransformOverlayStateType.Initial);
+    }
+
+    public void ModifierKeysInlet(bool isShiftDown, bool isCtrlDown, bool isAltDown)
+    {
+        var requestedCornerFreedom = TransformCornerFreedom.Scale;
+        var requestedSideFreedom = TransformSideFreedom.Stretch;
+
+        SnapToAngles = isShiftDown;
+        if (isShiftDown)
+        {
+            requestedCornerFreedom = TransformCornerFreedom.ScaleProportionally;
+            requestedSideFreedom = TransformSideFreedom.ScaleProportionally;
+        }
+        else if (isCtrlDown)
+        {
+            requestedCornerFreedom = TransformCornerFreedom.Free;
+            requestedSideFreedom = TransformSideFreedom.Free;
+        }
+        else if (isAltDown)
+        {
+            requestedSideFreedom = TransformSideFreedom.Shear;
+        }
+        else
+        {
+            requestedCornerFreedom = TransformCornerFreedom.Scale;
+            requestedSideFreedom = TransformSideFreedom.Stretch;
+        }
+
+        switch (activeTransformMode)
+        {
+            case DocumentTransformMode.Scale_Rotate_Shear_Perspective:
+                CornerFreedom = requestedCornerFreedom;
+                SideFreedom = requestedSideFreedom;
+                break;
+
+            case DocumentTransformMode.Scale_Rotate_Shear_NoPerspective:
+                if (requestedCornerFreedom != TransformCornerFreedom.Free)
+                    CornerFreedom = requestedCornerFreedom;
+                SideFreedom = requestedSideFreedom;
+                break;
+
+            case DocumentTransformMode.Scale_Rotate_NoShear_NoPerspective:
+            case DocumentTransformMode.Scale_NoRotate_NoShear_NoPerspective:
+                if (requestedCornerFreedom != TransformCornerFreedom.Free)
+                    CornerFreedom = requestedCornerFreedom;
+                if (requestedSideFreedom is not (TransformSideFreedom.Free or TransformSideFreedom.Shear))
+                    SideFreedom = requestedSideFreedom;
+                break;
+        }
+    }
+}

+ 121 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/LineToolOverlayViewModel.cs

@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PixiEditor;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Containers;
+using PixiEditor.ViewModels;
+using PixiEditor.ViewModels.SubViewModels;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+internal class LineToolOverlayViewModel : ObservableObject, ILineOverlayHandler
+{
+    public event EventHandler<(VecD, VecD)>? LineMoved;
+
+    private TransformOverlayUndoStack<(VecD, VecD)>? undoStack = null;
+
+    private VecD lineStart;
+    public VecD LineStart
+    {
+        get => lineStart;
+        set
+        {
+            if (SetProperty(ref lineStart, value))
+                LineMoved?.Invoke(this, (lineStart, lineEnd));
+        }
+    }
+
+    private VecD lineEnd;
+    public VecD LineEnd
+    {
+        get => lineEnd;
+        set
+        {
+            if (SetProperty(ref lineEnd, value))
+                LineMoved?.Invoke(this, (lineStart, lineEnd));
+        }
+    }
+
+    private bool isEnabled;
+    public bool IsEnabled
+    {
+        get => isEnabled;
+        set => SetProperty(ref isEnabled, value);
+    }
+
+    private ICommand? actionCompletedCommand = null;
+    public ICommand? ActionCompletedCommand
+    {
+        get => actionCompletedCommand;
+        set => SetProperty(ref actionCompletedCommand, value);
+    }
+
+    public LineToolOverlayViewModel()
+    {
+        ActionCompletedCommand = new RelayCommand(() => undoStack?.AddState((LineStart, LineEnd), TransformOverlayStateType.Move));
+    }
+
+    public void Show(VecD lineStart, VecD lineEnd)
+    {
+        if (undoStack is not null)
+            return;
+        undoStack = new();
+        undoStack.AddState((lineStart, lineEnd), TransformOverlayStateType.Initial);
+
+        LineStart = lineStart;
+        LineEnd = lineEnd;
+        IsEnabled = true;
+    }
+
+    public void Hide()
+    {
+        if (undoStack is null)
+            return;
+        undoStack = null;
+        IsEnabled = false;
+    }
+
+    public bool Nudge(VecD distance)
+    {
+        if (undoStack is null)
+            return false;
+        LineStart = LineStart + distance;
+        LineEnd = LineEnd + distance;
+        undoStack.AddState((lineStart, lineEnd), TransformOverlayStateType.Nudge);
+        return true;
+    }
+
+    public bool Undo()
+    {
+        if (undoStack is null)
+            return false;
+
+        var newState = undoStack.Undo();
+        if (newState is null)
+            return false;
+        LineStart = newState.Value.Item1;
+        LineEnd = newState.Value.Item2;
+        return true;
+    }
+
+    public bool Redo()
+    {
+        if (undoStack is null)
+            return false;
+
+        var newState = undoStack.Redo();
+        if (newState is null)
+            return false;
+        LineStart = newState.Value.Item1;
+        LineEnd = newState.Value.Item2;
+        return true;
+    }
+}

+ 28 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/TransformOverlayActionType.cs

@@ -0,0 +1,28 @@
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+internal enum TransformOverlayStateType
+{
+    /// <summary>
+    /// The overlay was moved via mouse
+    /// </summary>
+    Move,
+
+    /// <summary>
+    /// The overlay was nudged using arrows keys
+    /// </summary>
+    Nudge,
+
+    /// <summary>
+    /// The overlay was set to this state when it was enabled
+    /// </summary>
+    Initial
+}
+
+internal static class TransformOverlayStateTypeEx
+{
+    public static bool IsMergeable(this TransformOverlayStateType type) => type switch
+    {
+        TransformOverlayStateType.Move => false,
+        TransformOverlayStateType.Nudge => true,
+        TransformOverlayStateType.Initial => false
+    };
+}

+ 76 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/TransformOverlayUndoStack.cs

@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+
+namespace PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+internal class TransformOverlayUndoStack<TState> where TState : struct
+{
+    private struct StackItem<TState>
+    {
+        public TState State { get; set; }
+        public TransformOverlayStateType Type { get; set; }
+
+        public StackItem(TState state, TransformOverlayStateType type)
+        {
+            State = state;
+            Type = type;
+        }
+    }
+
+    private Stack<StackItem<TState>> undoStack = new();
+    private Stack<StackItem<TState>> redoStack = new();
+    private StackItem<TState>? current;
+
+    public void AddState(TState state, TransformOverlayStateType type)
+    {
+        redoStack.Clear();
+        if (current is not null)
+            undoStack.Push(current.Value);
+
+        current = new(state, type);
+    }
+
+    public TState? PeekCurrent() => current?.State;
+
+    public TState? Undo()
+    {
+        if (current is null || undoStack.Count == 0)
+            return null;
+
+        while (true)
+        {
+            TransformOverlayStateType oldType = current.Value.Type;
+            DoUndoStep();
+            TransformOverlayStateType newType = current.Value.Type;
+            if (oldType != newType || !oldType.IsMergeable() || undoStack.Count == 0)
+                break;
+        }
+        return current.Value.State;
+    }
+
+    public TState? Redo()
+    {
+        if (current is null || redoStack.Count == 0)
+            return null;
+
+        while (true)
+        {
+            TransformOverlayStateType oldType = current.Value.Type;
+            DoRedoStep();
+            TransformOverlayStateType newType = current.Value.Type;
+            if (oldType != newType || !oldType.IsMergeable() || redoStack.Count == 0)
+                break;
+        }
+        return current.Value.State;
+    }
+
+    private void DoUndoStep()
+    {
+        redoStack.Push(current.Value);
+        current = undoStack.Pop();
+    }
+
+    private void DoRedoStep()
+    {
+        undoStack.Push(current.Value);
+        current = redoStack.Pop();
+    }
+}

+ 0 - 49
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/MainViewModel.cs

@@ -1,49 +0,0 @@
-using System.Reactive;
-using CommunityToolkit.Mvvm.Input;
-using Microsoft.Extensions.DependencyInjection;
-using PixiEditor.Extensions.Common.Localization;
-using PixiEditor.Extensions.Common.UserPreferences;
-using PixiEditor.Helpers.Extensions;
-using PixiEditor.Models.Commands;
-using PixiEditor.ViewModels.SubViewModels;
-
-namespace PixiEditor.Avalonia.ViewModels;
-
-internal partial class MainViewModel : ViewModelBase
-{
-    public event Action OnStartupEvent;
-
-    public IServiceProvider Services { get; set; }
-    public CommandController CommandController { get; set; }
-    public ToolsViewModel ToolsViewModel { get; set; }
-
-    public IPreferences Preferences { get; set; }
-    public ILocalizationProvider LocalizationProvider { get; set; }
-
-    public MainViewModel()
-    {
-
-    }
-
-    public void Setup(IServiceProvider services)
-    {
-        Services = services;
-
-        Preferences = services.GetRequiredService<IPreferences>();
-        Preferences.Init();
-
-        LocalizationProvider = services.GetRequiredService<ILocalizationProvider>();
-        LocalizationProvider.LoadData();
-
-        ToolsViewModel = services.GetService<ToolsViewModel>();
-
-        CommandController = services.GetService<CommandController>();
-        CommandController.Init(services);
-    }
-
-    [RelayCommand]
-    private void OnStartup()
-    {
-        OnStartupEvent?.Invoke();
-    }
-}

+ 180 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ClipboardViewModel.cs

@@ -0,0 +1,180 @@
+using System.Collections.Immutable;
+using Avalonia.Input;
+using Avalonia.Media;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Commands.Attributes.Commands;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+#nullable enable
+[Command.Group("PixiEditor.Clipboard", "CLIPBOARD")]
+internal class ClipboardViewModel : SubViewModel<ViewModelMain>
+{
+    public ClipboardViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+    }
+
+    [Command.Basic("PixiEditor.Clipboard.Cut", "CUT", "CUT_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.X, Modifiers = ModifierKeys.Control)]
+    public void Cut()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        Copy();
+        doc.Operations.DeleteSelectedPixels(true);
+    }
+
+    [Command.Basic("PixiEditor.Clipboard.Paste", false, "PASTE", "PASTE_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = ModifierKeys.Shift)]
+    [Command.Basic("PixiEditor.Clipboard.PasteAsNewLayer", true, "PASTE_AS_NEW_LAYER", "PASTE_AS_NEW_LAYER_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = ModifierKeys.Control)]
+    public void Paste(bool pasteAsNewLayer)
+    {
+        if (Owner.DocumentManagerSubViewModel.ActiveDocument is null) 
+            return;
+        ClipboardController.TryPasteFromClipboard(Owner.DocumentManagerSubViewModel.ActiveDocument, pasteAsNewLayer);
+    }
+    
+    [Command.Basic("PixiEditor.Clipboard.PasteReferenceLayer", "PASTE_REFERENCE_LAYER", "PASTE_REFERENCE_LAYER_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste")]
+    public void PasteReferenceLayer(DataObject data)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+
+        var surface = (data == null ? ClipboardController.GetImagesFromClipboard() : ClipboardController.GetImage(data)).First();
+        using var image = surface.image;
+        
+        var bitmap = surface.image.ToWriteableBitmap();
+
+        byte[] pixels = new byte[bitmap.PixelWidth * bitmap.PixelHeight * 4];
+        bitmap.CopyPixels(pixels, bitmap.PixelWidth * 4, 0);
+
+        doc.Operations.ImportReferenceLayer(
+            pixels.ToImmutableArray(),
+            surface.image.Size);
+
+        Application.Current.MainWindow!.Activate();
+    }
+    
+    [Command.Internal("PixiEditor.Clipboard.PasteReferenceLayerFromPath")]
+    public void PasteReferenceLayer(string path)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+
+        var bitmap = Importer.GetPreviewBitmap(path);
+        byte[] pixels = new byte[bitmap.PixelWidth * bitmap.PixelHeight * 4];
+        bitmap.CopyPixels(pixels, bitmap.PixelWidth * 4, 0);
+
+        doc.Operations.ImportReferenceLayer(
+            pixels.ToImmutableArray(),
+            new VecI(bitmap.PixelWidth, bitmap.PixelHeight));
+    }
+
+    [Command.Basic("PixiEditor.Clipboard.PasteColor", false, "PASTE_COLOR", "PASTE_COLOR_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPasteColor", IconEvaluator = "PixiEditor.Clipboard.PasteColorIcon")]
+    [Command.Basic("PixiEditor.Clipboard.PasteColorAsSecondary", true, "PASTE_COLOR_SECONDARY", "PASTE_COLOR_SECONDARY_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPasteColor", IconEvaluator = "PixiEditor.Clipboard.PasteColorIcon")]
+    public void PasteColor(bool secondary)
+    {
+        if (!ColorHelper.ParseAnyFormat(Clipboard.GetText().Trim(), out var result))
+        {
+            return;
+        }
+
+        if (!secondary)
+        {
+            Owner.ColorsSubViewModel.PrimaryColor = result.Value;
+        }
+        else
+        {
+            Owner.ColorsSubViewModel.SecondaryColor = result.Value;
+        }
+    }
+
+    [Command.Basic("PixiEditor.Clipboard.Copy", "COPY", "COPY_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.C, Modifiers = ModifierKeys.Control)]
+    public void Copy()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        ClipboardController.CopyToClipboard(doc);
+    }
+
+    [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsHex", CopyColor.PrimaryHEX, "COPY_COLOR_HEX", "COPY_COLOR_HEX_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon")]
+    [Command.Basic("PixiEditor.Clipboard.CopyPrimaryColorAsRgb", CopyColor.PrimaryRGB, "COPY_COLOR_RGB", "COPY_COLOR_RGB_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon")]
+    [Command.Basic("PixiEditor.Clipboard.CopySecondaryColorAsHex", CopyColor.SecondaryHEX, "COPY_COLOR_SECONDARY_HEX", "COPY_COLOR_SECONDARY_HEX_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon")]
+    [Command.Basic("PixiEditor.Clipboard.CopySecondaryColorAsRgb", CopyColor.SecondardRGB, "COPY_COLOR_SECONDARY_RGB", "COPY_COLOR_SECONDARY_RGB_DESCRIPTIVE", IconEvaluator = "PixiEditor.Clipboard.CopyColorIcon")]
+    [Command.Filter("PixiEditor.Clipboard.CopyColorToClipboard", "COPY_COLOR_TO_CLIPBOARD", "COPY_COLOR", Key = Key.C, Modifiers = ModifierKeys.Shift)]
+    public void CopyColorAsHex(CopyColor color)
+    {
+        var targetColor = color switch
+        {
+            CopyColor.PrimaryHEX or CopyColor.PrimaryRGB => Owner.ColorsSubViewModel.PrimaryColor,
+            _ => Owner.ColorsSubViewModel.SecondaryColor
+        };
+
+        string text = color switch
+        {
+            CopyColor.PrimaryHEX or CopyColor.SecondaryHEX => targetColor.A == 255
+                ? $"#{targetColor.R:X2}{targetColor.G:X2}{targetColor.B:X2}"
+                : targetColor.ToString(),
+            _ => targetColor.A == 255
+                ? $"rgb({targetColor.R},{targetColor.G},{targetColor.B})"
+                : $"rgba({targetColor.R},{targetColor.G},{targetColor.B},{targetColor.A})",
+        };
+
+        Clipboard.SetText(text);
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Clipboard.CanPaste")]
+    public bool CanPaste(object parameter)
+    {
+        return Owner.DocumentIsNotNull(null) && parameter is DataObject data ? ClipboardController.IsImage(data) : ClipboardController.IsImageInClipboard();
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteColor")]
+    public static bool CanPasteColor() => ColorHelper.ParseAnyFormat(Clipboard.GetText().Trim(), out _);
+
+    [Evaluator.Icon("PixiEditor.Clipboard.PasteColorIcon")]
+    public static IImage GetPasteColorIcon()
+    {
+        Color color;
+
+        color = ColorHelper.ParseAnyFormat(Clipboard.GetText().Trim(), out var result) ? result.Value.ToOpaqueMediaColor() : Colors.Transparent;
+
+        return ColorSearchResult.GetIcon(color.ToOpaqueColor());
+    }
+
+    [Evaluator.Icon("PixiEditor.Clipboard.CopyColorIcon")]
+    public IImage GetCopyColorIcon(object data)
+    {
+        if (data is CopyColor color)
+        {
+        }
+        else if (data is Models.Commands.Commands.Command.BasicCommand command)
+        {
+            color = (CopyColor)command.Parameter;
+        }
+        else if (data is CommandSearchResult result)
+        {
+            color = (CopyColor)((Models.Commands.Commands.Command.BasicCommand)result.Command).Parameter;
+        }
+        else
+        {
+            throw new ArgumentException("data must be of type CopyColor, BasicCommand or CommandSearchResult");
+        }
+        
+        var targetColor = color switch
+        {
+            CopyColor.PrimaryHEX or CopyColor.PrimaryRGB => Owner.ColorsSubViewModel.PrimaryColor,
+            _ => Owner.ColorsSubViewModel.SecondaryColor
+        };
+
+        return ColorSearchResult.GetIcon(targetColor.ToOpaqueMediaColor().ToOpaqueColor());
+    }
+
+    public enum CopyColor
+    {
+        PrimaryHEX,
+        PrimaryRGB,
+        SecondaryHEX,
+        SecondardRGB
+    }
+}

+ 369 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ColorsViewModel.cs

@@ -0,0 +1,369 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Media;
+using Avalonia.Input;
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.AppExtensions.Services;
+using PixiEditor.Models.Commands.XAML;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.DataHolders.Palettes;
+using PixiEditor.Models.DataProviders;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.ExternalServices;
+using PixiEditor.Models.IO;
+using PixiEditor.Models.Localization;
+using PixiEditor.Views.Dialogs;
+using Color = PixiEditor.DrawingApi.Core.ColorsImpl.Color;
+using Colors = PixiEditor.DrawingApi.Core.ColorsImpl.Colors;
+using Command = PixiEditor.Models.Commands.Attributes.Commands.Command;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.Colors", "PALETTE_COLORS")]
+internal class ColorsViewModel : SubViewModel<ViewModelMain>
+{
+    public RelayCommand<List<PaletteColor>> ImportPaletteCommand { get; set; }
+    private PaletteProvider paletteProvider;
+
+    public PaletteProvider PaletteProvider
+    {
+        get => paletteProvider;
+        set => this.SetProperty(ref paletteProvider, value);
+    }
+
+    public LocalPalettesFetcher LocalPaletteFetcher => _localPaletteFetcher ??=
+        (LocalPalettesFetcher)PaletteProvider.DataSources.FirstOrDefault(x => x is LocalPalettesFetcher)!;
+
+    private Color primaryColor = Colors.Black;
+    private LocalPalettesFetcher _localPaletteFetcher;
+
+    public Color PrimaryColor // Primary color, hooked with left mouse button
+    {
+        get => primaryColor;
+        set
+        {
+            if (primaryColor != value)
+            {
+                primaryColor = value;
+                OnPropertyChanged(nameof(PrimaryColor));
+            }
+        }
+    }
+
+    private Color secondaryColor = Colors.White;
+
+    public Color SecondaryColor
+    {
+        get => secondaryColor;
+        set
+        {
+            if (secondaryColor != value)
+            {
+                secondaryColor = value;
+                OnPropertyChanged(nameof(SecondaryColor));
+            }
+        }
+    }
+
+    public ColorsViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+        ImportPaletteCommand = new RelayCommand<List<PaletteColor>>(ImportPalette, CanImportPalette);
+        Owner.OnStartupEvent += OwnerOnStartupEvent;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Colors.CanReplaceColors")]
+    public bool CanReplaceColors()
+    {
+        return ViewModelMain.Current?.DocumentManagerSubViewModel?.ActiveDocument is not null;
+    }
+
+    [Command.Internal("PixiEditor.Colors.ReplaceColors")]
+    public void ReplaceColors((PaletteColor oldColor, PaletteColor newColor) colors)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null || colors.oldColor == colors.newColor)
+            return;
+        doc.Operations.ReplaceColor(colors.oldColor, colors.newColor);
+    }
+
+    [Command.Basic("PixiEditor.Colors.ReplaceSecondaryByPrimaryColor", false, "REPLACE_SECONDARY_BY_PRIMARY", "REPLACE_SECONDARY_BY_PRIMARY", IconEvaluator = "PixiEditor.Colors.ReplaceColorIcon")]
+    [Command.Basic("PixiEditor.Colors.ReplacePrimaryBySecondaryColor", true, "REPLACE_PRIMARY_BY_SECONDARY", "REPLACE_PRIMARY_BY_SECONDARY_DESCRIPTIVE", IconEvaluator = "PixiEditor.Colors.ReplaceColorIcon")]
+    public void ReplaceColors(bool replacePrimary)
+    {
+        PaletteColor oldColor = replacePrimary ? PrimaryColor.ToPaletteColor() : SecondaryColor.ToPaletteColor();
+        PaletteColor newColor = replacePrimary ? SecondaryColor.ToPaletteColor() : PrimaryColor.ToPaletteColor();
+        
+        ReplaceColors((oldColor, newColor));
+    }
+
+    [Evaluator.Icon("PixiEditor.Colors.ReplaceColorIcon")]
+    public IImage ReplaceColorsIcon(object command)
+    {
+        bool replacePrimary = command switch
+        {
+            CommandSearchResult result => (bool)result.Command.GetParameter(),
+            Models.Commands.Commands.Command cmd => (bool)cmd.GetParameter(),
+            _ => false
+        };
+        
+        var oldColor = replacePrimary ? PrimaryColor : SecondaryColor;
+        var newColor = replacePrimary ? SecondaryColor : PrimaryColor;
+        
+        var oldDrawing = new GeometryDrawing { Brush = new SolidColorBrush(oldColor.ToOpaqueMediaColor()), Pen = new(Brushes.Gray, .5) };
+        var oldGeometry = new EllipseGeometry(new Point(5, 5), 5, 5);
+        
+        oldDrawing.Geometry = oldGeometry;
+        
+        var newDrawing = new GeometryDrawing { Brush = new SolidColorBrush(newColor.ToOpaqueMediaColor()), Pen = new(Brushes.White, 1) };
+        var newGeometry = new EllipseGeometry(new Point(10, 10), 6, 6);
+
+        newDrawing.Geometry = newGeometry;
+        
+        return new DrawingImage(new DrawingGroup
+        {
+            Children = new DrawingCollection
+            {
+                oldDrawing,
+                newDrawing
+            }
+        });
+    }
+
+    private async void OwnerOnStartupEvent(object sender, EventArgs e)
+    {
+        await ImportLospecPalette();
+    }
+
+    [Command.Basic("PixiEditor.Colors.OpenPaletteBrowser", "OPEN_PALETTE_BROWSER", "OPEN_PALETTE_BROWSER", CanExecute = "PixiEditor.HasDocument", IconPath = "Globe.png")]
+    public void OpenPalettesBrowser() 
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is not null)
+            PalettesBrowser.Open(PaletteProvider, ImportPaletteCommand, doc.Palette);
+    } 
+
+    private async Task ImportLospecPalette()
+    {
+        var args = StartupArgs.Args;
+        var lospecPaletteArg = args.FirstOrDefault(x => x.StartsWith("lospec-palette://"));
+
+        if (lospecPaletteArg != null)
+        {
+            var browser = PalettesBrowser.Open(PaletteProvider, ImportPaletteCommand,
+                new WpfObservableRangeCollection<PaletteColor>());
+
+            browser.IsFetching = true;
+            var palette = await LospecPaletteFetcher.FetchPalette(lospecPaletteArg.Split(@"://")[1].Replace("/", ""));
+            if (palette != null)
+            {
+                if (LocalPalettesFetcher.PaletteExists(palette.Name))
+                {
+                    var consent = ConfirmationDialog.Show(
+                        new LocalizedString("OVERWRITE_PALETTE_CONSENT", palette.Name),
+                        new LocalizedString("PALETTE_EXISTS"));
+                    if (consent == ConfirmationType.No)
+                    {
+                        palette.Name = LocalPalettesFetcher.GetNonExistingName(palette.Name);
+                    }
+                    else if (consent == ConfirmationType.Canceled)
+                    {
+                        browser.IsFetching = false;
+                        return;
+                    }
+                }
+
+                await SavePalette(palette, browser);
+            }
+            else
+            {
+                await browser.UpdatePaletteList();
+            }
+        }
+    }
+
+    private async Task SavePalette(Palette palette, PalettesBrowser browser)
+    {
+        palette.FileName = $"{palette.Name}.pal";
+
+        await LocalPaletteFetcher.SavePalette(
+            palette.FileName,
+            palette.Colors.Select(x => new PaletteColor(x.R, x.G, x.B)).ToArray());
+
+        await browser.UpdatePaletteList();
+        if (browser.SortedResults.Any(x => x.FileName == palette.FileName))
+        {
+            int indexOfImported =
+                browser.SortedResults.IndexOf(browser.SortedResults.First(x => x.FileName == palette.FileName));
+            browser.SortedResults.Move(indexOfImported, 0);
+        }
+        else
+        {
+            browser.SortedResults.Insert(0, palette);
+        }
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Colors.CanImportPalette")]
+    public bool CanImportPalette(List<PaletteColor> paletteColors)
+    {
+        return paletteColors is not null && Owner.DocumentIsNotNull(paletteColors) && paletteColors.Count > 0;
+    }
+
+    [Command.Internal("PixiEditor.Colors.ImportPalette", CanExecute = "PixiEditor.Colors.CanImportPalette")]
+    public void ImportPalette(List<PaletteColor> palette)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null || doc.Palette.SequenceEqual(palette))
+            return;
+
+        if (doc.Palette.Count == 0 || ConfirmationDialog.Show(new LocalizedString("REPLACE_PALETTE_CONSENT"), new LocalizedString("REPLACE_PALETTE")) == ConfirmationType.Yes)
+        {
+            doc.Palette.ReplaceRange(palette.Select(x => new PaletteColor(x.R, x.G, x.B)));
+        }
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Colors.CanSelectPaletteColor")]
+    public bool CanSelectPaletteColor(int index)
+    {
+        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        return document?.Palette is not null && document.Palette.Count > index;
+    }
+
+    [Evaluator.Icon("PixiEditor.Colors.FirstPaletteColorIcon")]
+    public IImage GetPaletteColorIcon1() => GetPaletteColorIcon(0);
+    [Evaluator.Icon("PixiEditor.Colors.SecondPaletteColorIcon")]
+    public IImage GetPaletteColorIcon2() => GetPaletteColorIcon(1);
+    [Evaluator.Icon("PixiEditor.Colors.ThirdPaletteColorIcon")]
+    public IImage GetPaletteColorIcon3() => GetPaletteColorIcon(2);
+    [Evaluator.Icon("PixiEditor.Colors.FourthPaletteColorIcon")]
+    public IImage GetPaletteColorIcon4() => GetPaletteColorIcon(3);
+    [Evaluator.Icon("PixiEditor.Colors.FifthPaletteColorIcon")]
+    public IImage GetPaletteColorIcon5() => GetPaletteColorIcon(4);
+    [Evaluator.Icon("PixiEditor.Colors.SixthPaletteColorIcon")]
+    public IImage GetPaletteColorIcon6() => GetPaletteColorIcon(5);
+    [Evaluator.Icon("PixiEditor.Colors.SeventhPaletteColorIcon")]
+    public IImage GetPaletteColorIcon7() => GetPaletteColorIcon(6);
+    [Evaluator.Icon("PixiEditor.Colors.EighthPaletteColorIcon")]
+    public IImage GetPaletteColorIcon8() => GetPaletteColorIcon(7);
+    [Evaluator.Icon("PixiEditor.Colors.NinthPaletteColorIcon")]
+    public IImage GetPaletteColorIcon9() => GetPaletteColorIcon(8);
+    [Evaluator.Icon("PixiEditor.Colors.TenthPaletteColorIcon")]
+    public IImage GetPaletteColorIcon10() => GetPaletteColorIcon(9);
+
+
+    private IImage GetPaletteColorIcon(int index)
+    {
+        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
+
+        Color color;
+        if (document?.Palette is null || document.Palette.Count <= index)
+        {
+            color = Colors.Gray;
+        }
+        else
+        {
+            PaletteColor paletteColor = document.Palette[index];
+            color = new Color(paletteColor.R, paletteColor.G, paletteColor.B);
+        }
+
+        return ColorSearchResult.GetIcon(color);
+    }
+
+    [Command.Basic("PixiEditor.Colors.SelectFirstPaletteColor", "SELECT_COLOR_1", "SELECT_COLOR_1_DESCRIPTIVE", Key = Key.D1, Parameter = 0, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.FirstPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectSecondPaletteColor", "SELECT_COLOR_2", "SELECT_COLOR_2_DESCRIPTIVE", Key = Key.D2, Parameter = 1, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.SecondPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectThirdPaletteColor", "SELECT_COLOR_3", "SELECT_COLOR_3_DESCRIPTIVE", Key = Key.D3, Parameter = 2, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.ThirdPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectFourthPaletteColor", "SELECT_COLOR_4", "SELECT_COLOR_4_DESCRIPTIVE", Key = Key.D4, Parameter = 3, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.FourthPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectFifthPaletteColor", "SELECT_COLOR_5", "SELECT_COLOR_5_DESCRIPTIVE", Key = Key.D5, Parameter = 4, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.FifthPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectSixthPaletteColor", "SELECT_COLOR_6", "SELECT_COLOR_6_DESCRIPTIVE", Key = Key.D6, Parameter = 5, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.SixthPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectSeventhPaletteColor", "SELECT_COLOR_7", "SELECT_COLOR_7_DESCRIPTIVE", Key = Key.D7, Parameter = 6, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.SeventhPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectEighthPaletteColor", "SELECT_COLOR_8", "SELECT_COLOR_8_DESCRIPTIVE", Key = Key.D8, Parameter = 7, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.EighthPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectNinthPaletteColor", "SELECT_COLOR_9", "SELECT_COLOR_9_DESCRIPTIVE", Key = Key.D9, Parameter = 8, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.NinthPaletteColorIcon")]
+    [Command.Basic("PixiEditor.Colors.SelectTenthPaletteColor", "SELECT_COLOR_10", "SELECT_COLOR_10_DESCRIPTIVE", Key = Key.D0, Parameter = 9, CanExecute = "PixiEditor.Colors.CanSelectPaletteColor", IconEvaluator = "PixiEditor.Colors.TenthPaletteColorIcon")]
+    public void SelectPaletteColor(int index)
+    {
+        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (document?.Palette is not null && document.Palette.Count > index)
+        {
+            PaletteColor paletteColor = document.Palette[index];
+            PrimaryColor = new Color(paletteColor.R, paletteColor.G, paletteColor.B);
+        }
+    }
+
+    [Command.Basic("PixiEditor.Colors.Swap", "SWAP_COLORS", "SWAP_COLORS_DESCRIPTIVE", Key = Key.X)]
+    public void SwapColors(object parameter)
+    {
+        (PrimaryColor, SecondaryColor) = (SecondaryColor, PrimaryColor);
+    }
+
+    public void AddSwatch(PaletteColor color)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        if (!doc.Swatches.Contains(color))
+        {
+            doc.Swatches.Add(color);
+        }
+    }
+
+    [Command.Internal("PixiEditor.Colors.RemoveSwatch")]
+    public void RemoveSwatch(PaletteColor color)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        if (doc.Swatches.Contains(color))
+        {
+            doc.Swatches.Remove(color);
+        }
+    }
+
+    [Command.Internal("PixiEditor.Colors.SelectColor")]
+    public void SelectColor(PaletteColor color)
+    {
+        PrimaryColor = color.ToColor();
+    }
+
+    [Command.Basic("PixIEditor.Colors.AddPrimaryToPalettes", "ADD_PRIMARY_COLOR_TO_PALETTE", "ADD_PRIMARY_COLOR_TO_PALETTE_DESCRIPTIVE", CanExecute = "PixiEditor.HasDocument", IconPath = "CopyAdd.png")]
+    public void AddPrimaryColorToPalette()
+    {
+        var palette = Owner.DocumentManagerSubViewModel.ActiveDocument.Palette;
+
+        if (!palette.Contains(PrimaryColor.ToPaletteColor()))
+        {
+            palette.Add(PrimaryColor.ToPaletteColor());
+        }
+    }
+
+    [Command.Internal("PixiEditor.CloseContextMenu")]
+    public void CloseContextMenu(ContextMenu menu)
+    {
+        menu.Close();
+    }
+
+    public void SetupPaletteProviders(IServiceProvider services)
+    {
+        PaletteProvider = (PaletteProvider)services.GetService<IPaletteProvider>();
+        PaletteProvider.AvailableParsers =
+            new ObservableCollection<PaletteFileParser>(services.GetServices<PaletteFileParser>());
+        var dataSources = services.GetServices<PaletteListDataSource>();
+
+        foreach (var dataSource in dataSources)
+        {
+            PaletteProvider.RegisterDataSource(dataSource);
+        }
+    }
+}

+ 264 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/DebugViewModel.cs

@@ -0,0 +1,264 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Hardware.Info;
+using Newtonsoft.Json;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Commands.Templates.Parsers;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.OperatingSystem;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.Debug", "DEBUG")]
+internal class DebugViewModel : SubViewModel<ViewModelMain>
+{
+    public static bool IsDebugBuild { get; set; }
+
+    public bool IsDebugModeEnabled { get; set; }
+
+    private bool useDebug;
+    public bool UseDebug
+    {
+        get => useDebug;
+        set => SetProperty(ref useDebug, value);
+    }
+
+    private LocalizationKeyShowMode localizationKeyShowMode;
+
+    public LocalizationKeyShowMode LocalizationKeyShowMode
+    {
+        get => localizationKeyShowMode;
+        set
+        {
+            if (SetProperty(ref localizationKeyShowMode, value))
+            {
+                LocalizedString.OverridenKeyFlowMode = value;
+                Owner.LocalizationProvider.ReloadLanguage();
+            }
+        }
+    }
+
+    private bool forceOtherFlowDirection;
+    
+    public bool ForceOtherFlowDirection
+    {
+        get => forceOtherFlowDirection;
+        set
+        {
+            if (SetProperty(ref forceOtherFlowDirection, value))
+            {
+                Language.FlipFlowDirection = value;
+                Owner.LocalizationProvider.ReloadLanguage();
+            }
+        }
+    }
+
+    public DebugViewModel(ViewModelMain owner, IPreferences preferences)
+        : base(owner)
+    {
+        SetDebug();
+        preferences.AddCallback<bool>("IsDebugModeEnabled", UpdateDebugMode);
+        UpdateDebugMode(preferences.GetPreference<bool>("IsDebugModeEnabled"));
+    }
+
+    public static void OpenFolder(string path)
+    {
+        if (!Directory.Exists(path))
+        {
+            NoticeDialog.Show(new LocalizedString("PATH_DOES_NOT_EXIST", path), "LOCATION_DOES_NOT_EXIST");
+            return;
+        }
+
+        IOperatingSystem.Current.OpenFolder(path);
+    }
+    
+
+    [Command.Debug("PixiEditor.Debug.OpenLocalAppDataDirectory", @"PixiEditor", "OPEN_LOCAL_APPDATA_DIR", "OPEN_LOCAL_APPDATA_DIR", IconPath = "Folder.png")]
+    [Command.Debug("PixiEditor.Debug.OpenCrashReportsDirectory", @"PixiEditor\crash_logs", "OPEN_CRASH_REPORTS_DIR", "OPEN_CRASH_REPORTS_DIR", IconPath = "Folder.png")]
+    public static void OpenLocalAppDataFolder(string subDirectory)
+    {
+        var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), subDirectory);
+        OpenFolder(path);
+    }
+
+    [Command.Debug("PixiEditor.Debug.OpenRoamingAppDataDirectory", @"PixiEditor", "OPEN_ROAMING_APPDATA_DIR", "OPEN_ROAMING_APPDATA_DIR", IconPath = "Folder.png")]
+    public static void OpenAppDataFolder(string subDirectory)
+    {
+        var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), subDirectory);
+        OpenFolder(path);
+    }
+
+    [Command.Debug("PixiEditor.Debug.OpenTempDirectory", @"PixiEditor", "OPEN_TEMP_DIR", "OPEN_TEMP_DIR", IconPath = "Folder.png")]
+    public static void OpenTempFolder(string subDirectory)
+    {
+        var path = Path.Combine(Path.GetTempPath(), subDirectory);
+        OpenFolder(path);
+    }
+
+    [Command.Debug("PixiEditor.Debug.DumpAllCommands", "DUMP_ALL_COMMANDS", "DUMP_ALL_COMMANDS_DESCRIPTIVE")]
+    public void DumpAllCommands()
+    {
+        SaveFileDialog dialog = new SaveFileDialog();
+        var dialogResult = dialog.ShowDialog();
+        if (dialogResult.HasValue && dialogResult.Value)
+        {
+            var commands = Owner.CommandController.Commands;
+
+            using StreamWriter writer = new StreamWriter(dialog.FileName);
+            foreach (var command in commands)
+            {
+                writer.WriteLine($"InternalName: {command.InternalName}");
+                writer.WriteLine($"Default Shortcut: {command.DefaultShortcut}");
+                writer.WriteLine($"IsDebug: {command.IsDebug}");
+                writer.WriteLine();
+            }
+        }
+    }
+    
+    [Command.Debug("PixiEditor.Debug.GenerateKeysTemplate", "GENERATE_KEY_BINDINGS_TEMPLATE", "GENERATE_KEY_BINDINGS_TEMPLATE_DESCRIPTIVE")]
+    public void GenerateKeysTemplate()
+    {
+        SaveFileDialog dialog = new SaveFileDialog();
+        var dialogResult = dialog.ShowDialog();
+        if (dialogResult.HasValue && dialogResult.Value)
+        {
+            var commands = Owner.CommandController.Commands;
+
+            using StreamWriter writer = new StreamWriter(dialog.FileName);
+            Dictionary<string, KeyDefinition> keyDefinitions = new Dictionary<string, KeyDefinition>();
+            foreach (var command in commands)
+            {
+                if(command.IsDebug)
+                    continue;
+                keyDefinitions.Add($"(provider).{command.InternalName}", new KeyDefinition(command.InternalName, new HumanReadableKeyCombination("None"), Array.Empty<string>()));
+            }
+
+            writer.Write(JsonConvert.SerializeObject(keyDefinitions, Formatting.Indented));
+            writer.Close();
+            string file = File.ReadAllText(dialog.FileName);
+            foreach (var command in commands)
+            {
+                if(command.IsDebug)
+                    continue;
+                file = file.Replace($"(provider).{command.InternalName}", "");
+            }
+            
+            File.WriteAllText(dialog.FileName, file);
+            IOperatingSystem.Current.OpenFolder(dialog.FileName);
+        }
+    }
+
+    [Command.Debug("PixiEditor.Debug.ValidateShortcutMap", "VALIDATE_SHORTCUT_MAP", "VALIDATE_SHORTCUT_MAP_DESCRIPTIVE")]
+    public void ValidateShortcutMap()
+    {
+        OpenFileDialog dialog = new OpenFileDialog();
+        dialog.Filter = "Json files (*.json)|*.json";
+        dialog.InitialDirectory = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Data", "ShortcutActionMaps");
+        var dialogResult = dialog.ShowDialog();
+        
+        if (dialogResult.HasValue && dialogResult.Value)
+        {
+            string file = File.ReadAllText(dialog.FileName);
+            var keyDefinitions = JsonConvert.DeserializeObject<Dictionary<string, KeyDefinition>>(file);
+            int emptyKeys = file.Split("\"\":").Length - 1;
+            int unknownCommands = 0;
+            
+            foreach (var keyDefinition in keyDefinitions)
+            {
+                if (!Owner.CommandController.Commands.ContainsKey(keyDefinition.Value.Command))
+                {
+                    unknownCommands++;
+                }
+            }
+
+            NoticeDialog.Show(new LocalizedString("VALIDATION_KEYS_NOTICE_DIALOG", emptyKeys, unknownCommands), "RESULT");
+        }
+    }
+
+    [Command.Debug("PixiEditor.Debug.ClearRecentDocument", "CLEAR_RECENT_DOCUMENTS", "CLEAR_RECENTLY_OPENED_DOCUMENTS")]
+    public void ClearRecentDocuments()
+    {
+        Owner.FileSubViewModel.RecentlyOpened.Clear();
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.RecentlyOpened, Array.Empty<object>());
+    }
+
+    [Command.Debug("PixiEditor.Debug.OpenCommandDebugWindow", "OPEN_CMD_DEBUG_WINDOW", "OPEN_CMD_DEBUG_WINDOW")]
+    public void OpenCommandDebugWindow()
+    {
+        //TODO: Fix this
+        //Mouse.OverrideCursor = Cursors.Wait;
+        new CommandDebugPopup().Show();
+        //Mouse.OverrideCursor = null;
+    }
+
+    [Command.Debug("PixiEditor.Debug.OpenLocalizationDebugWindow", "OPEN_LOCALIZATION_DEBUG_WINDOW", "OPEN_LOCALIZATION_DEBUG_WINDOW")]
+    public void OpenLocalizationDebugWindow()
+    {
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+        {
+            var window = desktop.Windows.OfType<LocalizationDebugWindow>().FirstOrDefault(new LocalizationDebugWindow());
+            window.Show();
+            window.Activate();
+        }
+
+    }
+
+    [Command.Internal("PixiEditor.Debug.SetLanguageFromFilePicker")]
+    public void SetLanguageFromFilePicker()
+    {
+        var file = new OpenFileDialog { Filter = "key-value json (*.json)|*.json" };
+
+        if (file.ShowDialog().GetValueOrDefault())
+        {
+            Owner.LocalizationProvider.LoadDebugKeys(
+                JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(file.FileName)), false);
+        }
+    }
+
+    [Command.Debug("PixiEditor.Debug.OpenInstallDirectory", "OPEN_INSTALLATION_DIR", "OPEN_INSTALLATION_DIR", IconPath = "Folder.png")]
+    public static void OpenInstallLocation()
+    {
+        IOperatingSystem.Current.OpenFolder(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));
+    }
+
+    [Command.Debug("PixiEditor.Debug.Crash", "CRASH", "CRASH_APP")]
+    public static void Crash() => throw new InvalidOperationException("User requested to crash :c");
+
+    [Command.Debug("PixiEditor.Debug.DeleteUserPreferences", @"%appdata%\PixiEditor\user_preferences.json", "DELETE_USR_PREFS", "DELETE_USR_PREFS")]
+    [Command.Debug("PixiEditor.Debug.DeleteShortcutFile", @"%appdata%\PixiEditor\shortcuts.json", "DELETE_SHORTCUT_FILE", "DELETE_SHORTCUT_FILE")]
+    [Command.Debug("PixiEditor.Debug.DeleteEditorData", @"%localappdata%\PixiEditor\editor_data.json", "DELETE_EDITOR_DATA", "DELETE_EDITOR_DATA")]
+    public static void DeleteFile(string path)
+    {
+        string file = Environment.ExpandEnvironmentVariables(path);
+        if (!File.Exists(file))
+        {
+            NoticeDialog.Show(new LocalizedString("File {0} does not exist\n(Full Path: {1})", path, file), "FILE_NOT_FOUND");
+            return;
+        }
+
+        OptionsDialog<string> dialog = new("ARE_YOU_SURE", new LocalizedString("ARE_YOU_SURE_PATH_FULL_PATH", path, file))
+        {
+            { "Yes", x => File.Delete(file) },
+            "Cancel"
+        };
+
+        dialog.ShowDialog();
+    }
+
+    [Conditional("DEBUG")]
+    private static void SetDebug() => IsDebugBuild = true;
+
+    private void UpdateDebugMode(bool setting)
+    {
+        IsDebugModeEnabled = setting;
+        UseDebug = IsDebugBuild || IsDebugModeEnabled;
+    }
+}

+ 231 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/DiscordViewModel.cs

@@ -0,0 +1,231 @@
+using System.ComponentModel;
+using DiscordRPC;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.Events;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+internal class DiscordViewModel : SubViewModel<ViewModelMain>, IDisposable
+{
+    private DiscordRpcClient client;
+    private string clientId;
+    private DocumentViewModel currentDocument;
+
+    public bool Enabled
+    {
+        get => client != null;
+        set
+        {
+            if (Enabled != value)
+            {
+                if (value)
+                {
+                    Start();
+                }
+                else
+                {
+                    Stop();
+                }
+            }
+        }
+    }
+
+    private bool showDocumentName = IPreferences.Current.GetPreference(nameof(ShowDocumentName), false);
+
+    public bool ShowDocumentName
+    {
+        get => showDocumentName;
+        set
+        {
+            if (showDocumentName != value)
+            {
+                showDocumentName = value;
+                UpdatePresence(currentDocument);
+            }
+        }
+    }
+
+    private bool showDocumentSize = IPreferences.Current.GetPreference(nameof(ShowDocumentSize), true);
+
+    public bool ShowDocumentSize
+    {
+        get => showDocumentSize;
+        set
+        {
+            if (showDocumentSize != value)
+            {
+                showDocumentSize = value;
+                UpdatePresence(currentDocument);
+            }
+        }
+    }
+
+    private bool showLayerCount = IPreferences.Current.GetPreference(nameof(ShowLayerCount), true);
+
+    public bool ShowLayerCount
+    {
+        get => showLayerCount;
+        set
+        {
+            if (showLayerCount != value)
+            {
+                showLayerCount = value;
+                UpdatePresence(currentDocument);
+            }
+        }
+    }
+
+    public DiscordViewModel(ViewModelMain owner, string clientId)
+        : base(owner)
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocumentChanged += DocumentChanged;
+        this.clientId = clientId;
+
+        Enabled = IPreferences.Current.GetPreference("EnableRichPresence", true);
+        IPreferences.Current.AddCallback("EnableRichPresence", x => Enabled = (bool)x);
+        IPreferences.Current.AddCallback(nameof(ShowDocumentName), x => ShowDocumentName = (bool)x);
+        IPreferences.Current.AddCallback(nameof(ShowDocumentSize), x => ShowDocumentSize = (bool)x);
+        IPreferences.Current.AddCallback(nameof(ShowLayerCount), x => ShowLayerCount = (bool)x);
+        AppDomain.CurrentDomain.ProcessExit += (_, _) => Enabled = false;
+    }
+
+    public void Start()
+    {
+        client = new DiscordRpcClient(clientId);
+        client.OnReady += OnReady;
+        client.Initialize();
+    }
+
+    public void Stop()
+    {
+        client.ClearPresence();
+        client.Dispose();
+        client = null;
+    }
+
+    public void UpdatePresence(DocumentViewModel? document)
+    {
+        if (client == null)
+        {
+            return;
+        }
+
+        RichPresence richPresence = NewDefaultRP();
+
+        if (document != null)
+        {
+            richPresence.WithTimestamps(new Timestamps(document.OpenedUTC));
+
+            richPresence.Details = ShowDocumentName
+                ? $"Editing {document.FileName.Limit(128)}" : "Editing an image";
+
+            string state = string.Empty;
+
+            if (ShowDocumentSize)
+            {
+                state = $"{document.Width}x{document.Height}";
+            }
+
+            if (ShowDocumentSize && ShowLayerCount)
+            {
+                state += ", ";
+            }
+
+            if (ShowLayerCount)
+            {
+                int count = CountLayers(document.StructureRoot);
+                state += count == 1 ? "1 layer" : $"{count} layers";
+            }
+
+            richPresence.State = state;
+        }
+
+        client.SetPresence(richPresence);
+    }
+
+    private int CountLayers(FolderViewModel folder)
+    {
+        int counter = 0;
+        foreach (var child in folder.Children)
+        {
+            if (child is LayerViewModel)
+                counter++;
+            else if (child is FolderViewModel innerFolder)
+                counter += CountLayers(innerFolder);
+        }
+        return counter;
+    }
+
+    public void Dispose()
+    {
+        Enabled = false;
+        GC.SuppressFinalize(this);
+    }
+
+    private static RichPresence NewDefaultRP()
+    {
+        return new RichPresence
+        {
+            Details = "Staring at absolutely",
+            State = "nothing",
+
+            Assets = new Assets
+            {
+                LargeImageKey = "editorlogo",
+                LargeImageText = "You've discovered PixiEditor's logo",
+                SmallImageKey = "github",
+                SmallImageText = "Download PixiEditor (pixieditor.net/download)!"
+            },
+            Timestamps = new Timestamps()
+            {
+                Start = DateTime.UtcNow
+            }
+        };
+    }
+    
+    private void DocumentChanged(object sender, DocumentChangedEventArgs e)
+    {
+        if (currentDocument != null)
+        {
+            currentDocument.PropertyChanged -= DocumentPropertyChanged;
+            currentDocument.LayersChanged -= DocumentLayerChanged;
+        }
+
+        currentDocument = e.NewDocument;
+
+        if (currentDocument != null)
+        {
+            UpdatePresence(currentDocument);
+            currentDocument.PropertyChanged += DocumentPropertyChanged;
+            currentDocument.LayersChanged += DocumentLayerChanged;
+        }
+    }
+
+    private void DocumentLayerChanged(object sender, LayersChangedEventArgs e)
+    {
+        UpdatePresence(currentDocument);
+    }
+
+    private void DocumentPropertyChanged(object sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName == nameof(currentDocument.FileName)
+            || e.PropertyName == nameof(currentDocument.Width)
+            || e.PropertyName == nameof(currentDocument.Height))
+        {
+            UpdatePresence(currentDocument);
+        }
+    }
+
+    private void OnReady(object sender, DiscordRPC.Message.ReadyMessage args)
+    {
+        UpdatePresence(Owner.DocumentManagerSubViewModel.ActiveDocument);
+    }
+
+    ~DiscordViewModel()
+    {
+        Enabled = false;
+    }
+}

+ 414 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/FileViewModel.cs

@@ -0,0 +1,414 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using ChunkyImageLib;
+using Newtonsoft.Json.Linq;
+using PixiEditor.Avalonia.Exceptions.Exceptions;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Parser;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.File", "FILE")]
+internal class FileViewModel : SubViewModel<ViewModelMain>
+{
+    private bool hasRecent;
+
+    public bool HasRecent
+    {
+        get => hasRecent;
+        set
+        {
+            hasRecent = value;
+            OnPropertyChanged(nameof(HasRecent));
+        }
+    }
+
+    public RecentlyOpenedCollection RecentlyOpened { get; init; }
+
+    public FileViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+        Owner.OnStartupEvent += Owner_OnStartupEvent;
+        RecentlyOpened = new RecentlyOpenedCollection(GetRecentlyOpenedDocuments());
+
+        if (RecentlyOpened.Count > 0)
+        {
+            HasRecent = true;
+        }
+
+        IPreferences.Current.AddCallback(PreferencesConstants.MaxOpenedRecently, UpdateMaxRecentlyOpened);
+    }
+
+    public void AddRecentlyOpened(string path)
+    {
+        if (RecentlyOpened.Contains(path))
+        {
+            RecentlyOpened.Move(RecentlyOpened.IndexOf(path), 0);
+        }
+        else
+        {
+            RecentlyOpened.Insert(0, path);
+        }
+
+        int maxCount = IPreferences.Current.GetPreference(PreferencesConstants.MaxOpenedRecently, PreferencesConstants.MaxOpenedRecentlyDefault);
+
+        while (RecentlyOpened.Count > maxCount)
+        {
+            RecentlyOpened.RemoveAt(RecentlyOpened.Count - 1);
+        }
+
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.RecentlyOpened, RecentlyOpened.Select(x => x.FilePath));
+    }
+
+    [Command.Internal("PixiEditor.File.RemoveRecent")]
+    public void RemoveRecentlyOpened(string path)
+    {
+        if (!RecentlyOpened.Contains(path))
+        {
+            return;
+        }
+
+        RecentlyOpened.Remove(path);
+        IPreferences.Current.UpdateLocalPreference(PreferencesConstants.RecentlyOpened, RecentlyOpened.Select(x => x.FilePath));
+    }
+
+    private void OpenHelloTherePopup()
+    {
+        new HelloTherePopup(this).Show();
+    }
+
+    private void Owner_OnStartupEvent(object sender, System.EventArgs e)
+    {
+        List<string> args = StartupArgs.Args;
+        string file = args.FirstOrDefault(x => Importer.IsSupportedFile(x) && File.Exists(x));
+        if (file != null)
+        {
+            OpenFromPath(file);
+        }
+        else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0
+                  || !args.Contains("--crash")) && !args.Contains("--openedInExisting"))
+        {
+            if (IPreferences.Current.GetPreference("ShowStartupWindow", true))
+            {
+                OpenHelloTherePopup();
+            }
+        }
+    }
+
+    [Command.Internal("PixiEditor.File.OpenRecent")]
+    public void OpenRecent(object parameter)
+    {
+        string path = (string)parameter;
+        if (!File.Exists(path))
+        {
+            NoticeDialog.Show("FILE_NOT_FOUND", "FAILED_TO_OPEN_FILE");
+            RecentlyOpened.Remove(path);
+            IPreferences.Current.UpdateLocalPreference(PreferencesConstants.RecentlyOpened, RecentlyOpened.Select(x => x.FilePath));
+            return;
+        }
+
+        OpenFromPath(path);
+    }
+
+    [Command.Basic("PixiEditor.File.Open", "OPEN", "OPEN_FILE", Key = Key.O, Modifiers = KeyModifiers.Control)]
+    public void OpenFromOpenFileDialog()
+    {
+        string filter = SupportedFilesHelper.BuildOpenFilter();
+
+        OpenFileDialog dialog = new OpenFileDialog
+        {
+            Filter = filter,
+            FilterIndex = 0
+        };
+
+        if (!(bool)dialog.ShowDialog() || !Importer.IsSupportedFile(dialog.FileName))
+            return;
+
+        OpenFromPath(dialog.FileName);
+    }
+
+    [Command.Basic("PixiEditor.File.OpenFileFromClipboard", "OPEN_FILE_FROM_CLIPBOARD", "OPEN_FILE_FROM_CLIPBOARD_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.HasImageInClipboard")]
+    public void OpenFromClipboard()
+    {
+        var images = ClipboardController.GetImagesFromClipboard();
+
+        foreach (var dataImage in images)
+        {
+            if (File.Exists(dataImage.name))
+            {
+                OpenRegularImage(dataImage.image, null);
+                continue;
+            }
+
+            OpenFromPath(dataImage.name, false);
+        }
+    }
+
+    private bool MakeExistingDocumentActiveIfOpened(string path)
+    {
+        foreach (DocumentViewModel document in Owner.DocumentManagerSubViewModel.Documents)
+        {
+            if (document.FullFilePath is not null && System.IO.Path.GetFullPath(document.FullFilePath) == System.IO.Path.GetFullPath(path))
+            {
+                Owner.WindowSubViewModel.MakeDocumentViewportActive(document);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /// <summary>
+    /// Tries to open the passed file if it isn't already open
+    /// </summary>
+    public void OpenFromPath(string path, bool associatePath = true)
+    {
+        if (MakeExistingDocumentActiveIfOpened(path))
+            return;
+
+        try
+        {
+            if (path.EndsWith(".pixi"))
+            {
+                OpenDotPixi(path, associatePath);
+            }
+            else
+            {
+                OpenRegularImage(path, associatePath);
+            }
+        }
+        catch (RecoverableException ex)
+        {
+            NoticeDialog.Show(ex.DisplayMessage, "ERROR");
+        }
+        catch (OldFileFormatException)
+        {
+            NoticeDialog.Show("OLD_FILE_FORMAT_DESCRIPTION", "OLD_FILE_FORMAT");
+        }
+    }
+
+    /// <summary>
+    /// Opens a .pixi file from path, creates a document from it, and adds it to the system
+    /// </summary>
+    private void OpenDotPixi(string path, bool associatePath = true)
+    {
+        DocumentViewModel document = Importer.ImportDocument(path, associatePath);
+        AddDocumentViewModelToTheSystem(document);
+        AddRecentlyOpened(document.FullFilePath);
+    }
+
+    /// <summary>
+    /// Opens a .pixi file from path, creates a document from it, and adds it to the system
+    /// </summary>
+    public void OpenRecoveredDotPixi(string? originalPath, byte[] dotPixiBytes)
+    {
+        DocumentViewModel document = Importer.ImportDocument(dotPixiBytes, originalPath);
+        document.MarkAsUnsaved();
+        AddDocumentViewModelToTheSystem(document);
+    }
+
+    /// <summary>
+    /// Opens a regular image file from path, creates a document from it, and adds it to the system.
+    /// </summary>
+    private void OpenRegularImage(string path, bool associatePath)
+    {
+        var image = Importer.ImportImage(path, VecI.NegativeOne);
+
+        if (image == null) return;
+
+        var doc = NewDocument(b => b
+            .WithSize(image.Size)
+            .WithLayer(l => l
+                .WithName("Image")
+                .WithSize(image.Size)
+                .WithSurface(image)));
+
+        if (associatePath)
+        {
+            doc.FullFilePath = path;
+        }
+
+        AddRecentlyOpened(path);
+    }
+
+    /// <summary>
+    /// Opens a regular image file from path, creates a document from it, and adds it to the system.
+    /// </summary>
+    private void OpenRegularImage(Surface surface, string path)
+    {
+        DocumentViewModel doc = NewDocument(b => b
+            .WithSize(surface.Size)
+            .WithLayer(l => l
+                .WithName("Image")
+                .WithSize(surface.Size)
+                .WithSurface(surface)));
+
+        if (path == null)
+        {
+            return;
+        }
+
+        doc.FullFilePath = path;
+        AddRecentlyOpened(path);
+    }
+
+    [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N, Modifiers = ModifierKeys.Control)]
+    public void CreateFromNewFileDialog()
+    {
+        NewFileDialog newFile = new NewFileDialog();
+        if (newFile.ShowDialog())
+        {
+            NewDocument(b => b
+                .WithSize(newFile.Width, newFile.Height)
+                .WithLayer(l => l
+                    .WithName(new LocalizedString("BASE_LAYER_NAME"))
+                    .WithSurface(new Surface(new VecI(newFile.Width, newFile.Height)))));
+        }
+    }
+
+    private DocumentViewModel NewDocument(Action<DocumentViewModelBuilder> builder)
+    {
+        var doc = DocumentViewModel.Build(builder);
+        AddDocumentViewModelToTheSystem(doc);
+        return doc;
+    }
+
+    private void AddDocumentViewModelToTheSystem(DocumentViewModel doc)
+    {
+        Owner.DocumentManagerSubViewModel.Documents.Add(doc);
+        Owner.WindowSubViewModel.CreateNewViewport(doc);
+        Owner.WindowSubViewModel.MakeDocumentViewportActive(doc);
+    }
+
+    [Command.Basic("PixiEditor.File.Save", false, "SAVE", "SAVE_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = ModifierKeys.Control, IconPath = "Save.png")]
+    [Command.Basic("PixiEditor.File.SaveAsNew", true, "SAVE_AS", "SAVE_IMAGE_AS", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = ModifierKeys.Control | ModifierKeys.Shift, IconPath = "Save.png")]
+    public bool SaveActiveDocument(bool asNew)
+    {
+        DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return false;
+        return SaveDocument(doc, asNew);
+    }
+
+    public bool SaveDocument(DocumentViewModel document, bool asNew)
+    {
+        string finalPath = null;
+        if (asNew || string.IsNullOrEmpty(document.FullFilePath))
+        {
+            var result = Exporter.TrySaveWithDialog(document, out string path);
+            if (result == DialogSaveResult.Cancelled)
+                return false;
+            if (result != DialogSaveResult.Success)
+            {
+                ShowSaveError(result);
+                return false;
+            }
+            finalPath = path;
+            AddRecentlyOpened(path);
+        }
+        else
+        {
+            var result = Exporter.TrySave(document, document.FullFilePath);
+            if (result != SaveResult.Success)
+            {
+                ShowSaveError((DialogSaveResult)result);
+                return false;
+            }
+            finalPath = document.FullFilePath;
+        }
+
+        document.FullFilePath = finalPath;
+        document.MarkAsSaved();
+        return true;
+    }
+
+    /// <summary>
+    ///     Generates export dialog or saves directly if save data is known.
+    /// </summary>
+    /// <param name="parameter">CommandProperty.</param>
+    [Command.Basic("PixiEditor.File.Export", "EXPORT", "EXPORT_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.E, Modifiers = ModifierKeys.Control)]
+    public void ExportFile()
+    {
+        DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        ExportFileDialog info = new ExportFileDialog(doc.SizeBindable);
+        if (info.ShowDialog())
+        {
+            SaveResult result = Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat, out string finalPath, new(info.FileWidth, info.FileHeight));
+            if (result == SaveResult.Success)
+                ProcessHelper.OpenInExplorer(finalPath);
+            else
+                ShowSaveError((DialogSaveResult)result);
+        }
+    }
+
+    private void ShowSaveError(DialogSaveResult result)
+    {
+        switch (result)
+        {
+            case DialogSaveResult.InvalidPath:
+                NoticeDialog.Show("ERROR", "ERROR_SAVE_LOCATION");
+                break;
+            case DialogSaveResult.ConcurrencyError:
+                NoticeDialog.Show("INTERNAL_ERROR", "ERROR_WHILE_SAVING");
+                break;
+            case DialogSaveResult.SecurityError:
+                NoticeDialog.Show(title: "SECURITY_ERROR", message: "SECURITY_ERROR_MSG");
+                break;
+            case DialogSaveResult.IoError:
+                NoticeDialog.Show(title: "IO_ERROR", message: "IO_ERROR_MSG");
+                break;
+            case DialogSaveResult.UnknownError:
+                NoticeDialog.Show("ERROR", "UNKNOWN_ERROR_SAVING");
+                break;
+        }
+    }
+
+    private void UpdateMaxRecentlyOpened(object parameter)
+    {
+        int newAmount = (int)parameter;
+
+        if (newAmount >= RecentlyOpened.Count)
+        {
+            return;
+        }
+
+        List<RecentlyOpenedDocument> recentlyOpenedDocuments = new List<RecentlyOpenedDocument>(RecentlyOpened.Take(newAmount));
+
+        RecentlyOpened.Clear();
+
+        foreach (RecentlyOpenedDocument recent in recentlyOpenedDocuments)
+        {
+            RecentlyOpened.Add(recent);
+        }
+    }
+
+    private List<RecentlyOpenedDocument> GetRecentlyOpenedDocuments()
+    {
+        IEnumerable<string> paths = IPreferences.Current.GetLocalPreference(nameof(RecentlyOpened), new JArray()).ToObject<string[]>()
+            .Take(IPreferences.Current.GetPreference(PreferencesConstants.MaxOpenedRecently, 8));
+
+        List<RecentlyOpenedDocument> documents = new List<RecentlyOpenedDocument>();
+
+        foreach (string path in paths)
+        {
+            if (!File.Exists(path))
+                continue;
+            documents.Add(new RecentlyOpenedDocument(path));
+        }
+
+        return documents;
+    }
+}

+ 301 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/IoViewModel.cs

@@ -0,0 +1,301 @@
+using System.Linq;
+using System.Windows;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using CommunityToolkit.Mvvm.Input;
+using Hardware.Info;
+using PixiEditor.Avalonia.Views;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Commands;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Events;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.Views;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+#nullable enable
+internal class IoViewModel : SubViewModel<ViewModelMain>
+{
+    private bool hadSwapped;
+    private int? previousEraseSize;
+    private bool hadSharedToolbar;
+    private bool? drawingWithRight;
+    private bool startedWithEraser;
+
+    public RelayCommand MouseMoveCommand { get; set; }
+    public RelayCommand MouseDownCommand { get; set; }
+    public RelayCommand PreviewMouseMiddleButtonCommand { get; set; }
+    public RelayCommand MouseUpCommand { get; set; }
+
+    private MouseInputFilter mouseFilter = new();
+    private KeyboardInputFilter keyboardFilter = new();
+
+    public IoViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+        MouseDownCommand = new RelayCommand(mouseFilter.MouseDownInlet);
+        MouseMoveCommand = new RelayCommand(mouseFilter.MouseMoveInlet);
+        MouseUpCommand = new RelayCommand(mouseFilter.MouseUpInlet);
+        PreviewMouseMiddleButtonCommand = new RelayCommand(OnPreviewMiddleMouseButton);
+        GlobalMouseHook.Instance.OnMouseUp += mouseFilter.MouseUpInlet;
+
+        InputManager.Current.PreProcessInput += Current_PreProcessInput;
+
+        mouseFilter.OnMouseDown += OnMouseDown;
+        mouseFilter.OnMouseMove += OnMouseMove;
+        mouseFilter.OnMouseUp += OnMouseUp;
+
+        keyboardFilter.OnAnyKeyDown += OnKeyDown;
+        keyboardFilter.OnAnyKeyUp += OnKeyUp;
+
+        keyboardFilter.OnConvertedKeyDown += OnConvertedKeyDown;
+        keyboardFilter.OnConvertedKeyUp += OnConvertedKeyDown;
+
+        Application.Current.Deactivated += keyboardFilter.DeactivatedInlet;
+        Application.Current.Deactivated += mouseFilter.DeactivatedInlet;
+    }
+
+    private void OnConvertedKeyDown(object? sender, FilteredKeyEventArgs args)
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnConvertedKeyDown(args);
+        Owner.ToolsSubViewModel.ConvertedKeyDownInlet(args);
+    }
+
+    private void OnConvertedKeyUp(object? sender, FilteredKeyEventArgs args)
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnConvertedKeyUp(args);
+        Owner.ToolsSubViewModel.ConvertedKeyUpInlet(args);
+    }
+
+    private void Current_PreProcessInput(object sender, PreProcessInputEventArgs e)
+    {
+        if (e is { StagingItem: { Input: { } } })
+        {
+            InputEventArgs inputEvent = e.StagingItem.Input;
+
+            if (inputEvent is KeyboardEventArgs)
+            {
+                KeyboardEventArgs k = (KeyboardEventArgs)inputEvent;
+                RoutedEvent r = k.RoutedEvent;
+                KeyEventArgs? keyEvent = k as KeyEventArgs;
+
+                if (keyEvent is null && keyEvent?.InputSource?.RootVisual != MainWindow.Current)
+                    return;
+                if (r == Keyboard.KeyDownEvent)
+                {
+                    keyboardFilter.KeyDownInlet(keyEvent);
+                }
+
+                if (r == Keyboard.KeyUpEvent)
+                {
+                    keyboardFilter.KeyUpInlet(keyEvent);
+                }
+            }
+        }
+    }
+
+    private void OnKeyDown(object? sender, FilteredKeyEventArgs args)
+    {
+        ProcessShortcutDown(args.IsRepeat, args.Key);
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnKeyDown(args.Key);
+    }
+
+    private void HandleTransientKey(Key transientKey)
+    {
+        if (ShortcutController.ShortcutExecutionBlocked)
+        {
+            return;
+        }
+
+        var tool = GetTransientTool(transientKey);
+
+        if (tool is not null)
+        {
+            Owner.ToolsSubViewModel.SetActiveTool(tool.ToolType, true);
+        }
+    }
+
+    private static Command.ToolCommand? GetTransientTool(Key transientKey)
+    {
+        Command.ToolCommand? tool = CommandController.Current.Commands
+            .OfType<Command.ToolCommand?>()
+            .FirstOrDefault(x => x != null && x.TransientKey == transientKey);
+        return tool;
+    }
+
+    private void ProcessShortcutDown(bool isRepeat, Key key)
+    {
+        HandleTransientKey(key);
+        if (isRepeat && Owner.ShortcutController.LastCommands != null &&
+            Owner.ShortcutController.LastCommands.Any(x => x is Command.ToolCommand))
+        {
+            Owner.ToolsSubViewModel.HandleToolRepeatShortcutDown();
+        }
+
+        Owner.ShortcutController.KeyPressed(key, Keyboard.Modifiers);
+    }
+
+    private void OnKeyUp(object? sender, FilteredKeyEventArgs args)
+    {
+        ProcessShortcutUp(new(args.Key, args.Modifiers));
+
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnKeyUp(args.Key);
+    }
+
+    private void ProcessShortcutUp(KeyCombination shortcut)
+    {
+        var transientTool = GetTransientTool(shortcut.Key);
+
+        if (Owner.ShortcutController.LastCommands != null &&
+            Owner.ShortcutController.LastCommands.Any(x => x.Shortcut == shortcut) || transientTool is not null)
+        {
+            Owner.ToolsSubViewModel.HandleToolShortcutUp();
+        }
+    }
+
+    private void OnMouseDown(object? sender, MouseOnCanvasEventArgs args)
+    {
+        if (drawingWithRight != null || args.Button is not (MouseButton.Left or MouseButton.Right))
+            return;
+
+        if (args.Button == MouseButton.Right && !HandleRightMouseDown())
+            return;
+
+        var docManager = Owner.DocumentManagerSubViewModel;
+        var activeDocument = docManager.ActiveDocument;
+        if (activeDocument == null)
+            return;
+
+        drawingWithRight = args.Button == MouseButton.Right;
+        Owner.ToolsSubViewModel.UseToolEventInlet(args.PositionOnCanvas, args.Button);
+        activeDocument.EventInlet.OnCanvasLeftMouseButtonDown(args.PositionOnCanvas);
+    }
+
+    private bool HandleRightMouseDown()
+    {
+        var tools = Owner.ToolsSubViewModel;
+
+        startedWithEraser = tools.ActiveTool is EraserToolViewModel;
+
+        switch (tools.RightClickMode)
+        {
+            case RightClickMode.SecondaryColor when tools.ActiveTool.UsesColor:
+            case RightClickMode.Erase when tools.ActiveTool is ColorPickerToolViewModel:
+                Owner.ColorsSubViewModel.SwapColors(null);
+                hadSwapped = true;
+                return true;
+            case RightClickMode.Erase when tools.ActiveTool.IsErasable:
+            {
+                HandleRightMouseEraseDown(tools);
+                return true;
+            }
+            case RightClickMode.SecondaryColor when tools.ActiveTool is BrightnessToolViewModel:
+                return true;
+            case RightClickMode.ContextMenu:
+            default:
+                return false;
+        }
+    }
+
+    private void HandleRightMouseEraseDown(ToolsViewModel tools)
+    {
+        var currentToolSize = tools.ActiveTool.Toolbar.Settings.FirstOrDefault(x => x.Name == "ToolSize");
+        hadSharedToolbar = tools.EnableSharedToolbar;
+        if (currentToolSize != null)
+        {
+            tools.EnableSharedToolbar = false;
+            var toolSize = tools.GetTool<EraserToolViewModel>().Toolbar.Settings.First(x => x.Name == "ToolSize");
+            previousEraseSize = (int)toolSize.Value;
+            toolSize.Value = tools.ActiveTool is PenToolViewModel { PixelPerfectEnabled: true } ? 1 : currentToolSize.Value;
+        }
+        else
+        {
+            previousEraseSize = null;
+        }
+
+        tools.SetActiveTool<EraserToolViewModel>(true);
+    }
+    
+    private void OnPreviewMiddleMouseButton(object sender)
+    {
+        Owner.ToolsSubViewModel.SetActiveTool<MoveViewportToolViewModel>(true);
+    }
+
+    private void OnMouseMove(object? sender, VecD pos)
+    {
+        DocumentViewModel? activeDocument = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (activeDocument is null)
+            return;
+        activeDocument.EventInlet.OnCanvasMouseMove(pos);
+    }
+
+    private void OnMouseUp(object? sender, MouseButton button)
+    {
+        bool toLeftRightClick = drawingWithRight == null ||
+                                (button == MouseButton.Left && drawingWithRight.Value) ||
+                                (button == MouseButton.Right && !drawingWithRight.Value);
+        
+        if (toLeftRightClick && button != MouseButton.Middle)
+            return;
+
+        if (Owner.DocumentManagerSubViewModel.ActiveDocument is null)
+            return;
+        var tools = Owner.ToolsSubViewModel;
+
+        var rightCanUp = (button == MouseButton.Right && tools.RightClickMode == RightClickMode.Erase || tools.RightClickMode == RightClickMode.SecondaryColor);
+        
+        if (button == MouseButton.Left || rightCanUp)
+        {
+            Owner.DocumentManagerSubViewModel.ActiveDocument.EventInlet.OnCanvasLeftMouseButtonUp();
+        }
+        
+        drawingWithRight = null;
+
+        HandleRightMouseUp(button, tools);
+        
+        hadSwapped = false;
+    }
+
+    private void HandleRightMouseUp(MouseButton button, ToolsViewModel tools)
+    {
+        switch (button)
+        {
+            case MouseButton.Middle:
+                tools.RestorePreviousTool();
+                break;
+            case MouseButton.Right when hadSwapped && 
+                                        (tools.RightClickMode == RightClickMode.SecondaryColor || 
+                                         tools is { ActiveTool: ColorPickerToolViewModel, RightClickMode: RightClickMode.Erase }
+                                        ):
+
+                Owner.ColorsSubViewModel.SwapColors(null);
+                break;
+            case MouseButton.Right when tools.RightClickMode == RightClickMode.Erase:
+                HandleRightMouseEraseUp(tools);
+                break;
+        }
+    }
+
+    private void HandleRightMouseEraseUp(ToolsViewModel tools)
+    {
+        if (startedWithEraser)
+        {
+            return;
+        }
+
+        tools.EnableSharedToolbar = hadSharedToolbar;
+        if (previousEraseSize != null)
+        {
+            tools.GetTool<EraserToolViewModel>().Toolbar.Settings.First(x => x.Name == "ToolSize").Value = previousEraseSize.Value;
+        }
+        tools.RestorePreviousTool();
+    }
+}

+ 424 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/LayersViewModel.cs

@@ -0,0 +1,424 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media.Imaging;
+using PixiEditor.Avalonia.Exceptions.Exceptions;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.IO;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+#nullable enable
+[Command.Group("PixiEditor.Layer", "LAYER")]
+internal class LayersViewModel : SubViewModel<ViewModelMain>
+{
+    public LayersViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+    }
+
+    public void CreateFolderFromActiveLayers()
+    {
+
+    }
+
+    public bool CanCreateFolderFromSelected()
+    {
+        return false;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Layer.CanDeleteSelected")]
+    public bool CanDeleteSelected()
+    {
+        var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;
+        if (member is null)
+            return false;
+        return true;
+    }
+
+    [Command.Basic("PixiEditor.Layer.DeleteSelected", "LAYER_DELETE_SELECTED", "LAYER_DELETE_SELECTED_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.CanDeleteSelected", IconPath = "Trash.png")]
+    public void DeleteSelected()
+    {
+        var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;
+        if (member is null)
+            return;
+
+        member.Document.Operations.DeleteStructureMember(member.GuidValue);
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Layer.HasSelectedMembers")]
+    public bool HasSelectedMembers()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return false;
+        return doc.SelectedStructureMember is not null || doc.SoftSelectedStructureMembers.Count > 0;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Layer.HasMultipleSelectedMembers")]
+    public bool HasMultipleSelectedMembers()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return false;
+        int count = doc.SoftSelectedStructureMembers.Count;
+        if (doc.SelectedStructureMember is not null)
+            count++;
+        return count > 1;
+    }
+
+    private List<Guid> GetSelected()
+    {
+        List<Guid> members = new();
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return members;
+        if (doc.SelectedStructureMember is not null)
+            members.Add(doc.SelectedStructureMember.GuidValue);
+        members.AddRange(doc.SoftSelectedStructureMembers.Select(static member => member.GuidValue));
+        return members;
+    }
+
+    [Command.Basic("PixiEditor.Layer.DeleteAllSelected", "LAYER_DELETE_ALL_SELECTED", "LAYER_DELETE_ALL_SELECTED_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasSelectedMembers", IconPath = "Trash.png")]
+    public void DeleteAllSelected()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        var selected = GetSelected();
+        if (selected.Count > 0)
+            doc.Operations.DeleteStructureMembers(selected);
+    }
+
+    [Command.Basic("PixiEditor.Layer.NewFolder", "NEW_FOLDER", "CREATE_NEW_FOLDER", CanExecute = "PixiEditor.Layer.CanCreateNewMember", IconPath = "Folder-add.png")]
+    public void NewFolder()
+    {
+        if (Owner.DocumentManagerSubViewModel.ActiveDocument is not { } doc)
+            return;
+        doc.Operations.CreateStructureMember(StructureMemberType.Folder);
+    }
+
+    [Command.Basic("PixiEditor.Layer.NewLayer", "NEW_LAYER", "CREATE_NEW_LAYER", CanExecute = "PixiEditor.Layer.CanCreateNewMember", Key = Key.N, Modifiers = KeyModifiers.Control | KeyModifiers.Shift, IconPath = "Layer-add.png")]
+    public void NewLayer()
+    {
+        if (Owner.DocumentManagerSubViewModel.ActiveDocument is not { } doc)
+            return;
+        doc.Operations.CreateStructureMember(StructureMemberType.Layer);
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Layer.CanCreateNewMember")]
+    public bool CanCreateNewMember()
+    {
+        return Owner.DocumentManagerSubViewModel.ActiveDocument is { } doc && !doc.UpdateableChangeActive;
+    }
+
+    [Command.Internal("PixiEditor.Layer.ToggleLockTransparency", CanExecute = "PixiEditor.Layer.SelectedMemberIsLayer")]
+    public void ToggleLockTransparency()
+    {
+        var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;
+        if (member is not LayerViewModel layerVm)
+            return;
+        layerVm.LockTransparencyBindable = !layerVm.LockTransparencyBindable;
+    }
+
+    [Command.Internal("PixiEditor.Layer.OpacitySliderDragStarted")]
+    public void OpacitySliderDragStarted()
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseOpacitySlider();
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnOpacitySliderDragStarted();
+    }
+
+    [Command.Internal("PixiEditor.Layer.OpacitySliderDragged")]
+    public void OpacitySliderDragged(double value)
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnOpacitySliderDragged((float)value);
+    }
+
+    [Command.Internal("PixiEditor.Layer.OpacitySliderDragEnded")]
+    public void OpacitySliderDragEnded()
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnOpacitySliderDragEnded();
+    }
+
+    [Command.Internal("PixiEditor.Layer.OpacitySliderSet")]
+    public void OpacitySliderSet(double value)
+    {
+        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
+
+        if (document?.SelectedStructureMember != null)
+        {
+            document.Operations.SetMemberOpacity(document.SelectedStructureMember.GuidValue, (float)value);
+        }
+    }
+
+    [Command.Basic("PixiEditor.Layer.DuplicateSelectedLayer", "DUPLICATE_SELECTED_LAYER", "DUPLICATE_SELECTED_LAYER", CanExecute = "PixiEditor.Layer.SelectedMemberIsLayer")]
+    public void DuplicateLayer()
+    {
+        var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;
+        if (member is not LayerViewModel layerVM)
+            return;
+        member.Document.Operations.DuplicateLayer(member.GuidValue);
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Layer.SelectedMemberIsLayer")]
+    public bool SelectedMemberIsLayer(object property)
+    {
+        var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;
+        return member is LayerViewModel;
+    }
+
+    private bool HasSelectedMember(bool above)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = doc?.SelectedStructureMember;
+        if (member is null)
+            return false;
+        var path = doc!.StructureHelper.FindPath(member.GuidValue);
+        if (path.Count < 2)
+            return false;
+        var parent = (FolderViewModel)path[1];
+        int index = parent.Children.IndexOf(path[0]);
+        if (above && index == parent.Children.Count - 1)
+            return false;
+        if (!above && index == 0)
+            return false;
+        return true;
+    }
+
+    private void MoveSelectedMember(bool upwards)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember;
+        if (member is null)
+            return;
+        var path = doc!.StructureHelper.FindPath(member.GuidValue);
+        if (path.Count < 2)
+            return;
+        var parent = (FolderViewModel)path[1];
+        int curIndex = parent.Children.IndexOf(path[0]);
+        if (upwards)
+        {
+            if (curIndex == parent.Children.Count - 1)
+                return;
+            doc.Operations.MoveStructureMember(member.GuidValue, parent.Children[curIndex + 1].GuidValue, StructureMemberPlacement.Above);
+        }
+        else
+        {
+            if (curIndex == 0)
+                return;
+            doc.Operations.MoveStructureMember(member.GuidValue, parent.Children[curIndex - 1].GuidValue, StructureMemberPlacement.Below);
+        }
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Layer.ActiveLayerHasMask")]
+    public bool ActiveMemberHasMask() => Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember?.HasMaskBindable ?? false;
+
+    [Evaluator.CanExecute("PixiEditor.Layer.ActiveLayerHasNoMask")]
+    public bool ActiveLayerHasNoMask() => !Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember?.HasMaskBindable ?? false;
+
+    [Command.Basic("PixiEditor.Layer.CreateMask", "CREATE_MASK", "CREATE_MASK", CanExecute = "PixiEditor.Layer.ActiveLayerHasNoMask", IconPath = "Create-mask.png")]
+    public void CreateMask()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = doc?.SelectedStructureMember;
+        if (member is null || member.HasMaskBindable)
+            return;
+        doc!.Operations.CreateMask(member);
+    }
+
+    [Command.Basic("PixiEditor.Layer.DeleteMask", "DELETE_MASK", "DELETE_MASK", CanExecute = "PixiEditor.Layer.ActiveLayerHasMask", IconPath = "Trash.png")]
+    public void DeleteMask()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = doc?.SelectedStructureMember;
+        if (member is null || !member.HasMaskBindable)
+            return;
+        doc!.Operations.DeleteMask(member);
+    }
+
+    [Command.Basic("PixiEditor.Layer.ToggleMask", "TOGGLE_MASK", "TOGGLE_MASK", CanExecute = "PixiEditor.Layer.ActiveLayerHasMask")]
+    public void ToggleMask()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = doc?.SelectedStructureMember;
+        if (member is null || !member.HasMaskBindable)
+            return;
+        
+        member.MaskIsVisibleBindable = !member.MaskIsVisibleBindable;
+    }
+    
+    [Command.Basic("PixiEditor.Layer.ApplyMask", "APPLY_MASK", "APPLY_MASK", CanExecute = "PixiEditor.Layer.ActiveLayerHasMask")]
+    public void ApplyMask()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = doc?.SelectedStructureMember;
+        if (member is null || !member.HasMaskBindable)
+            return;
+        
+        doc!.Operations.ApplyMask(member);
+    }
+
+    [Command.Basic("PixiEditor.Layer.ToggleVisible", "TOGGLE_VISIBILITY", "TOGGLE_VISIBILITY", CanExecute = "PixiEditor.HasDocument")]
+    public void ToggleVisible()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = doc?.SelectedStructureMember;
+        if (member is null)
+            return;
+        
+        member.IsVisibleBindable = !member.IsVisibleBindable;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Layer.HasMemberAbove")]
+    public bool HasMemberAbove(object property) => HasSelectedMember(true);
+    [Evaluator.CanExecute("PixiEditor.Layer.HasMemberBelow")]
+    public bool HasMemberBelow(object property) => HasSelectedMember(false);
+
+    [Command.Basic("PixiEditor.Layer.MoveSelectedMemberUpwards", "MOVE_MEMBER_UP", "MOVE_MEMBER_UP_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasMemberAbove")]
+    public void MoveSelectedMemberUpwards() => MoveSelectedMember(true);
+    [Command.Basic("PixiEditor.Layer.MoveSelectedMemberDownwards", "MOVE_MEMBER_DOWN", "MOVE_MEMBER_DOWN_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasMemberBelow")]
+    public void MoveSelectedMemberDownwards() => MoveSelectedMember(false);
+
+    [Command.Basic("PixiEditor.Layer.MergeSelected", "MERGE_ALL_SELECTED_LAYERS", "MERGE_ALL_SELECTED_LAYERS", CanExecute = "PixiEditor.Layer.HasMultipleSelectedMembers")]
+    public void MergeSelected()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        var selected = GetSelected();
+        if (selected.Count == 0)
+            return;
+        doc.Operations.MergeStructureMembers(selected);
+    }
+
+    public void MergeSelectedWith(bool above)
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        var member = doc?.SelectedStructureMember;
+        if (doc is null || member is null)
+            return;
+        var (child, parent) = doc.StructureHelper.FindChildAndParent(member.GuidValue);
+        if (child is null || parent is null)
+            return;
+        int index = parent.Children.IndexOf(child);
+        if (!above && index == 0)
+            return;
+        if (above && index == parent.Children.Count - 1)
+            return;
+        doc.Operations.MergeStructureMembers(new List<Guid> { member.GuidValue, above ? parent.Children[index + 1].GuidValue : parent.Children[index - 1].GuidValue });
+    }
+
+    [Command.Basic("PixiEditor.Layer.MergeWithAbove", "MERGE_WITH_ABOVE", "MERGE_WITH_ABOVE_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasMemberAbove")]
+    public void MergeWithAbove() => MergeSelectedWith(true);
+
+    [Command.Basic("PixiEditor.Layer.MergeWithBelow", "MERGE_WITH_BELOW", "MERGE_WITH_BELOW_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.HasMemberBelow", IconPath = "Merge-downwards.png")]
+    public void MergeWithBelow() => MergeSelectedWith(false);
+
+    [Evaluator.CanExecute("PixiEditor.Layer.ReferenceLayerExists")]
+    public bool ReferenceLayerExists() => Owner.DocumentManagerSubViewModel.ActiveDocument?.ReferenceLayerViewModel.ReferenceBitmap is not null;
+    [Evaluator.CanExecute("PixiEditor.Layer.ReferenceLayerDoesntExist")]
+    public bool ReferenceLayerDoesntExist() => 
+        Owner.DocumentManagerSubViewModel.ActiveDocument is not null && Owner.DocumentManagerSubViewModel.ActiveDocument.ReferenceLayerViewModel.ReferenceBitmap is null;
+
+    [Command.Basic("PixiEditor.Layer.ImportReferenceLayer", "ADD_REFERENCE_LAYER", "ADD_REFERENCE_LAYER", CanExecute = "PixiEditor.Layer.ReferenceLayerDoesntExist", IconPath = "Add-reference.png")]
+    public void ImportReferenceLayer()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        string path = OpenReferenceLayerFilePicker();
+        if (path is null)
+            return;
+
+        WriteableBitmap bitmap;
+        try
+        {
+            bitmap = Importer.ImportWriteableBitmap(path);
+        }
+        catch (RecoverableException e)
+        {
+            NoticeDialog.Show(title: "ERROR", message: e.DisplayMessage);
+            return;
+        }
+
+        byte[] pixels = new byte[bitmap.PixelWidth * bitmap.PixelHeight * 4];
+        bitmap.CopyPixels(pixels, bitmap.PixelWidth * 4, 0);
+
+        VecI size = new VecI(bitmap.PixelWidth, bitmap.PixelHeight);
+
+        doc.Operations.ImportReferenceLayer(
+            pixels.ToImmutableArray(), 
+            size);
+    }
+
+    private string OpenReferenceLayerFilePicker()
+    {
+        var imagesFilter = new FileTypeDialogDataSet(FileTypeDialogDataSet.SetKind.Images).GetFormattedTypes();
+        OpenFileDialog dialog = new OpenFileDialog
+        {
+            Title = new LocalizedString("REFERENCE_LAYER_PATH"),
+            CheckPathExists = true,
+            Filter = imagesFilter
+        };
+
+        return (bool)dialog.ShowDialog() ? dialog.FileName : null;
+    }
+
+    [Command.Basic("PixiEditor.Layer.DeleteReferenceLayer", "DELETE_REFERENCE_LAYER", "DELETE_REFERENCE_LAYER", CanExecute = "PixiEditor.Layer.ReferenceLayerExists", IconPath = "Trash.png")]
+    public void DeleteReferenceLayer()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        doc.Operations.DeleteReferenceLayer();
+    }
+
+    [Command.Basic("PixiEditor.Layer.TransformReferenceLayer", "TRANSFORM_REFERENCE_LAYER", "TRANSFORM_REFERENCE_LAYER", CanExecute = "PixiEditor.Layer.ReferenceLayerExists", IconPath = "crop.png")]
+    public void TransformReferenceLayer()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        doc.Operations.TransformReferenceLayer();
+    }
+
+    [Command.Basic("PixiEditor.Layer.ToggleReferenceLayerTopMost", "TOGGLE_REFERENCE_LAYER_POS", "TOGGLE_REFERENCE_LAYER_POS_DESCRIPTIVE", CanExecute = "PixiEditor.Layer.ReferenceLayerExists", IconEvaluator = "PixiEditor.Layer.ToggleReferenceLayerTopMostIcon")]
+    public void ToggleReferenceLayerTopMost()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        doc.ReferenceLayerViewModel.IsTopMost = !doc.ReferenceLayerViewModel.IsTopMost;
+    }
+
+    [Command.Basic("PixiEditor.Layer.ResetReferenceLayerPosition", "RESET_REFERENCE_LAYER_POS", "RESET_REFERENCE_LAYER_POS", CanExecute = "PixiEditor.Layer.ReferenceLayerExists", IconPath = "Layout.png")]
+    public void ResetReferenceLayerPosition()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+
+        doc.Operations.ResetReferenceLayerPosition();
+    }
+
+    [Evaluator.Icon("PixiEditor.Layer.ToggleReferenceLayerTopMostIcon")]
+    public ImageSource GetAboveEverythingReferenceLayerIcon()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null || doc.ReferenceLayerViewModel.IsTopMost)
+            return new BitmapImage(new Uri("pack://application:,,,/Images/ReferenceLayerBelow.png"));
+
+        return new BitmapImage(new Uri("pack://application:,,,/Images/ReferenceLayerAbove.png"));
+    }
+}

+ 37 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/MiscViewModel.cs

@@ -0,0 +1,37 @@
+using System.Threading.Tasks;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.OperatingSystem;
+using PixiEditor.Views.Dialogs;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.Links", "MISC")]
+internal class MiscViewModel : SubViewModel<ViewModelMain>
+{
+    public MiscViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+    }
+
+    [Command.Internal("PixiEditor.Links.OpenHyperlink")]
+    [Command.Basic("PixiEditor.Links.OpenDocumentation", "https://pixieditor.net/docs/introduction", "DOCUMENTATION", "OPEN_DOCUMENTATION", IconPath = "Globe.png")]
+    [Command.Basic("PixiEditor.Links.OpenWebsite", "https://pixieditor.net", "WEBSITE", "OPEN_WEBSITE", IconPath = "Globe.png")]
+    [Command.Basic("PixiEditor.Links.OpenRepository", "https://github.com/PixiEditor/PixiEditor", "REPOSITORY", "OPEN_REPOSITORY", IconPath = "Globe.png")]
+    [Command.Basic("PixiEditor.Links.OpenLicense", "https://github.com/PixiEditor/PixiEditor/blob/master/LICENSE", "LICENSE", "OPEN_LICENSE", IconPath = "Globe.png")]
+    [Command.Basic("PixiEditor.Links.OpenOtherLicenses", "https://pixieditor.net/docs/Third-party-licenses", "THIRD_PARTY_LICENSES", "OPEN_THIRD_PARTY_LICENSES", IconPath = "Globe.png")]
+    public static async Task OpenHyperlink(string url)
+    {
+        try
+        {
+            IOperatingSystem.Current.OpenHyperlink(url);
+        }
+        catch (Exception e)
+        {
+            NoticeDialog.Show(title: "Error", message: $"Couldn't open the address {url} in your default browser");
+            await CrashHelper.SendExceptionInfoToWebhook(e);
+        }
+    }
+}

+ 89 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/SelectionViewModel.cs

@@ -0,0 +1,89 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Commands.Attributes.Commands;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.Selection", "SELECTION")]
+internal class SelectionViewModel : SubViewModel<ViewModelMain>
+{
+    public SelectionViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+    }
+
+    [Command.Basic("PixiEditor.Selection.SelectAll", "SELECT_ALL", "SELECT_ALL_DESCRIPTIVE", CanExecute = "PixiEditor.HasDocument", Key = Key.A, Modifiers = KeyModifiers.Control)]
+    public void SelectAll()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        doc.Operations.SelectAll();
+    }
+
+    [Command.Basic("PixiEditor.Selection.Clear", "CLEAR_SELECTION", "CLEAR_SELECTION", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.D, Modifiers = KeyModifiers.Control)]
+    public void ClearSelection()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        doc.Operations.ClearSelection();
+    }
+
+    [Command.Basic("PixiEditor.Selection.InvertSelection", "INVERT_SELECTION", "INVERT_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.I, Modifiers = KeyModifiers.Control)]
+    public void InvertSelection()
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.InvertSelection();
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Selection.IsNotEmpty")]
+    public bool SelectionIsNotEmpty()
+    {
+        return !Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectionPathBindable?.IsEmpty ?? false;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Selection.IsNotEmptyAndHasMask")]
+    public bool SelectionIsNotEmptyAndHasMask()
+    {
+        return SelectionIsNotEmpty() && (Owner.DocumentManagerSubViewModel.ActiveDocument?.SelectedStructureMember?.HasMaskBindable ?? false);
+    }
+
+    [Command.Basic("PixiEditor.Selection.TransformArea", "TRANSFORM_SELECTED_AREA", "TRANSFORM_SELECTED_AREA", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.T, Modifiers = KeyModifiers.Control)]
+    public void TransformSelectedArea()
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.TransformSelectedArea(false);
+    }
+
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectLeft", "NUDGE_SELECTED_LEFT", "NUDGE_SELECTED_LEFT", Key = Key.Left, Parameter = new int[] { -1, 0 }, IconPath = "E76B", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectRight", "NUDGE_SELECTED_RIGHT", "NUDGE_SELECTED_RIGHT", Key = Key.Right, Parameter = new int[] { 1, 0 }, IconPath = "E76C", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectUp", "NUDGE_SELECTED_UP", "NUDGE_SELECTED_UP", Key = Key.Up, Parameter = new int[] { 0, -1 }, IconPath = "E70E", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    [Command.Basic("PixiEditor.Selection.NudgeSelectedObjectDown", "NUDGE_SELECTED_DOWN", "NUDGE_SELECTED_DOWN", Key = Key.Down, Parameter = new int[] { 0, 1 }, IconPath = "E70D", IconEvaluator = "PixiEditor.FontIcon", CanExecute = "PixiEditor.Selection.CanNudgeSelectedObject")]
+    public void NudgeSelectedObject(int[] dist)
+    {
+        VecI distance = new(dist[0], dist[1]);
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.NudgeSelectedObject(distance);
+    }
+
+    [Command.Basic("PixiEditor.Selection.NewToMask", SelectionMode.New, "MASK_FROM_SELECTION", "MASK_FROM_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty")]
+    [Command.Basic("PixiEditor.Selection.AddToMask", SelectionMode.Add, "ADD_SELECTION_TO_MASK", "ADD_SELECTION_TO_MASK", CanExecute = "PixiEditor.Selection.IsNotEmpty")]
+    [Command.Basic("PixiEditor.Selection.SubtractFromMask", SelectionMode.Subtract, "SUBTRACT_SELECTION_FROM_MASK", "SUBTRACT_SELECTION_FROM_MASK", CanExecute = "PixiEditor.Selection.IsNotEmptyAndHasMask")]
+    [Command.Basic("PixiEditor.Selection.IntersectSelectionMask", SelectionMode.Intersect, "INTERSECT_SELECTION_MASK", "INTERSECT_SELECTION_MASK", CanExecute = "PixiEditor.Selection.IsNotEmptyAndHasMask")]
+    [Command.Filter("PixiEditor.Selection.ToMaskMenu", "SELECTION_TO_MASK", "SELECTION_TO_MASK", Key = Key.M, Modifiers = KeyModifiers.Control)]
+    public void SelectionToMask(SelectionMode mode)
+    {
+        Owner.DocumentManagerSubViewModel.ActiveDocument?.Operations.SelectionToMask(mode);
+    }
+
+    [Command.Basic("PixiEditor.Selection.CropToSelection", "CROP_TO_SELECTION", "CROP_TO_SELECTION_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty")]
+    public void CropToSelection()
+    {
+        var document = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        
+        document!.Operations.CropToSelection();
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Selection.CanNudgeSelectedObject")]
+    public bool CanNudgeSelectedObject(int[] dist) => Owner.DocumentManagerSubViewModel.ActiveDocument?.UpdateableChangeActive == true;
+}

+ 266 - 7
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ToolsViewModel.cs

@@ -1,21 +1,280 @@
-using PixiEditor.Avalonia.ViewModels;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Input;
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Containers;
 using PixiEditor.Models.Containers;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Events;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
 
 
-namespace PixiEditor.ViewModels.SubViewModels;
-
-internal class ToolsViewModel : SubViewModel<MainViewModel>, IToolsHandler
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+#nullable enable
+[Command.Group("PixiEditor.Tools", "TOOLS")]
+internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
 {
 {
-    public ToolsViewModel(MainViewModel owner) : base(owner)
+    private RightClickMode rightClickMode;
+    public ZoomToolViewModel? ZoomTool => GetTool<ZoomToolViewModel>();
+
+    public ToolViewModel? LastActionTool { get; private set; }
+
+    public RightClickMode RightClickMode
+    {
+        get => rightClickMode;
+        set
+        {
+            if (SetProperty(ref rightClickMode, value))
+            {
+                IPreferences.Current.UpdatePreference(nameof(RightClickMode), value);
+            }
+        }
+    }
+
+    public bool EnableSharedToolbar
+    {
+        get => IPreferences.Current.GetPreference<bool>(nameof(EnableSharedToolbar));
+        set
+        {
+            if (EnableSharedToolbar == value)
+            {
+                return;
+            }
+
+            IPreferences.Current.UpdatePreference(nameof(EnableSharedToolbar), value);
+            OnPropertyChanged(nameof(EnableSharedToolbar));
+        }
+    }
+
+    private Cursor? toolCursor;
+    public Cursor? ToolCursor
+    {
+        get => toolCursor;
+        set => SetProperty(ref toolCursor, value);
+    }
+
+    public BasicToolbar? ActiveBasicToolbar
+    {
+        get => ActiveTool?.Toolbar as BasicToolbar;
+    }
+
+    private ToolViewModel? activeTool;
+    public ToolViewModel? ActiveTool
+    {
+        get => activeTool;
+        private set
+        {
+            SetProperty(ref activeTool, value);
+            OnPropertyChanged(nameof(ActiveBasicToolbar));
+        }
+    }
+
+    public List<ToolViewModel>? ToolSet { get; private set; }
+
+    public event EventHandler<SelectedToolEventArgs>? SelectedToolChanged;
+
+    private bool shiftIsDown;
+    private bool ctrlIsDown;
+    private bool altIsDown;
+
+    private ToolViewModel _preTransientTool;
+
+
+    public ToolsViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+        rightClickMode = IPreferences.Current.GetPreference<RightClickMode>(nameof(RightClickMode));
+    }
+
+    public void SetupTools(IServiceProvider services)
+    {
+        ToolSet = services.GetServices<ToolViewModel>().ToList();
+    }
+
+    public void SetupToolsTooltipShortcuts(IServiceProvider services)
     {
     {
+        foreach (ToolViewModel tool in ToolSet!)
+        {
+            tool.Shortcut = Owner.ShortcutController.GetToolShortcut(tool.GetType());
+        }
+    }
+
+    public T? GetTool<T>()
+        where T : ToolViewModel
+    {
+        return (T?)ToolSet?.Where(static tool => tool is T).FirstOrDefault();
+    }
+
+    public void SetActiveTool<T>(bool transient)
+        where T : ToolViewModel
+    {
+        SetActiveTool(typeof(T), transient);
+    }
+
+    [Command.Basic("PixiEditor.Tools.ApplyTransform", "APPLY_TRANSFORM", "", Key = Key.Enter)]
+    public void ApplyTransform()
+    {
+        DocumentViewModel? doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        doc.EventInlet.OnApplyTransform();
+    }
+
+    [Command.Internal("PixiEditor.Tools.SelectTool", CanExecute = "PixiEditor.HasDocument")]
+    public void SetActiveTool(ToolViewModel tool)
+    {
+        SetActiveTool(tool, false);
+    }
+
+    public void SetActiveTool(ToolViewModel tool, bool transient)
+    {
+        if (ActiveTool == tool)
+        {
+            ActiveTool.IsTransient = transient;
+            return;
+        }
+
+        ActiveTool?.OnDeselecting();
+
+        if (!tool.Toolbar.SettingsGenerated)
+            tool.Toolbar.GenerateSettings();
+
+        if (ActiveTool != null) ActiveTool.IsTransient = false;
+        bool shareToolbar = EnableSharedToolbar;
+        if (ActiveTool is not null)
+        {
+            ActiveTool.IsActive = false;
+            if (shareToolbar)
+                ActiveTool.Toolbar.SaveToolbarSettings();
+        }
+
+        LastActionTool = ActiveTool;
+        ActiveTool = tool;
+
+        if (shareToolbar)
+        {
+            ActiveTool.Toolbar.LoadSharedSettings();
+        }
+
+        if (LastActionTool != ActiveTool)
+            SelectedToolChanged?.Invoke(this, new SelectedToolEventArgs(LastActionTool, ActiveTool));
+
+        //update old tool
+        LastActionTool?.ModifierKeyChanged(false, false, false);
+        //update new tool
+        ActiveTool.ModifierKeyChanged(ctrlIsDown, shiftIsDown, altIsDown);
+        ActiveTool.OnSelected();
+
+        tool.IsActive = true;
+        ActiveTool.IsTransient = transient;
+        SetToolCursor(tool.GetType());
+
+        if (Owner.StylusSubViewModel != null)
+        {
+            Owner.StylusSubViewModel.ToolSetByStylus = false;
+        }
     }
     }
 
 
     public void SetTool(object parameter)
     public void SetTool(object parameter)
     {
     {
-        throw new NotImplementedException();
+        if (parameter is Type type)
+        {
+            SetActiveTool(type, false);
+            return;
+        }
+
+        ToolViewModel tool = (ToolViewModel)parameter;
+        SetActiveTool(tool.GetType(), false);
+    }
+
+    [Command.Basic("PixiEditor.Tools.IncreaseSize", 1, "INCREASE_TOOL_SIZE", "INCREASE_TOOL_SIZE", CanExecute = "PixiEditor.Tools.CanChangeToolSize", Key = Key.OemCloseBrackets)]
+    [Command.Basic("PixiEditor.Tools.DecreaseSize", -1, "DECREASE_TOOL_SIZE", "DECREASE_TOOL_SIZE", CanExecute = "PixiEditor.Tools.CanChangeToolSize", Key = Key.OemOpenBrackets)]
+    public void ChangeToolSize(int increment)
+    {
+        if (ActiveTool?.Toolbar is not BasicToolbar toolbar)
+            return;
+        int newSize = toolbar.ToolSize + increment;
+        if (newSize > 0)
+            toolbar.ToolSize = newSize;
     }
     }
 
 
+    [Evaluator.CanExecute("PixiEditor.Tools.CanChangeToolSize")]
+    public bool CanChangeToolSize() => Owner.ToolsSubViewModel.ActiveTool?.Toolbar is BasicToolbar
+                                       && Owner.ToolsSubViewModel.ActiveTool is not PenToolViewModel
+                                       {
+                                           PixelPerfectEnabled: true
+                                       };
+
+    public void SetActiveTool(Type toolType, bool transient)
+    {
+        if (!typeof(ToolViewModel).IsAssignableFrom(toolType))
+            throw new ArgumentException($"'{toolType}' does not inherit from {typeof(ToolViewModel)}");
+        ToolViewModel foundTool = ToolSet!.First(x => x.GetType() == toolType);
+        SetActiveTool(foundTool, transient);
+    }
+    
     public void RestorePreviousTool()
     public void RestorePreviousTool()
     {
     {
-        throw new NotImplementedException();
+        if (LastActionTool != null)
+        {
+            SetActiveTool(LastActionTool, false);
+        }
+        else
+        {
+            SetActiveTool<PenToolViewModel>(false);
+        }
+    }
+
+    private void SetToolCursor(Type tool)
+    {
+        if (tool is not null)
+        {
+            ToolCursor = ActiveTool?.Cursor;
+        }
+        else
+        {
+            ToolCursor = new Cursor(StandardCursorType.Arrow);
+        }
+    }
+
+    public void HandleToolRepeatShortcutDown()
+    {
+        if(ActiveTool == null) return;
+        if (ActiveTool is null or { IsTransient: false })
+        {
+            ShortcutController.BlockShortcutExecution("ShortcutDown");
+            ActiveTool.IsTransient = true;
+        }
+    }
+    
+    public void HandleToolShortcutUp()
+    {
+        if(ActiveTool == null) return;
+        if (ActiveTool.IsTransient && LastActionTool is { } tool)
+            SetActiveTool(tool, false);
+        ShortcutController.UnblockShortcutExecution("ShortcutDown");
+    }
+
+    public void UseToolEventInlet(VecD canvasPos, MouseButton button)
+    {
+        if (ActiveTool == null) return;
+
+        ActiveTool.UsedWith = button;
+        ActiveTool.UseTool(canvasPos);
+    }
+
+    public void ConvertedKeyDownInlet(FilteredKeyEventArgs args)
+    {
+        ActiveTool?.ModifierKeyChanged(args.IsCtrlDown, args.IsShiftDown, args.IsAltDown);
+    }
+
+    public void ConvertedKeyUpInlet(FilteredKeyEventArgs args)
+    {
+        ActiveTool?.ModifierKeyChanged(args.IsCtrlDown, args.IsShiftDown, args.IsAltDown);
     }
     }
 }
 }

+ 70 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/UndoViewModel.cs

@@ -0,0 +1,70 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Commands.Attributes.Evaluators;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.Undo", "UNDO")]
+internal class UndoViewModel : SubViewModel<ViewModelMain>
+{
+    public UndoViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+    }
+
+    /// <summary>
+    ///     Redo last action.
+    /// </summary>
+    [Command.Basic("PixiEditor.Undo.Redo", "REDO", "REDO_DESCRIPTIVE", CanExecute = "PixiEditor.Undo.CanRedo", Key = Key.Y, Modifiers = KeyModifiers.Control,
+        IconPath = "E7A6", IconEvaluator = "PixiEditor.FontIcon")]
+    public void Redo()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null || (!doc.UpdateableChangeActive && !doc.HasSavedRedo))
+            return;
+        doc.Operations.Redo();
+    }
+
+    /// <summary>
+    ///     Undo last action.
+    /// </summary>
+    [Command.Basic("PixiEditor.Undo.Undo", "UNDO", "UNDO_DESCRIPTIVE", CanExecute = "PixiEditor.Undo.CanUndo", Key = Key.Z, Modifiers = KeyModifiers.Control,
+        IconPath = "E7A7", IconEvaluator = "PixiEditor.FontIcon")]
+    public void Undo()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null || (!doc.UpdateableChangeActive && !doc.HasSavedUndo))
+            return;
+        doc.Operations.Undo();
+    }
+
+    /// <summary>
+    ///     Returns true if undo can be done.
+    /// </summary>
+    /// <param name="property">CommandParameter.</param>
+    /// <returns>True if can undo.</returns>
+    [Evaluator.CanExecute("PixiEditor.Undo.CanUndo")]
+    public bool CanUndo()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return false;
+        return doc.UpdateableChangeActive || doc.HasSavedUndo;
+    }
+
+    /// <summary>
+    ///     Returns true if redo can be done.
+    /// </summary>
+    /// <param name="property">CommandProperty.</param>
+    /// <returns>True if can redo.</returns>
+    [Evaluator.CanExecute("PixiEditor.Undo.CanRedo")]
+    public bool CanRedo()
+    {
+        var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return false;
+        return doc.UpdateableChangeActive || doc.HasSavedRedo;
+    }
+}

+ 260 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/UpdateViewModel.cs

@@ -0,0 +1,260 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.Localization;
+using PixiEditor.OperatingSystem;
+using PixiEditor.Platform;
+using PixiEditor.UpdateModule;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+internal class UpdateViewModel : SubViewModel<ViewModelMain>
+{
+    private bool updateReadyToInstall = false;
+
+    public UpdateChecker UpdateChecker { get; set; }
+
+    public List<UpdateChannel> UpdateChannels { get; } = new List<UpdateChannel>();
+
+    private string versionText;
+
+    public string VersionText
+    {
+        get => versionText;
+        set
+        {
+            versionText = value;
+            OnPropertyChanged(nameof(VersionText));
+        }
+    }
+
+    public bool UpdateReadyToInstall
+    {
+        get => updateReadyToInstall;
+        set
+        {
+            updateReadyToInstall = value;
+            OnPropertyChanged(nameof(UpdateReadyToInstall));
+            if (value)
+            {
+                VersionText = new LocalizedString("TO_INSTALL_UPDATE", UpdateChecker.LatestReleaseInfo.TagName); // Button shows "Restart" before this text
+            }
+        }
+    }
+
+    public UpdateViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+        Owner.OnStartupEvent += Owner_OnStartupEvent;
+        IPreferences.Current.AddCallback<string>("UpdateChannel", val =>
+        {
+            string prevChannel = UpdateChecker.Channel.ApiUrl;
+            UpdateChecker.Channel = GetUpdateChannel(val);
+            if (prevChannel != UpdateChecker.Channel.ApiUrl)
+            {
+                ConditionalUPDATE();
+            }
+        });
+        InitUpdateChecker();
+    }
+
+    public async Task<bool> CheckForUpdate()
+    {
+        bool updateAvailable = await UpdateChecker.CheckUpdateAvailable();
+        bool updateCompatible = await UpdateChecker.IsUpdateCompatible();
+        bool updateFileDoesNotExists = !File.Exists(
+            Path.Join(UpdateDownloader.DownloadLocation, $"update-{UpdateChecker.LatestReleaseInfo.TagName}.zip"));
+        bool updateExeDoesNotExists = !File.Exists(
+            Path.Join(UpdateDownloader.DownloadLocation, $"update-{UpdateChecker.LatestReleaseInfo.TagName}.exe"));
+        if (updateAvailable && updateFileDoesNotExists && updateExeDoesNotExists)
+        {
+            UpdateReadyToInstall = false;
+            VersionText = new LocalizedString("DOWNLOADING_UPDATE");
+            try
+            {
+                if (updateCompatible)
+                {
+                    await UpdateDownloader.DownloadReleaseZip(UpdateChecker.LatestReleaseInfo);
+                }
+                else
+                {
+                    await UpdateDownloader.DownloadInstaller(UpdateChecker.LatestReleaseInfo);
+                }
+
+                UpdateReadyToInstall = true;
+            }
+            catch (IOException ex)
+            {
+                NoticeDialog.Show("FAILED_DOWNLOADING_TITLE", "FAILED_DOWNLOADING");
+                return false;
+            }
+            catch(TaskCanceledException ex)
+            {
+                return false;
+            }
+
+            return true;
+        }
+
+        return false;
+    }
+
+    private void AskToInstall()
+    {
+#if RELEASE || DEVRELEASE
+            if (IPreferences.Current.GetPreference("CheckUpdatesOnStartup", true))
+            {
+                string dir = AppDomain.CurrentDomain.BaseDirectory;
+                
+                UpdateDownloader.CreateTempDirectory();
+                if(UpdateChecker.LatestReleaseInfo == null || string.IsNullOrEmpty(UpdateChecker.LatestReleaseInfo.TagName)) return;
+                bool updateFileExists = File.Exists(
+                    Path.Join(UpdateDownloader.DownloadLocation, $"update-{UpdateChecker.LatestReleaseInfo.TagName}.zip"));
+                string exePath = Path.Join(UpdateDownloader.DownloadLocation,
+                    $"update-{UpdateChecker.LatestReleaseInfo.TagName}.exe");
+
+                bool updateExeExists = File.Exists(exePath);
+
+                if (updateExeExists && !UpdateChecker.VersionDifferent(UpdateChecker.LatestReleaseInfo.TagName, UpdateChecker.CurrentVersionTag))
+                {
+                    File.Delete(exePath);
+                    updateExeExists = false;
+                }
+
+                string updaterPath = Path.Join(dir, "PixiEditor.UpdateInstaller.exe");
+
+                if (updateFileExists || updateExeExists)
+                {
+                    ViewModelMain.Current.UpdateSubViewModel.UpdateReadyToInstall = true;
+                    var result = ConfirmationDialog.Show("UPDATE_READY", "NEW_UPDATE");
+                    if (result == Models.Enums.ConfirmationType.Yes)
+                    {
+                        if (updateFileExists && File.Exists(updaterPath))
+                        {
+                            InstallHeadless(updaterPath);
+                        }
+                        else if (updateExeExists)
+                        {
+                            OpenExeInstaller(exePath);
+                        }
+                    }
+                }
+            }
+#endif
+    }
+
+    private static void InstallHeadless(string updaterPath)
+    {
+        try
+        {
+            ProcessHelper.RunAsAdmin(updaterPath);
+            Shutdown();
+        }
+        catch (Win32Exception)
+        {
+            NoticeDialog.Show(
+                "COULD_NOT_UPDATE_WITHOUT_ADMIN",
+                "INSUFFICIENT_PERMISSIONS");
+        }
+    }
+
+    private static void OpenExeInstaller(string updateExeFile)
+    {
+        bool alreadyUpdated = VersionHelpers.GetCurrentAssemblyVersion().ToString() ==
+                              updateExeFile.Split('-')[1].Split(".exe")[0];
+
+        if (!alreadyUpdated)
+        {
+            RestartToUpdate(updateExeFile);
+        }
+        else
+        {
+            File.Delete(updateExeFile);
+        }
+    }
+
+    private static void RestartToUpdate(string updateExeFile)
+    {
+        Process.Start(updateExeFile);
+        Shutdown();
+    }
+
+    private static void Shutdown()
+    {
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+            desktop.Shutdown();
+    }
+
+    [Command.Internal("PixiEditor.Restart")]
+    public static void RestartApplication()
+    {
+        try
+        {
+            ProcessHelper.RunAsAdmin(Path.Join(AppDomain.CurrentDomain.BaseDirectory, "PixiEditor.UpdateInstaller.exe"));
+            Shutdown();
+        }
+        catch (Win32Exception)
+        {
+            NoticeDialog.Show("COULD_NOT_UPDATE_WITHOUT_ADMIN", "INSUFFICIENT_PERMISSIONS");
+        }
+    }
+
+    private void Owner_OnStartupEvent(object sender, EventArgs e)
+    {
+        ConditionalUPDATE();
+    }
+
+    [Conditional("UPDATE")]
+    private async void ConditionalUPDATE()
+    {
+        if (IPreferences.Current.GetPreference("CheckUpdatesOnStartup", true))
+        {
+            try
+            {
+                await CheckForUpdate();
+            }
+            catch (System.Net.Http.HttpRequestException)
+            {
+                NoticeDialog.Show("COULD_NOT_CHECK_FOR_UPDATES", "UPDATE_CHECK_FAILED");
+            }
+
+            AskToInstall();
+        }
+    }
+
+    private void InitUpdateChecker()
+    {
+#if UPDATE
+        UpdateChannels.Add(new UpdateChannel("Release", "PixiEditor", "PixiEditor"));
+        UpdateChannels.Add(new UpdateChannel("Development", "PixiEditor", "PixiEditor-development-channel"));
+#else
+        string platformName = IPlatform.Current.Name;
+        UpdateChannels.Add(new UpdateChannel(platformName, "", ""));
+#endif
+
+        string updateChannel = IPreferences.Current.GetPreference<string>("UpdateChannel");
+
+        string version = VersionHelpers.GetCurrentAssemblyVersionString();
+        UpdateChecker = new UpdateChecker(version, GetUpdateChannel(updateChannel));
+        VersionText = new LocalizedString("VERSION", version);
+    }
+
+    private UpdateChannel GetUpdateChannel(string channelName)
+    {
+        UpdateChannel selectedChannel = UpdateChannels.FirstOrDefault(x => x.Name == channelName, UpdateChannels[0]);
+        return selectedChannel;
+    }
+}

+ 37 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ViewOptionsViewModel.cs

@@ -0,0 +1,37 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.Commands.Attributes.Commands;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+#nullable enable
+internal class ViewOptionsViewModel : SubViewModel<ViewModelMain>
+{
+    private bool gridLinesEnabled;
+
+    public bool GridLinesEnabled
+    {
+        get => gridLinesEnabled;
+        set => SetProperty(ref gridLinesEnabled, value);
+    }
+
+    public ViewOptionsViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+    }
+
+    [Command.Basic("PixiEditor.View.ToggleGrid", "TOGGLE_GRIDLINES", "TOGGLE_GRIDLINES", Key = Key.OemTilde, Modifiers = KeyModifiers.Control)]
+    public void ToggleGridLines()
+    {
+        GridLinesEnabled = !GridLinesEnabled;
+    }
+
+    [Command.Basic("PixiEditor.View.ZoomIn", 1, "ZOOM_IN", "ZOOM_IN", CanExecute = "PixiEditor.HasDocument", Key = Key.OemPlus)]
+    [Command.Basic("PixiEditor.View.Zoomout", -1, "ZOOM_OUT", "ZOOM_OUT", CanExecute = "PixiEditor.HasDocument", Key = Key.OemMinus)]
+    public void ZoomViewport(double zoom)
+    {
+        ViewportWindowViewModel? viewport = Owner.WindowSubViewModel.ActiveWindow as ViewportWindowViewModel;
+        if (viewport is null)
+            return;
+        viewport.ZoomViewportTrigger.Execute(this, zoom);
+    }
+}

+ 16 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/SelectedToolEventArgs.cs

@@ -0,0 +1,16 @@
+using PixiEditor.ViewModels.SubViewModels.Tools;
+
+namespace PixiEditor.Models.Events;
+
+internal class SelectedToolEventArgs
+{
+    public SelectedToolEventArgs(ToolViewModel oldTool, ToolViewModel newTool)
+    {
+        OldTool = oldTool;
+        NewTool = newTool;
+    }
+
+    public ToolViewModel OldTool { get; set; }
+
+    public ToolViewModel NewTool { get; set; }
+}

+ 21 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ShapeTool.cs

@@ -0,0 +1,21 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools;
+
+internal abstract class ShapeTool : ToolViewModel
+{
+    public override BrushShape BrushShape => BrushShape.Pixel;
+
+    public override bool UsesColor => true;
+
+    public override bool IsErasable => true;
+
+    public ShapeTool()
+    {
+        Cursor = new Cursor(StandardCursorType.Cross);
+        Toolbar = new BasicShapeToolbar();
+    }
+}

+ 14 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/SettingValueChangedEventArgs.cs

@@ -0,0 +1,14 @@
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings;
+
+internal class SettingValueChangedEventArgs<T> : EventArgs
+{
+    public T OldValue { get; set; }
+
+    public T NewValue { get; set; }
+
+    public SettingValueChangedEventArgs(T oldValue, T newValue)
+    {
+        OldValue = oldValue;
+        NewValue = newValue;
+    }
+}

+ 46 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/BoolSetting.cs

@@ -0,0 +1,46 @@
+using System.Windows;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Layout;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+internal sealed class BoolSetting : Setting<bool>
+{
+    public BoolSetting(string name, string label = "")
+        : this(name, false, label)
+    {
+    }
+
+    public BoolSetting(string name, bool isChecked, string label = "")
+        : base(name)
+    {
+        Label = label;
+        Value = isChecked;
+    }
+
+    private Control GenerateCheckBox()
+    {
+        var checkBox = new CheckBox
+        {
+            IsChecked = Value,
+            VerticalAlignment = VerticalAlignment.Center
+        };
+
+        var binding = new Binding("Value")
+        {
+            Mode = BindingMode.TwoWay
+        };
+
+        checkBox.Bind(ToggleButton.IsCheckedProperty, binding);
+
+        return checkBox;
+    }
+
+    public override Control GenerateControl()
+    {
+        return GenerateCheckBox();
+    }
+}

+ 53 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/ColorSetting.cs

@@ -0,0 +1,53 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Microsoft.Xaml.Behaviors;
+using PixiEditor.Helpers.Behaviours;
+using PixiEditor.Views.UserControls;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+internal sealed class ColorSetting : Setting<Color>
+{
+    public ColorSetting(string name, string label = "") : this(name, Colors.White, label)
+    { }
+    
+    public ColorSetting(string name, Color defaultValue, string label = "")
+        : base(name)
+    {
+        Label = label;
+        Value = defaultValue;
+    }
+
+    private ToolSettingColorPicker GenerateColorPicker()
+    {
+        var resourceDictionary = new ResourceDictionary();
+        resourceDictionary.Source = new Uri(
+            "pack://application:,,,/ColorPicker;component/Styles/DefaultColorPickerStyle.xaml",
+            UriKind.RelativeOrAbsolute);
+        var picker = new ToolSettingColorPicker
+        {
+            Style = (Style)resourceDictionary["DefaultColorPickerStyle"]
+        };
+
+        var selectedColorBinding = new Binding("Value")
+        {
+            Mode = BindingMode.TwoWay
+        };
+
+        var behavior = new GlobalShortcutFocusBehavior();
+        Interaction.GetBehaviors(picker).Add(behavior);
+        picker.SetBinding(ToolSettingColorPicker.SelectedColorProperty, selectedColorBinding);
+        return picker;
+    }
+
+    public override Control GenerateControl()
+    {
+        return GenerateColorPicker();
+    }
+}

+ 107 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/EnumSetting.cs

@@ -0,0 +1,107 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.Styling;
+using PixiEditor.Extensions.Helpers;
+using PixiEditor.Extensions.UI;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+internal sealed class EnumSetting<TEnum> : Setting<TEnum, ComboBox>
+    where TEnum : struct, Enum
+{
+    private int selectedIndex;
+
+    /// <summary>
+    /// Gets or sets the selected Index of the <see cref="ComboBox"/>.
+    /// </summary>
+    public int SelectedIndex
+    {
+        get => selectedIndex;
+        set
+        {
+            if (SetProperty(ref selectedIndex, value))
+            {
+                OnPropertyChanged(nameof(Value));
+            }
+        }
+    }
+
+    /// <summary>
+    /// Gets or sets the selected value of the <see cref="ComboBox"/>.
+    /// </summary>
+    public override TEnum Value
+    {
+        get => Enum.GetValues<TEnum>()[SelectedIndex];
+        set
+        {
+            var values = Enum.GetValues<TEnum>();
+
+            for (var i = 0; i < values.Length; i++)
+            {
+                if (values[i].Equals(value))
+                {
+                    SelectedIndex = i;
+                    break;
+                }
+            }
+
+            base.Value = value;
+        }
+    }
+
+    public override Control GenerateControl()
+    {
+        return GenerateDropdown();
+    }
+
+    public EnumSetting(string name, string label)
+        : base(name)
+    {
+        Label = label;
+    }
+
+    public EnumSetting(string name, string label, TEnum defaultValue)
+        : this(name, label)
+    {
+        Value = defaultValue;
+    }
+
+    private static ComboBox GenerateDropdown()
+    {
+        var combobox = new ComboBox
+        {
+            VerticalAlignment = VerticalAlignment.Center,
+            MinWidth = 85
+        };
+
+        GenerateItems(combobox);
+
+        var binding = new Binding(nameof(SelectedIndex))
+        {
+            Mode = BindingMode.TwoWay
+        };
+
+        combobox.Bind(ComboBox.SelectedIndexProperty, binding);
+
+        return combobox;
+    }
+
+    private static void GenerateItems(ComboBox comboBox)
+    {
+        var values = Enum.GetValues<TEnum>();
+
+        foreach (var value in values)
+        {
+            var item = new ComboBoxItem
+            {
+                Tag = value
+            };
+
+            Translator.SetKey(item, value.GetDescription());
+
+            comboBox.Items.Add(item);
+        }
+    }
+}

+ 50 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/FloatSetting.cs

@@ -0,0 +1,50 @@
+using System.Windows.Controls;
+using System.Windows.Data;
+using Avalonia.Controls;
+using Avalonia.Data;
+using PixiEditor.Views.UserControls;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+internal sealed class FloatSetting : Setting<float>
+{
+    public FloatSetting(
+        string name,
+        float initialValue,
+        string label = "",
+        float min = float.NegativeInfinity,
+        float max = float.PositiveInfinity)
+        : base(name)
+    {
+        Label = label;
+        Value = initialValue;
+        Min = min;
+        Max = max;
+    }
+
+    public float Min { get; set; }
+
+    public float Max { get; set; }
+
+    private NumberInput GenerateNumberInput()
+    {
+        var numbrInput = new NumberInput
+        {
+            Width = 40,
+            Height = 20,
+            Min = Min,
+            Max = Max
+        };
+        var binding = new Binding("Value")
+        {
+            Mode = BindingMode.TwoWay
+        };
+        numbrInput.SetBinding(NumberInput.ValueProperty, binding);
+        return numbrInput;
+    }
+
+    public override Control GenerateControl()
+    {
+        return GenerateNumberInput();
+    }
+}

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

@@ -0,0 +1,89 @@
+using Avalonia.Controls;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Localization;
+
+#pragma warning disable SA1402 // File may only contain a single type, Justification: "Same class with generic value"
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+internal abstract class Setting<T, TControl> : Setting<T>
+    where TControl : Control
+{
+    protected Setting(string name)
+        : base(name)
+    {
+    }
+
+    public new TControl SettingControl
+    {
+        get => (TControl)base.SettingControl;
+        set => base.SettingControl = value;
+    }
+}
+
+internal abstract class Setting<T> : Setting
+{
+    protected Setting(string name)
+        : base(name)
+    {
+    }
+
+    public new event EventHandler<SettingValueChangedEventArgs<T>> ValueChanged;
+
+    public new virtual T Value
+    {
+        get => (T)base.Value;
+        set
+        {
+            T oldValue = default;
+            if (base.Value != null)
+            {
+                oldValue = Value;
+            }
+
+            base.Value = value;
+            ValueChanged?.Invoke(this, new SettingValueChangedEventArgs<T>(oldValue, Value));
+            OnPropertyChanged(nameof(Value));
+        }
+    }
+
+    public override Type GetSettingType() => typeof(T);
+}
+
+internal abstract class Setting : ObservableObject
+{
+    private object _value;
+    
+    protected Setting(string name)
+    {
+        Name = name;
+    }
+
+    public event EventHandler<SettingValueChangedEventArgs<object>> ValueChanged;
+
+    public object Value
+    {
+        get => _value;
+        set
+        {
+            var old = _value;
+            if (SetProperty(ref _value, value))
+            {
+                ValueChanged?.Invoke(this, new SettingValueChangedEventArgs<object>(old, value));
+            }
+        }
+    }
+
+    public string Name { get; }
+
+    public LocalizedString Label { get; set; }
+
+    public bool HasLabel => !string.IsNullOrEmpty(Label);
+
+    public Control SettingControl { get; set; }
+
+    public abstract Control GenerateControl();
+
+    public abstract Type GetSettingType();
+}

+ 42 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Settings/SizeSetting.cs

@@ -0,0 +1,42 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Layout;
+using PixiEditor.Views.UserControls;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+internal sealed class SizeSetting : Setting<int>
+{
+    public SizeSetting(string name, string label = null)
+        : base(name)
+    {
+        Label = label;
+        Value = 1;
+    }
+
+    private SizeInput GenerateTextBox()
+    {
+        SizeInput tb = new SizeInput
+        {
+            Height = 20,
+            VerticalAlignment = VerticalAlignment.Center,
+            MaxSize = 9999,
+            IsEnabled = true
+        };
+
+        Binding binding = new Binding("Value")
+        {
+            Mode = BindingMode.TwoWay,
+        };
+        tb.SetBinding(SizeInput.SizeProperty, binding);
+        return tb;
+    }
+
+    public override Control GenerateControl()
+    {
+        return GenerateTextBox();
+    }
+}

+ 18 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/BasicShapeToolbar.cs

@@ -0,0 +1,18 @@
+using System.Windows.Media;
+using Avalonia.Media;
+using PixiEditor.Models.Containers.Toolbars;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+#nullable enable
+internal class BasicShapeToolbar : BasicToolbar, IBasicShapeToolbar
+{
+    public bool Fill => GetSetting<BoolSetting>(nameof(Fill)).Value;
+    public Color FillColor => GetSetting<ColorSetting>(nameof(FillColor)).Value;
+
+    public BasicShapeToolbar()
+    {
+        Settings.Add(new BoolSetting(nameof(Fill), "FILL_SHAPE_LABEL"));
+        Settings.Add(new ColorSetting(nameof(FillColor), "FILL_COLOR_LABEL"));
+    }
+}

+ 27 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/BasicToolbar.cs

@@ -0,0 +1,27 @@
+using PixiEditor.Models.Containers.Toolbars;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+/// <summary>
+///     Toolbar with size setting.
+/// </summary>
+internal class BasicToolbar : Toolbar, IBasicToolbar
+{
+    public int ToolSize
+    {
+        get => GetSetting<SizeSetting>(nameof(ToolSize)).Value;
+        set => GetSetting<SizeSetting>(nameof(ToolSize)).Value = value;
+    }
+    public BasicToolbar()
+    {
+        var setting = new SizeSetting(nameof(ToolSize), "TOOL_SIZE_LABEL");
+        setting.ValueChanged += (_, _) => OnPropertyChanged(nameof(ToolSize));
+        Settings.Add(setting);
+    }
+
+    public override void OnLoadedSettings()
+    {
+        OnPropertyChanged(nameof(ToolSize));
+    }
+}

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/EmptyToolbar.cs

@@ -0,0 +1,5 @@
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+internal class EmptyToolbar : Toolbar
+{
+}

+ 87 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/SettingAttributes.cs

@@ -0,0 +1,87 @@
+using System.Reflection.Emit;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+public static class Settings
+{
+    /// <summary>
+    /// A toolbar setting of type <see cref="bool"/>
+    /// </summary>
+    public class BoolAttribute : SettingsAttribute
+    {
+        public BoolAttribute(string labelKey) : base(labelKey) { }
+
+        public BoolAttribute(string labelKey, object defaultValue) : base(labelKey, defaultValue) { }
+    }
+
+    /// <summary>
+    /// A toolbar setting of any enum
+    /// </summary>
+    public class EnumAttribute : SettingsAttribute
+    {
+        public EnumAttribute(string labelKey) : base(labelKey) { }
+
+        public EnumAttribute(string labelKey, object defaultValue) : base(labelKey, defaultValue) { }
+    }
+
+    /// <summary>
+    /// A toolbar setting of type <see cref="Color"/>
+    /// </summary>
+    public class ColorAttribute : SettingsAttribute
+    {
+        public ColorAttribute(string labelKey) : base(labelKey) { }
+
+        public ColorAttribute(string labelKey, byte r, byte g, byte b) : base(labelKey, new Color(r, g, b)) { }
+        
+        public ColorAttribute(string labelKey, byte r, byte g, byte b, byte a) : base(labelKey, new Color(r, g, b, a)) { }
+    }
+
+    /// <summary>
+    /// A toolbar setting of type <see cref="float"/>
+    /// </summary>
+    public class FloatAttribute : SettingsAttribute
+    {
+        public FloatAttribute(string labelKey) : base(labelKey) { }
+
+        public FloatAttribute(string labelKey, float defaultValue) : base(labelKey, defaultValue) { }
+    }
+
+    /// <summary>
+    /// A toolbar setting of type <see cref="int"/>
+    /// </summary>
+    public class SizeAttribute : SettingsAttribute
+    {
+        public SizeAttribute(string labelKey) : base(labelKey) { }
+    }
+
+    /// <summary>
+    /// Marks a setting to be inherited from the toolbar type
+    /// </summary>
+    public class InheritedAttribute : SettingsAttribute { }
+
+    [AttributeUsage(AttributeTargets.Property)]
+    public abstract class SettingsAttribute : Attribute
+    {
+        public string Name { get; set; }
+        
+        public string Notify { get; set; }
+
+        public SettingsAttribute() { }
+        
+        public SettingsAttribute(string labelKey)
+        {
+            LabelKey = labelKey;
+        }
+
+        public SettingsAttribute(string labelKey, object defaultValue)
+        {
+            LabelKey = labelKey;
+            DefaultValue = defaultValue;
+        }
+        
+        public readonly string LabelKey;
+
+        public readonly object DefaultValue;
+    }
+}

+ 92 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/Toolbar.cs

@@ -0,0 +1,92 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Models.Containers.Toolbars;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+internal abstract class Toolbar : ObservableObject, IToolbar
+{
+    private static readonly List<Setting> SharedSettings = new List<Setting>();
+
+    public ObservableCollection<Setting> Settings { get; set; } = new ObservableCollection<Setting>();
+    public bool SettingsGenerated { get; private set; }
+
+    public void GenerateSettings()
+    {
+        foreach (Setting setting in Settings)
+        {
+            setting.SettingControl = setting.GenerateControl();
+        }
+
+        SettingsGenerated = true;
+    }
+
+    /// <summary>
+    ///     Gets setting in toolbar by name.
+    /// </summary>
+    /// <param name="name">Setting name, non case sensitive.</param>
+    /// <returns>Generic Setting.</returns>
+    public virtual Setting GetSetting(string name)
+    {
+        return Settings.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.CurrentCultureIgnoreCase));
+    }
+
+    /// <summary>
+    ///     Gets setting of given type T in toolbar by name.
+    /// </summary>
+    /// <param name="name">Setting name, non case sensitive.</param>
+    /// <returns>Setting of given type.</returns>
+    public T GetSetting<T>(string name)
+        where T : Setting
+    {
+        Setting setting = Settings.FirstOrDefault(currentSetting => string.Equals(currentSetting.Name, name, StringComparison.CurrentCultureIgnoreCase));
+
+        if (setting is null || setting is not T convertedSetting)
+        {
+            return null;
+        }
+
+        return convertedSetting;
+    }
+
+    /// <summary>
+    ///     Saves current toolbar state, so other toolbars with common settings can load them.
+    /// </summary>
+    public void SaveToolbarSettings()
+    {
+        for (int i = 0; i < Settings.Count; i++)
+        {
+            if (SharedSettings.Any(x => x.Name == Settings[i].Name))
+            {
+                SharedSettings.First(x => x.Name == Settings[i].Name).Value = Settings[i].Value;
+            }
+            else
+            {
+                SharedSettings.Add(Settings[i]);
+            }
+        }
+    }
+
+    /// <summary>
+    ///     Loads common settings saved from previous tools to current one.
+    /// </summary>
+    public void LoadSharedSettings()
+    {
+        for (int i = 0; i < SharedSettings.Count; i++)
+        {
+            if (Settings.Any(x => x.Name == SharedSettings[i].Name))
+            {
+                Settings.First(x => x.Name == SharedSettings[i].Name).Value = SharedSettings[i].Value;
+            }
+        }
+
+        OnLoadedSettings();
+    }
+
+    public virtual void OnLoadedSettings()
+    {
+    }
+}

+ 104 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolSettings/Toolbars/ToolbarFactory.cs

@@ -0,0 +1,104 @@
+using System.Reflection;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+internal static class ToolbarFactory
+{
+    public static Toolbar Create<T>(T tool) where T : ToolViewModel => Create<T, EmptyToolbar>(tool);
+
+    public static TToolbar Create<T, TToolbar>(T tool) where T : ToolViewModel where TToolbar : Toolbar, new()
+    {
+        var toolType = typeof(T);
+        var toolbar = new TToolbar();
+
+        foreach (var property in toolType.GetProperties())
+        {
+            var attribute = property.GetCustomAttribute<Settings.SettingsAttribute>();
+            if (attribute == null) continue;
+
+            var name = attribute.Name ?? property.Name;
+            var label = attribute.LabelKey ?? name;
+
+            if (attribute is Settings.InheritedAttribute)
+            {
+                ProcessInheritedSetting(toolType, tool, toolbar, property, attribute, name);
+            }
+            else
+            {
+                var setting = CreateSetting(property.PropertyType, name, attribute, label);
+                AddValueChangedHandlerIfRequired(toolType, tool, setting, attribute);
+                toolbar.Settings.Add(setting);
+            }
+        }
+
+        return toolbar;
+    }
+
+    private static void ProcessInheritedSetting(Type toolType, ToolViewModel tool, Toolbar toolbar,
+        PropertyInfo property, Settings.SettingsAttribute attribute, string name)
+    {
+        var inherited = toolbar.GetSetting(name);
+        if (inherited == null || inherited.GetSettingType() != property.PropertyType)
+        {
+            throw new InvalidOperationException(
+                $"Inherited setting '{name}' does not match property type '{property.PropertyType}' (Tool: {toolType.FullName})");
+        }
+
+        AddValueChangedHandlerIfRequired(toolType, tool, inherited, attribute);
+    }
+
+    private static Setting CreateSetting(Type propertyType, string name, Settings.SettingsAttribute attribute,
+        string label)
+    {
+        return attribute switch
+        {
+            Settings.BoolAttribute => new BoolSetting(name, (bool)(attribute.DefaultValue ?? false), label),
+            Settings.ColorAttribute => new ColorSetting(name,
+                ((Color)(attribute.DefaultValue ?? Colors.White)).ToColor(), label),
+            Settings.EnumAttribute => GetEnumSetting(propertyType, name, attribute),
+            Settings.FloatAttribute => new FloatSetting(name, (float)(attribute.DefaultValue ?? 0f), label),
+            Settings.SizeAttribute => new SizeSetting(name, label),
+            _ => throw new NotImplementedException(
+                $"SettingsAttribute of type '{attribute.GetType().FullName}' has not been implemented")
+        };
+    }
+
+    private static void AddValueChangedHandlerIfRequired(Type toolType, ToolViewModel tool, Setting setting,
+        Settings.SettingsAttribute attribute)
+    {
+        if (attribute.Notify != null)
+        {
+            AddValueChangedHandler(toolType, tool, setting, attribute);
+        }
+    }
+
+    private static void AddValueChangedHandler<T>(Type toolType, T tool, Setting setting,
+        Settings.SettingsAttribute attribute) where T : ToolViewModel
+    {
+        if (attribute.Notify != null)
+        {
+            var method = toolType.GetMethod(attribute.Notify,
+                BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static,
+                Array.Empty<Type>());
+
+            if (method is null)
+            {
+                throw new NullReferenceException(
+                    $"No method found with the name '{attribute.Notify}' that does not have any parameters");
+            }
+
+            setting.ValueChanged += (_, _) => method.Invoke(tool, null);
+        }
+    }
+
+    private static Setting GetEnumSetting(Type enumType, string name, Settings.SettingsAttribute attribute)
+    {
+        return (Setting)typeof(EnumSetting<>)
+            .MakeGenericType(enumType)
+            .GetConstructor(new[] { typeof(string), typeof(string), enumType })!
+            .Invoke(new[] { name, attribute.LabelKey ?? name, attribute.DefaultValue });
+    }
+}

+ 105 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/ToolViewModel.cs

@@ -0,0 +1,105 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using Avalonia.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools;
+
+internal abstract class ToolViewModel : ObservableObject
+{
+    public bool IsTransient { get; set; } = false;
+    public KeyCombination Shortcut { get; set; }
+
+    public virtual string ToolName => GetType().Name.Replace("Tool", string.Empty).Replace("ViewModel", string.Empty);
+
+    public abstract string ToolNameLocalizationKey { get; }
+    public virtual LocalizedString DisplayName => new LocalizedString(ToolNameLocalizationKey);
+
+    public virtual string ImagePath => $"/Images/Tools/{ToolName}Image.png";
+
+    public virtual BrushShape BrushShape => BrushShape.Square;
+
+    public virtual bool HideHighlight { get; }
+
+    public abstract LocalizedString Tooltip { get; }
+
+    /// <summary>
+    /// Determines if secondary color should be used if right click mode is set to secondary color
+    /// </summary>
+    public virtual bool UsesColor => false;
+
+   /// <summary>
+   /// Determines if PixiEditor should switch to the Eraser when right click mode is set to erase
+   /// </summary>
+    public virtual bool IsErasable => false;
+
+    /// <summary>
+    /// The mouse button that is being used with the tool
+    /// </summary>
+    public MouseButton UsedWith { get; set; }
+
+    private LocalizedString actionDisplay = string.Empty;
+    public LocalizedString ActionDisplay
+    {
+        get => actionDisplay;
+        set
+        {
+            actionDisplay = value;
+            OnPropertyChanged(nameof(ActionDisplay));
+        }
+    }
+
+    private bool isActive;
+    public bool IsActive
+    {
+        get => isActive;
+        set
+        {
+            isActive = value;
+            OnPropertyChanged(nameof(IsActive));
+        }
+    }
+
+    public Cursor Cursor { get; set; } = new Cursor(StandardCursorType.Arrow);
+
+    public Toolbar Toolbar { get; set; } = new EmptyToolbar();
+
+    internal ToolViewModel()
+    {
+        ILocalizationProvider.Current.OnLanguageChanged += OnLanguageChanged;
+    }
+
+    private void OnLanguageChanged(Language obj)
+    {
+        ActionDisplay = new LocalizedString(ActionDisplay.Key);
+    }
+
+    public virtual void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown) { }
+    public virtual void UseTool(VecD pos) { }
+    public virtual void OnSelected() 
+    {
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Operations.TryStopToolLinkedExecutor();
+    }
+
+    public virtual void OnDeselecting()
+    { }
+
+    protected T GetValue<T>([CallerMemberName] string name = null)
+    {
+        var setting = Toolbar.GetSetting(name);
+
+        if (setting.GetSettingType().IsAssignableTo(typeof(Enum)))
+        {
+            var property = setting.GetType().GetProperty("Value",  BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
+            return (T)property!.GetValue(setting);
+        }
+
+        return (T)setting.Value;
+    }
+}

+ 63 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/BrightnessToolViewModel.cs

@@ -0,0 +1,63 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.U)]
+internal class BrightnessToolViewModel : ToolViewModel
+{
+    private readonly string defaultActionDisplay = "BRIGHTNESS_TOOL_ACTION_DISPLAY_DEFAULT";
+    public override string ToolNameLocalizationKey => "BRIGHTNESS_TOOL";
+
+    public BrightnessToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+        Toolbar = ToolbarFactory.Create<BrightnessToolViewModel, BasicToolbar>(this);
+    }
+
+    public override bool IsErasable => true;
+
+    public override LocalizedString Tooltip => new LocalizedString("BRIGHTNESS_TOOL_TOOLTIP", Shortcut);
+
+    public override BrushShape BrushShape => BrushShape.Circle;
+
+    [Settings.Inherited]
+    public int ToolSize => GetValue<int>();
+    
+    [Settings.Float("STRENGTH_LABEL", 5)]
+    public float CorrectionFactor => GetValue<float>();
+
+    [Settings.Enum("MODE_LABEL")]
+    public BrightnessMode BrightnessMode => GetValue<BrightnessMode>();
+    
+    public bool Darken { get; private set; } = false;
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (!ctrlIsDown)
+        {
+            ActionDisplay = defaultActionDisplay;
+            Darken = false;
+        }
+        else
+        {
+            ActionDisplay = "BRIGHTNESS_TOOL_ACTION_DISPLAY_CTRL";
+            Darken = true;
+        }
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseBrightnessTool();
+    }
+}

+ 166 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/ColorPickerToolViewModel.cs

@@ -0,0 +1,166 @@
+using System.ComponentModel;
+using System.Windows.Input;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Hardware.Info;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Events;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Document.TransformOverlays;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.O, Transient = Key.LeftAlt)]
+internal class ColorPickerToolViewModel : ToolViewModel
+{
+    private readonly string defaultReferenceActionDisplay = "COLOR_PICKER_ACTION_DISPLAY_DEFAULT";
+    
+    private readonly string defaultActionDisplay = "COLOR_PICKER_ACTION_DISPLAY_CANVAS_ONLY";
+    
+    public override bool HideHighlight => true;
+
+    public override bool UsesColor => true;
+
+    public override string ToolNameLocalizationKey => "COLOR_PICKER_TOOL";
+    public override BrushShape BrushShape => BrushShape.Pixel;
+
+    public override LocalizedString Tooltip => new("COLOR_PICKER_TOOLTIP", Shortcut);
+
+    private bool pickFromCanvas = true;
+    public bool PickFromCanvas
+    {
+        get => pickFromCanvas;
+        private set
+        {
+            if (SetProperty(ref pickFromCanvas, value))
+            {
+                OnPropertyChanged(nameof(PickOnlyFromReferenceLayer));
+            }
+        }
+    }
+    
+    private bool pickFromReferenceLayer = true;
+    public bool PickFromReferenceLayer
+    {
+        get => pickFromReferenceLayer;
+        private set
+        {
+            if (SetProperty(ref pickFromReferenceLayer, value))
+            {
+                OnPropertyChanged(nameof(PickOnlyFromReferenceLayer));
+            }
+        }
+    }
+
+    public bool PickOnlyFromReferenceLayer => !pickFromCanvas && pickFromReferenceLayer;
+
+    [Settings.Enum("SCOPE_LABEL", DocumentScope.AllLayers)]
+    public DocumentScope Mode => GetValue<DocumentScope>();
+
+    public ColorPickerToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+        Toolbar = ToolbarFactory.Create<ColorPickerToolViewModel, EmptyToolbar>(this);
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocumentChanged += DocumentChanged;
+    }
+
+    private void DocumentChanged(object sender, DocumentChangedEventArgs e)
+    {
+        if (e.OldDocument != null)
+        {
+            e.OldDocument.ReferenceLayerViewModel.PropertyChanged -= ReferenceLayerChanged;
+            e.OldDocument.TransformViewModel.PropertyChanged -= TransformViewModelOnPropertyChanged;
+        }
+
+        if (e.NewDocument != null)
+        {
+            e.NewDocument.ReferenceLayerViewModel.PropertyChanged += ReferenceLayerChanged;
+            e.NewDocument.TransformViewModel.PropertyChanged += TransformViewModelOnPropertyChanged;
+        }
+
+        UpdateActionDisplay();
+    }
+
+    private void TransformViewModelOnPropertyChanged(object sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName is nameof(DocumentTransformViewModel.TransformActive))
+        {
+            UpdateActionDisplay();
+        }
+    }
+
+    private void ReferenceLayerChanged(object sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName is nameof(ReferenceLayerViewModel.ReferenceBitmap) or nameof(ReferenceLayerViewModel.ReferenceShapeBindable))
+        {
+            UpdateActionDisplay();
+        }
+    }
+
+    private void UpdateActionDisplay()
+    {
+        // TODO: We probably need to create keyboard service to handle this
+        bool ctrlDown = (Keyboard.Modifiers & ModifierKeys.Control) != 0;
+        bool shiftDown = (Keyboard.Modifiers & ModifierKeys.Shift) != 0;
+        
+        UpdateActionDisplay(ctrlDown, shiftDown);
+    }
+    
+    private void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown)
+    {
+        var document = ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument;
+
+        if (document == null)
+        {
+            return;
+        }
+
+        var documentBounds = new RectD(default, document.SizeBindable);
+        var referenceLayer = document.ReferenceLayerViewModel;
+        
+        if (referenceLayer.ReferenceBitmap == null || document.TransformViewModel.TransformActive || !referenceLayer.ReferenceShapeBindable.Intersects(documentBounds))
+        {
+            PickFromCanvas = true;
+            PickFromReferenceLayer = true;
+            ActionDisplay = defaultActionDisplay;
+            return;
+        }
+    
+        if (ctrlIsDown)
+        {
+            PickFromCanvas = false;
+            PickFromReferenceLayer = true;
+            ActionDisplay = "COLOR_PICKER_ACTION_DISPLAY_REFERENCE_ONLY";
+        }
+        else if (shiftIsDown)
+        {
+            PickFromCanvas = true;
+            PickFromReferenceLayer = false;
+            ActionDisplay = "COLOR_PICKER_ACTION_DISPLAY_CANVAS_ONLY";
+            return;
+        }
+        else
+        {
+            PickFromCanvas = true;
+            PickFromReferenceLayer = true;
+            ActionDisplay = defaultReferenceActionDisplay;
+        }
+
+        referenceLayer.RaiseShowHighestChanged();
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseColorPickerTool();
+    }
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown) =>
+        UpdateActionDisplay(ctrlIsDown, shiftIsDown);
+}

+ 44 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/EllipseToolViewModel.cs

@@ -0,0 +1,44 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.C)]
+internal class EllipseToolViewModel : ShapeTool
+{
+    private string defaultActionDisplay = "ELLIPSE_TOOL_ACTION_DISPLAY_DEFAULT";
+    public override string ToolNameLocalizationKey => "ELLIPSE_TOOL";
+
+    public EllipseToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+    }
+
+    public override LocalizedString Tooltip => new LocalizedString("ELLIPSE_TOOL_TOOLTIP", Shortcut);
+    public bool DrawCircle { get; private set; }
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (shiftIsDown)
+        {
+            ActionDisplay = "ELLIPSE_TOOL_ACTION_DISPLAY_SHIFT";
+            DrawCircle = true;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            DrawCircle = false;
+        }
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseEllipseTool();
+    }
+}

+ 37 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/EraserToolViewModel.cs

@@ -0,0 +1,37 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.E)]
+internal class EraserToolViewModel : ToolViewModel
+{
+    public EraserToolViewModel()
+    {
+        ActionDisplay = "ERASER_TOOL_ACTION_DISPLAY";
+        Toolbar = ToolbarFactory.Create<EraserToolViewModel, BasicToolbar>(this);
+    }
+
+    [Settings.Inherited]
+    public int ToolSize => GetValue<int>();
+
+    public override bool IsErasable => true;
+
+    public override string ToolNameLocalizationKey => "ERASER_TOOL";
+    public override BrushShape BrushShape => BrushShape.Circle;
+
+    public override LocalizedString Tooltip => new LocalizedString("ERASER_TOOL_TOOLTIP", Shortcut);
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseEraserTool();
+    }
+}

+ 50 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/FloodFillToolViewModel.cs

@@ -0,0 +1,50 @@
+using System.Windows.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.G)]
+internal class FloodFillToolViewModel : ToolViewModel
+{
+    private readonly string defaultActionDisplay = "FLOOD_FILL_TOOL_ACTION_DISPLAY_DEFAULT";
+
+    public override string ToolNameLocalizationKey => "FLOOD_FILL_TOOL";
+    public override BrushShape BrushShape => BrushShape.Pixel;
+
+    public override LocalizedString Tooltip => new("FLOOD_FILL_TOOL_TOOLTIP", Shortcut);
+
+    public override bool UsesColor => true;
+
+    public override bool IsErasable => true;
+
+    public bool ConsiderAllLayers { get; private set; }
+
+    public FloodFillToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+    }
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (ctrlIsDown)
+        {
+            ConsiderAllLayers = true;
+            ActionDisplay = "FLOOD_FILL_TOOL_ACTION_DISPLAY_CTRL";
+        }
+        else
+        {
+            ConsiderAllLayers = false;
+            ActionDisplay = defaultActionDisplay;
+        }
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseFloodFillTool();
+    }
+}

+ 57 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/LassoToolViewModel.cs

@@ -0,0 +1,57 @@
+using System.Windows.Input;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.ToolAttribute(Key = Key.Q)]
+internal class LassoToolViewModel : ToolViewModel
+{
+    private string defaultActionDisplay = "LASSO_TOOL_ACTION_DISPLAY_DEFAULT";
+
+    public LassoToolViewModel()
+    {
+        Toolbar = ToolbarFactory.Create(this);
+        ActionDisplay = defaultActionDisplay;
+    }
+
+    private SelectionMode modifierKeySelectionMode = SelectionMode.New;
+    public SelectionMode ResultingSelectionMode => modifierKeySelectionMode != SelectionMode.New ? modifierKeySelectionMode : SelectMode;
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (shiftIsDown)
+        {
+            ActionDisplay = "LASSO_TOOL_ACTION_DISPLAY_SHIFT";
+            modifierKeySelectionMode = SelectionMode.Add;
+        }
+        else if (ctrlIsDown)
+        {
+            ActionDisplay = "LASSO_TOOL_ACTION_DISPLAY_CTRL";
+            modifierKeySelectionMode = SelectionMode.Subtract;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            modifierKeySelectionMode = SelectionMode.New;
+        }
+    }
+
+    public override LocalizedString Tooltip => new LocalizedString("LASSO_TOOL_TOOLTIP", Shortcut);
+
+    public override string ToolNameLocalizationKey => "LASSO_TOOL";
+    public override BrushShape BrushShape => BrushShape.Pixel;
+
+    [Settings.Enum("MODE_LABEL")]
+    public SelectionMode SelectMode => GetValue<SelectionMode>();
+    
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseLassoTool();
+    }
+}

+ 48 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/LineToolViewModel.cs

@@ -0,0 +1,48 @@
+using System.Windows.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.L)]
+internal class LineToolViewModel : ShapeTool
+{
+    private string defaultActionDisplay = "LINE_TOOL_ACTION_DISPLAY_DEFAULT";
+
+    public LineToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+        Toolbar = ToolbarFactory.Create<LineToolViewModel, BasicToolbar>(this);
+    }
+
+    public override string ToolNameLocalizationKey => "LINE_TOOL";
+    public override LocalizedString Tooltip => new LocalizedString("LINE_TOOL_TOOLTIP", Shortcut);
+
+    [Settings.Inherited]
+    public int ToolSize => GetValue<int>();
+
+    public bool Snap { get; private set; }
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (shiftIsDown)
+        {
+            ActionDisplay = "LINE_TOOL_ACTION_DISPLAY_SHIFT";
+            Snap = true;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            Snap = false;
+        }
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseLineTool();
+    }
+}

+ 37 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/MagicWandToolViewModel.cs

@@ -0,0 +1,37 @@
+using System.Windows.Input;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.W)]
+internal class MagicWandToolViewModel : ToolViewModel
+{
+    public override LocalizedString Tooltip => new LocalizedString("MAGIC_WAND_TOOL_TOOLTIP", Shortcut);
+
+    public override string ToolNameLocalizationKey => "MAGIC_WAND_TOOL";
+    public override BrushShape BrushShape => BrushShape.Pixel;
+
+    [Settings.Enum("MODE_LABEL")]
+    public SelectionMode SelectMode => GetValue<SelectionMode>();
+
+    [Settings.Enum("SCOPE_LABEL")]
+    public DocumentScope DocumentScope => GetValue<DocumentScope>();
+    
+    public MagicWandToolViewModel()
+    {
+        Toolbar = ToolbarFactory.Create(this);
+        ActionDisplay = "MAGIC_WAND_ACTION_DISPLAY";
+    }
+    
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseMagicWandTool();
+    }
+}

+ 76 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/MoveToolViewModel.cs

@@ -0,0 +1,76 @@
+using System.Windows.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.V)]
+internal class MoveToolViewModel : ToolViewModel
+{
+    private string defaultActionDisplay = "MOVE_TOOL_ACTION_DISPLAY";
+    public override string ToolNameLocalizationKey => "MOVE_TOOL";
+
+    private string transformingActionDisplay = "MOVE_TOOL_ACTION_DISPLAY_TRANSFORMING";
+    private bool transformingSelectedArea = false;
+
+    public bool MoveAllLayers { get; set; }
+
+    public MoveToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+        Toolbar = ToolbarFactory.Create(this);
+        Cursor = Cursors.Arrow;
+    }
+
+    public override LocalizedString Tooltip => new LocalizedString("MOVE_TOOL_TOOLTIP", Shortcut);
+
+    [Settings.Bool("KEEP_ORIGINAL_IMAGE_SETTING")]
+    public bool KeepOriginalImage => GetValue<bool>();
+
+    public override BrushShape BrushShape => BrushShape.Hidden;
+    public override bool HideHighlight => true;
+
+    public bool TransformingSelectedArea
+    {
+        get => transformingSelectedArea;
+        set
+        {
+            transformingSelectedArea = value;
+            ActionDisplay = value ? transformingActionDisplay : defaultActionDisplay;
+        }
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseShiftLayerTool();
+    }
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (TransformingSelectedArea)
+        {
+            return;
+        }
+        
+        if (ctrlIsDown)
+        {
+            ActionDisplay = new LocalizedString("MOVE_TOOL_ACTION_DISPLAY_CTRL");
+            MoveAllLayers = true;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            MoveAllLayers = false;
+        }
+    }
+
+    public override void OnSelected()
+    {
+        ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument?.Operations.TransformSelectedArea(true);
+    }
+}

+ 26 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/MoveViewportToolViewModel.cs

@@ -0,0 +1,26 @@
+using System.Windows.Input;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.H, Transient = Key.Space)]
+internal class MoveViewportToolViewModel : ToolViewModel
+{
+    public override string ToolNameLocalizationKey => "MOVE_VIEWPORT_TOOL";
+    public override BrushShape BrushShape => BrushShape.Hidden;
+    public override bool HideHighlight => true;
+    public override LocalizedString Tooltip => new LocalizedString("MOVE_VIEWPORT_TOOLTIP", Shortcut);
+
+    public MoveViewportToolViewModel()
+    {
+        Cursor = Cursors.SizeAll;
+    }
+
+    public override void OnSelected()
+    {
+        ActionDisplay = new LocalizedString("MOVE_VIEWPORT_ACTION_DISPLAY");
+    }
+}

+ 104 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/PenToolViewModel.cs

@@ -0,0 +1,104 @@
+using System.Windows.Input;
+using System.Windows.Media;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Events;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Settings;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools
+{
+    [Command.Tool(Key = Key.B)]
+    internal class PenToolViewModel : ShapeTool
+    {
+        private int actualToolSize;
+
+        public override string ToolNameLocalizationKey => "PEN_TOOL";
+        public override BrushShape BrushShape => BrushShape.Circle;
+
+        public PenToolViewModel()
+        {
+            Cursor = Cursors.Pen;
+            Toolbar = ToolbarFactory.Create<PenToolViewModel, BasicToolbar>(this);
+            
+            ViewModelMain.Current.ToolsSubViewModel.SelectedToolChanged += SelectedToolChanged;
+        }
+
+        public override LocalizedString Tooltip => new LocalizedString("PEN_TOOL_TOOLTIP", Shortcut);
+
+        [Settings.Inherited]
+        public int ToolSize => GetValue<int>();
+
+        [Settings.Bool("PIXEL_PERFECT_SETTING", Notify = nameof(PixelPerfectChanged))]
+        public bool PixelPerfectEnabled => GetValue<bool>();
+
+        public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+        {
+            ActionDisplay = new LocalizedString("PEN_TOOL_ACTION_DISPLAY", Shortcut);
+        }
+
+        public override void UseTool(VecD pos)
+        {
+            ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UsePenTool();
+        }
+
+        private void SelectedToolChanged(object sender, SelectedToolEventArgs e)
+        {
+            if (e.NewTool == this && PixelPerfectEnabled)
+            {
+                var toolbar = (BasicToolbar)Toolbar;
+                var setting = (SizeSetting)toolbar.Settings.First(x => x.Name == "ToolSize");
+                setting.Value = 1;
+            }
+            
+            if (!IPreferences.Current.GetPreference<bool>("EnableSharedToolbar"))
+            {
+                return;
+            }
+
+            if (e.OldTool is not { Toolbar: BasicToolbar oldToolbar })
+            {
+                return;
+            }
+            
+            var oldSetting = (SizeSetting)oldToolbar.Settings[0];
+            actualToolSize = oldSetting.Value;
+        }
+
+        public override void OnDeselecting()
+        {
+            if (!PixelPerfectEnabled)
+            {
+                return;
+            }
+
+            var toolbar = (BasicToolbar)Toolbar;
+            var setting = (SizeSetting)toolbar.Settings[0];
+            setting.Value = actualToolSize;
+        }
+
+        private void PixelPerfectChanged()
+        {
+            var toolbar = (BasicToolbar)Toolbar;
+            var setting = (SizeSetting)toolbar.Settings[0];
+
+            setting.SettingControl.IsEnabled = !PixelPerfectEnabled;
+
+            if (PixelPerfectEnabled)
+            {
+                actualToolSize = ToolSize;
+                setting.Value = 1;
+            }
+            else
+            {
+                setting.Value = actualToolSize;
+            }
+        }
+    }
+}

+ 47 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/RectangleToolViewModel.cs

@@ -0,0 +1,47 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Containers.Toolbars;
+using PixiEditor.Models.Containers.Tools;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.R)]
+internal class RectangleToolViewModel : ShapeTool, IRectangleToolHandler
+{
+    private string defaultActionDisplay = "RECTANGLE_TOOL_ACTION_DISPLAY_DEFAULT";
+    public RectangleToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+    }
+
+    public override string ToolNameLocalizationKey => "RECTANGLE_TOOL";
+    public IToolbar Toolbar { get; set; }
+    public override LocalizedString Tooltip => new LocalizedString("RECTANGLE_TOOL_TOOLTIP", Shortcut);
+
+    public bool Filled { get; set; } = false;
+    public bool DrawSquare { get; private set; } = false;
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (shiftIsDown)
+        {
+            DrawSquare = true;
+            ActionDisplay = "RECTANGLE_TOOL_ACTION_DISPLAY_SHIFT";
+        }
+        else
+        {
+            DrawSquare = false;
+            ActionDisplay = defaultActionDisplay;
+        }
+    }
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseRectangleTool();
+    }
+}

+ 31 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/RotateViewportToolViewModel.cs

@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.N)]
+internal class RotateViewportToolViewModel : ToolViewModel
+{
+    public override string ToolNameLocalizationKey => "ROTATE_VIEWPORT_TOOL";
+    public override BrushShape BrushShape => BrushShape.Hidden;
+    public override bool HideHighlight => true;
+    public override LocalizedString Tooltip => new LocalizedString("ROTATE_VIEWPORT_TOOLTIP", Shortcut);
+
+    public RotateViewportToolViewModel()
+    {
+    }
+
+    public override void OnSelected()
+    {
+        ActionDisplay = new LocalizedString("ROTATE_VIEWPORT_ACTION_DISPLAY");
+    }
+}

+ 65 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/SelectToolViewModel.cs

@@ -0,0 +1,65 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.M)]
+internal class SelectToolViewModel : ToolViewModel
+{
+    private string defaultActionDisplay = "SELECT_TOOL_ACTION_DISPLAY_DEFAULT";
+    public override string ToolNameLocalizationKey => "SELECT_TOOL_NAME";
+
+    public SelectToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+        Toolbar = ToolbarFactory.Create(this);
+        Cursor = Cursors.Cross;
+    }
+
+    private SelectionMode modifierKeySelectionMode = SelectionMode.New;
+    public SelectionMode ResultingSelectionMode => modifierKeySelectionMode != SelectionMode.New ? modifierKeySelectionMode : SelectMode;
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (shiftIsDown)
+        {
+            ActionDisplay = new LocalizedString("SELECT_TOOL_ACTION_DISPLAY_SHIFT");
+            modifierKeySelectionMode = SelectionMode.Add;
+        }
+        else if (ctrlIsDown)
+        {
+            ActionDisplay = new LocalizedString("SELECT_TOOL_ACTION_DISPLAY_CTRL");
+            modifierKeySelectionMode = SelectionMode.Subtract;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            modifierKeySelectionMode = SelectionMode.New;
+        }
+    }
+
+    [Settings.Enum("MODE_LABEL")]
+    public SelectionMode SelectMode => GetValue<SelectionMode>();
+
+    [Settings.Enum("SHAPE_LABEL")]
+    public SelectionShape SelectShape => GetValue<SelectionShape>();
+
+    public override BrushShape BrushShape => BrushShape.Pixel;
+
+    public override LocalizedString Tooltip => new LocalizedString("SELECT_TOOL_TOOLTIP", Shortcut);
+
+    public override void UseTool(VecD pos)
+    {
+        ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Tools.UseSelectTool();
+    }
+}

+ 47 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/ZoomToolViewModel.cs

@@ -0,0 +1,47 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Localization;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+[Command.Tool(Key = Key.Z)]
+internal class ZoomToolViewModel : ToolViewModel
+{
+    private bool zoomOutOnClick = false;
+    public bool ZoomOutOnClick
+    {
+        get => zoomOutOnClick;
+        set => SetProperty(ref zoomOutOnClick, value);
+    }
+
+    private string defaultActionDisplay = new LocalizedString("ZOOM_TOOL_ACTION_DISPLAY_DEFAULT");
+
+    public override string ToolNameLocalizationKey => "ZOOM_TOOL";
+    public override BrushShape BrushShape => BrushShape.Hidden;
+
+    public ZoomToolViewModel()
+    {
+        ActionDisplay = defaultActionDisplay;
+    }
+
+    public override bool HideHighlight => true;
+
+    public override LocalizedString Tooltip => new LocalizedString("ZOOM_TOOL_TOOLTIP", Shortcut);
+
+    public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
+    {
+        if (ctrlIsDown)
+        {
+            ActionDisplay = new LocalizedString("ZOOM_TOOL_ACTION_DISPLAY_CTRL");
+            ZoomOutOnClick = true;
+        }
+        else
+        {
+            ActionDisplay = defaultActionDisplay;
+            ZoomOutOnClick = false;
+        }
+    }
+}

+ 301 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/ViewModelMain.cs

@@ -0,0 +1,301 @@
+using System.ComponentModel;
+using System.Linq;
+using System.Windows;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Collections;
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Events;
+using PixiEditor.Models.Localization;
+using PixiEditor.ViewModels.SubViewModels.AdditionalContent;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Main;
+using PixiEditor.ViewModels.SubViewModels.Tools;
+
+namespace PixiEditor.ViewModels;
+
+internal partial class ViewModelMain : ViewModelBase
+{
+    public static ViewModelMain Current { get; set; }
+    public IServiceProvider Services { get; private set; }
+
+    public Action CloseAction { get; set; }
+    public event EventHandler OnStartupEvent;
+
+    public FileViewModel FileSubViewModel { get; set; }
+
+    public UpdateViewModel UpdateSubViewModel { get; set; }
+
+    public ToolsViewModel ToolsSubViewModel { get; set; }
+
+    public IoViewModel IoSubViewModel { get; set; }
+
+    public LayersViewModel LayersSubViewModel { get; set; }
+
+    public ClipboardViewModel ClipboardSubViewModel { get; set; }
+
+    public UndoViewModel UndoSubViewModel { get; set; }
+
+    public SelectionViewModel SelectionSubViewModel { get; set; }
+
+    public ViewOptionsViewModel ViewportSubViewModel { get; set; }
+
+    public ColorsViewModel ColorsSubViewModel { get; set; }
+
+    public MiscViewModel MiscSubViewModel { get; set; }
+
+    public DiscordViewModel DiscordViewModel { get; set; }
+
+    public DebugViewModel DebugSubViewModel { get; set; }
+
+    public DocumentManagerViewModel DocumentManagerSubViewModel { get; set; }
+
+    public CommandController CommandController { get; set; }
+
+    public ShortcutController ShortcutController { get; set; }
+
+    public StylusViewModel StylusSubViewModel { get; set; }
+
+    public WindowViewModel WindowSubViewModel { get; set; }
+
+    public SearchViewModel SearchSubViewModel { get; set; }
+
+    public RegistryViewModel RegistrySubViewModel { get; set; }
+
+    public AdditionalContentViewModel AdditionalContentSubViewModel { get; set; }
+
+    public ExtensionsViewModel ExtensionsSubViewModel { get; set; }
+
+    public IPreferences Preferences { get; set; }
+    public ILocalizationProvider LocalizationProvider { get; set; }
+
+    public LocalizedString ActiveActionDisplay
+    {
+        get
+        {
+            if (ActionDisplays.HasActive())
+            {
+                return ActionDisplays.GetActive();
+            }
+
+            var documentDisplay = DocumentManagerSubViewModel.ActiveDocument?.ActionDisplays;
+            if (documentDisplay != null && documentDisplay.HasActive())
+            {
+                return documentDisplay.GetActive();
+            }
+
+            return ToolsSubViewModel.ActiveTool?.ActionDisplay ?? default;
+        }
+    }
+
+    public ActionDisplayList ActionDisplays { get; }
+
+    public ViewModelMain(IServiceProvider serviceProvider)
+    {
+        Current = this;
+        ActionDisplays = new ActionDisplayList(() => RaisePropertyChanged(nameof(ActiveActionDisplay)));
+    }
+
+    public void Setup(IServiceProvider services)
+    {
+        Services = services;
+
+        Preferences = services.GetRequiredService<IPreferences>();
+        Preferences.Init();
+
+        LocalizationProvider = services.GetRequiredService<ILocalizationProvider>();
+        LocalizationProvider.LoadData();
+
+        WindowSubViewModel = services.GetService<WindowViewModel>();
+        DocumentManagerSubViewModel = services.GetRequiredService<DocumentManagerViewModel>();
+        SelectionSubViewModel = services.GetService<SelectionViewModel>();
+
+        FileSubViewModel = services.GetService<FileViewModel>();
+        ToolsSubViewModel = services.GetService<ToolsViewModel>();
+        ToolsSubViewModel.SelectedToolChanged += ToolsSubViewModel_SelectedToolChanged;
+
+        IoSubViewModel = services.GetService<IoViewModel>();
+        LayersSubViewModel = services.GetService<LayersViewModel>();
+        ClipboardSubViewModel = services.GetService<ClipboardViewModel>();
+        UndoSubViewModel = services.GetService<UndoViewModel>();
+        ViewportSubViewModel = services.GetService<ViewOptionsViewModel>();
+        ColorsSubViewModel = services.GetService<ColorsViewModel>();
+        ColorsSubViewModel?.SetupPaletteProviders(services);
+
+        ToolsSubViewModel?.SetupTools(services);
+
+        DiscordViewModel = services.GetService<DiscordViewModel>();
+        UpdateSubViewModel = services.GetService<UpdateViewModel>();
+        DebugSubViewModel = services.GetService<DebugViewModel>();
+
+        StylusSubViewModel = services.GetService<StylusViewModel>();
+        RegistrySubViewModel = services.GetService<RegistryViewModel>();
+
+        AdditionalContentSubViewModel = services.GetService<AdditionalContentViewModel>();
+
+        MiscSubViewModel = services.GetService<MiscViewModel>();
+
+        CommandController = services.GetService<CommandController>();
+        CommandController.Init(services);
+        ShortcutController = new ShortcutController();
+
+        ToolsSubViewModel?.SetupToolsTooltipShortcuts(services);
+
+        SearchSubViewModel = services.GetService<SearchViewModel>();
+
+        ExtensionsSubViewModel = services.GetService<ExtensionsViewModel>(); // Must be last
+
+        DocumentManagerSubViewModel.ActiveDocumentChanged += OnActiveDocumentChanged;
+    }
+
+    public bool DocumentIsNotNull(object property)
+    {
+        return DocumentManagerSubViewModel.ActiveDocument is not null;
+    }
+
+    public bool DocumentIsNotNull((Color oldColor, Color newColor) obj)
+    {
+        return DocumentIsNotNull(null);
+    }
+
+    [RelayCommand]
+    public void CloseWindow(object property)
+    {
+        if (!(property is CancelEventArgs))
+        {
+            throw new ArgumentException();
+        }
+
+        ((CancelEventArgs)property).Cancel = !DisposeAllDocumentsWithSaveConfirmation();
+    }
+
+    private void ToolsSubViewModel_SelectedToolChanged(object sender, SelectedToolEventArgs e)
+    {
+        if (e.OldTool != null)
+            e.OldTool.PropertyChanged -= SelectedTool_PropertyChanged;
+        e.NewTool.PropertyChanged += SelectedTool_PropertyChanged;
+
+        NotifyToolActionDisplayChanged();
+    }
+
+    private void SelectedTool_PropertyChanged(object sender, PropertyChangedEventArgs e)
+    {
+        if (e.PropertyName == nameof(ToolViewModel.ActionDisplay))
+        {
+            NotifyToolActionDisplayChanged();
+        }
+    }
+
+    public void NotifyToolActionDisplayChanged()
+    {
+        if (!ActionDisplays.Any()) OnPropertyChanged(nameof(ActiveActionDisplay));
+    }
+
+    /// <summary>
+    /// Closes documents with unsaved changes confirmation dialog.
+    /// </summary>
+    /// <returns>If documents was removed successfully.</returns>
+    private bool DisposeAllDocumentsWithSaveConfirmation()
+    {
+        int docCount = DocumentManagerSubViewModel.Documents.Count;
+        for (int i = 0; i < docCount; i++)
+        {
+            WindowSubViewModel.MakeDocumentViewportActive(DocumentManagerSubViewModel.Documents.First());
+            bool canceled = !DisposeActiveDocumentWithSaveConfirmation();
+            if (canceled)
+            {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /// <summary>
+    /// Disposes the active document after showing the unsaved changes confirmation dialog.
+    /// </summary>
+    /// <returns>If the document was closed successfully.</returns>
+    public bool DisposeActiveDocumentWithSaveConfirmation()
+    {
+        if (DocumentManagerSubViewModel.ActiveDocument is null)
+            return false;
+        return DisposeDocumentWithSaveConfirmation(DocumentManagerSubViewModel.ActiveDocument);
+    }
+
+    public bool DisposeDocumentWithSaveConfirmation(DocumentViewModel document)
+    {
+        const string ConfirmationDialogTitle = "UNSAVED_CHANGES";
+        const string ConfirmationDialogMessage = "DOCUMENT_MODIFIED_SAVE";
+
+        ConfirmationType result = ConfirmationType.No;
+        if (!document.AllChangesSaved)
+        {
+            result = ConfirmationDialog.Show(ConfirmationDialogMessage, ConfirmationDialogTitle);
+            if (result == ConfirmationType.Yes)
+            {
+                if (!FileSubViewModel.SaveDocument(document, false))
+                    return false;
+            }
+        }
+
+        if (result != ConfirmationType.Canceled)
+        {
+            if (!DocumentManagerSubViewModel.Documents.Remove(document))
+                throw new InvalidOperationException("Trying to close a document that's not in the documents collection. Likely, the document wasn't added there after creation by mistake.");
+
+            if (DocumentManagerSubViewModel.ActiveDocument == document)
+            {
+                if (DocumentManagerSubViewModel.Documents.Count > 0)
+                    WindowSubViewModel.MakeDocumentViewportActive(DocumentManagerSubViewModel.Documents.Last());
+                else
+                    WindowSubViewModel.MakeDocumentViewportActive(null);
+            }
+
+            // TODO: this thing should actually dispose the document to free up ram
+            // We need the UI to be able to handle disposed documents
+            // Like, the viewports should show nothing, the commands shouldn't work, etc. At least nothing should crash or behave unexpectedly
+            // Mostly we only care about this because avalondock doesn't remove the UI elements of closed viewports (at least not right away)
+            // So they remain alive and keep "showing" the now disposed DocumentViewModel
+            // And since they reference the DocumentViewModel it doesn't get collected by GC
+
+            // document.Dispose();
+            WindowSubViewModel.CloseViewportsForDocument(document);
+
+            return true;
+        }
+        return false;
+    }
+
+    [RelayCommand]
+    private void OnStartup(object parameter)
+    {
+        OnStartupEvent?.Invoke(this, EventArgs.Empty);
+    }
+
+    private void OnActiveDocumentChanged(object sender, DocumentChangedEventArgs e)
+    {
+        NotifyToolActionDisplayChanged();
+        if (e.OldDocument is not null)
+            e.OldDocument.SizeChanged -= ActiveDocument_DocumentSizeChanged;
+        if (e.NewDocument is not null)
+            e.NewDocument.SizeChanged += ActiveDocument_DocumentSizeChanged;
+    }
+
+    private void ActiveDocument_DocumentSizeChanged(object sender, DocumentSizeChangedEventArgs e)
+    {
+        foreach (var viewport in WindowSubViewModel.Viewports.Where(viewport => viewport.Document == e.Document))
+        {
+            viewport.CenterViewportTrigger.Execute(this, e.NewSize);
+        }
+    }
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Dialogs/ConfirmationType.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.Enums;
+
+public enum ConfirmationType
+{
+    Yes,
+    No,
+    Canceled
+}

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainView.axaml

@@ -2,12 +2,12 @@
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
              xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
-             xmlns:vm="clr-namespace:PixiEditor.Avalonia.ViewModels"
              xmlns:main="clr-namespace:PixiEditor.Avalonia.Views.Main"
              xmlns:main="clr-namespace:PixiEditor.Avalonia.Views.Main"
              xmlns:xaml="clr-namespace:PixiEditor.Models.Commands.XAML"
              xmlns:xaml="clr-namespace:PixiEditor.Models.Commands.XAML"
+             xmlns:viewModels="clr-namespace:PixiEditor.ViewModels"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.Avalonia.Views.MainView"
              x:Class="PixiEditor.Avalonia.Views.MainView"
-             x:DataType="vm:MainViewModel" Background="{DynamicResource ThemeBackgroundBrush}">
+             x:DataType="viewModels:ViewModelMain" Background="{DynamicResource ThemeBackgroundBrush}">
     <Grid>
     <Grid>
         <Grid.RowDefinitions>
         <Grid.RowDefinitions>
             <RowDefinition Height="25"/>
             <RowDefinition Height="25"/>

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainWindow.axaml.cs

@@ -22,7 +22,7 @@ internal partial class MainWindow : Window
     private readonly IServiceProvider services;
     private readonly IServiceProvider services;
     private static ExtensionLoader extLoader;
     private static ExtensionLoader extLoader;
 
 
-    public new MainViewModel DataContext { get => (MainViewModel)base.DataContext; set => base.DataContext = value; }
+    public new ViewModelMain DataContext { get => (ViewModelMain)base.DataContext; set => base.DataContext = value; }
 
 
     public MainWindow(ExtensionLoader extensionLoader)
     public MainWindow(ExtensionLoader extensionLoader)
     {
     {
@@ -39,7 +39,7 @@ internal partial class MainWindow : Window
 
 
         preferences = services.GetRequiredService<IPreferences>();
         preferences = services.GetRequiredService<IPreferences>();
         platform = services.GetRequiredService<IPlatform>();
         platform = services.GetRequiredService<IPlatform>();
-        DataContext = services.GetRequiredService<MainViewModel>();
+        DataContext = services.GetRequiredService<ViewModelMain>();
         DataContext.Setup(services);
         DataContext.Setup(services);
 
 
         InitializeComponent();
         InitializeComponent();

+ 13 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/BrushShapeOverlay/BrushShape.cs

@@ -0,0 +1,13 @@
+using PixiEditor;
+using PixiEditor.Views;
+using PixiEditor.Views.UserControls;
+using PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+
+namespace PixiEditor.Views.UserControls.Overlays.BrushShapeOverlay;
+internal enum BrushShape
+{
+    Hidden,
+    Pixel,
+    Square,
+    Circle
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/Anchor.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Views.UserControls.Overlays.TransformOverlay;
+#nullable enable
+internal enum Anchor
+{
+    TopLeft, TopRight, BottomLeft, BottomRight,
+    Top, Left, Right, Bottom,
+    Origin
+}

+ 9 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformCornerFreedom.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.Views.UserControls.Overlays.TransformOverlay;
+#nullable enable
+internal enum TransformCornerFreedom
+{
+    Locked,
+    ScaleProportionally,
+    Scale,
+    Free
+}

+ 303 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformHelper.cs

@@ -0,0 +1,303 @@
+using System.Windows;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+#nullable enable
+
+namespace PixiEditor.Views.UserControls.Overlays.TransformOverlay;
+internal static class TransformHelper
+{
+    public const double AnchorSize = 10;
+    public const double MoveHandleSize = 16;
+
+    public static Rect ToAnchorRect(VecD pos, double zoomboxScale)
+    {
+        double scaled = AnchorSize / zoomboxScale;
+        return new Rect(pos.X - scaled / 2, pos.Y - scaled / 2, scaled, scaled);
+    }
+
+    public static Rect ToHandleRect(VecD pos, double zoomboxScale)
+    {
+        double scaled = MoveHandleSize / zoomboxScale;
+        return new Rect(pos.X - scaled / 2, pos.Y - scaled / 2, scaled, scaled);
+    }
+
+    public static VecD ToVecD(Point pos) => new VecD(pos.X, pos.Y);
+    public static Point ToPoint(VecD vec) => new Point(vec.X, vec.Y);
+
+    public static ShapeCorners SnapToPixels(ShapeCorners corners)
+    {
+        corners.TopLeft = corners.TopLeft.Round();
+        corners.TopRight = corners.TopRight.Round();
+        corners.BottomLeft = corners.BottomLeft.Round();
+        corners.BottomRight = corners.BottomRight.Round();
+        return corners;
+    }
+
+    public static Cursor GetResizeCursor(Anchor anchor, ShapeCorners corners, double zoomboxAngle)
+    {
+        double angle;
+        if (IsSide(anchor))
+        {
+            var (left, right) = GetCornersOnSide(anchor);
+            VecD leftPos = GetAnchorPosition(corners, left);
+            VecD rightPos = GetAnchorPosition(corners, right);
+            angle = (leftPos - rightPos).Angle + Math.PI / 2;
+        }
+        else if (IsCorner(anchor))
+        {
+            var (left, right) = GetNeighboringCorners(anchor);
+            VecD leftPos = GetAnchorPosition(corners, left);
+            VecD curPos = GetAnchorPosition(corners, anchor);
+            VecD rightPos = GetAnchorPosition(corners, right);
+            angle = ((curPos - leftPos).Normalize() + (curPos - rightPos).Normalize()).Angle;
+        }
+        else
+        {
+            return new Cursor(StandardCursorType.Arrow);
+        }
+
+        angle += zoomboxAngle;
+        angle = Math.Round(angle * 4 / Math.PI);
+        angle = (int)((angle % 8 + 8) % 8);
+        if (angle is 0 or 4)
+        {
+            return new Cursor(StandardCursorType.SizeWestEast);
+        }
+
+        if (angle is (2 or 6))
+        {
+            return new Cursor(StandardCursorType.SizeNorthSouth);
+        }
+
+        if (angle is (1 or 5))
+        {
+            return new Cursor(StandardCursorType.SizeAll);
+        }
+
+        return new Cursor(StandardCursorType.SizeAll);
+    }
+
+    private static double GetSnappingAngle(double angle)
+    {
+        return Math.Round(angle * 8 / (Math.PI * 2)) * (Math.PI * 2) / 8;
+    }
+    public static double FindSnappingAngle(ShapeCorners corners, double desiredAngle)
+    {
+        var desTop = (corners.TopLeft - corners.TopRight).Rotate(desiredAngle).Angle;
+        var desRight = (corners.TopRight - corners.BottomRight).Rotate(desiredAngle).Angle;
+        var desBottom = (corners.BottomRight - corners.BottomLeft).Rotate(desiredAngle).Angle;
+        var desLeft = (corners.BottomLeft - corners.TopLeft).Rotate(desiredAngle).Angle;
+
+        var deltaTop = GetSnappingAngle(desTop) - desTop;
+        var deltaRight = GetSnappingAngle(desRight) - desRight;
+        var deltaLeft = GetSnappingAngle(desLeft) - desLeft;
+        var deltaBottom = GetSnappingAngle(desBottom) - desBottom;
+
+        var minDelta = deltaTop;
+        if (Math.Abs(minDelta) > Math.Abs(deltaRight))
+            minDelta = deltaRight;
+        if (Math.Abs(minDelta) > Math.Abs(deltaLeft))
+            minDelta = deltaLeft;
+        if (Math.Abs(minDelta) > Math.Abs(deltaBottom))
+            minDelta = deltaBottom;
+        return minDelta + desiredAngle;
+    }
+
+    public static VecD OriginFromCorners(ShapeCorners corners)
+    {
+        var maybeOrigin = TwoLineIntersection(
+            GetAnchorPosition(corners, Anchor.Top),
+            GetAnchorPosition(corners, Anchor.Bottom),
+            GetAnchorPosition(corners, Anchor.Left),
+            GetAnchorPosition(corners, Anchor.Right)
+            );
+        return maybeOrigin ?? corners.TopLeft.Lerp(corners.BottomRight, 0.5);
+    }
+
+    public static VecD? TwoLineIntersection(VecD line1Start, VecD line1End, VecD line2Start, VecD line2End)
+    {
+        const double epsilon = 0.0001;
+
+        VecD line1delta = line1End - line1Start;
+        VecD line2delta = line2End - line2Start;
+
+        // both lines are vertical, no intersections
+        if (Math.Abs(line1delta.X) < epsilon && Math.Abs(line2delta.X) < epsilon)
+            return null;
+
+        // y = mx + c
+        double m1 = line1delta.Y / line1delta.X;
+        double m2 = line2delta.Y / line2delta.X;
+
+        // line 1 is vertical (m1 is infinity)
+        if (Math.Abs(line1delta.X) < epsilon)
+        {
+            double c2 = line2Start.Y - line2Start.X * m2;
+            return new(line1Start.X, m2 * line1Start.X + c2);
+        }
+
+        // line 2 is vertical
+        if (Math.Abs(line2delta.X) < epsilon)
+        {
+            double c1 = line1Start.Y - line1Start.X * m1;
+            return new(line2Start.X, m1 * line2Start.X + c1);
+        }
+
+        // lines are parallel
+        if (Math.Abs(m1 - m2) < epsilon)
+            return null;
+
+        {
+            double c1 = line1Start.Y - line1Start.X * m1;
+            double c2 = line2Start.Y - line2Start.X * m2;
+            double x = (c1 - c2) / (m2 - m1);
+            return new(x, m1 * x + c1);
+        }
+    }
+
+    public static bool IsCorner(Anchor anchor)
+    {
+        return anchor is Anchor.TopLeft or Anchor.TopRight or Anchor.BottomRight or Anchor.BottomLeft;
+    }
+
+    public static bool IsSide(Anchor anchor)
+    {
+        return anchor is Anchor.Left or Anchor.Right or Anchor.Top or Anchor.Bottom;
+    }
+
+    public static Anchor GetOpposite(Anchor anchor)
+    {
+        return anchor switch
+        {
+            Anchor.TopLeft => Anchor.BottomRight,
+            Anchor.TopRight => Anchor.BottomLeft,
+            Anchor.BottomLeft => Anchor.TopRight,
+            Anchor.BottomRight => Anchor.TopLeft,
+            Anchor.Top => Anchor.Bottom,
+            Anchor.Left => Anchor.Right,
+            Anchor.Right => Anchor.Left,
+            Anchor.Bottom => Anchor.Top,
+            _ => throw new ArgumentException($"{anchor} is not a corner or a side"),
+        };
+    }
+
+    /// <summary>
+    /// The first anchor would be on your left if you were standing on the side and looking inside the shape; the second anchor is to the right.
+    /// </summary>
+    public static (Anchor leftAnchor, Anchor rightAnchor) GetCornersOnSide(Anchor side)
+    {
+        return side switch
+        {
+            Anchor.Left => (Anchor.TopLeft, Anchor.BottomLeft),
+            Anchor.Right => (Anchor.BottomRight, Anchor.TopRight),
+            Anchor.Top => (Anchor.TopRight, Anchor.TopLeft),
+            Anchor.Bottom => (Anchor.BottomLeft, Anchor.BottomRight),
+            _ => throw new ArgumentException($"{side} is not a side anchor"),
+        };
+    }
+
+    /// <summary>
+    /// The first corner would be on your left if you were standing on the passed corner and looking inside the shape; the second corner is to the right.
+    /// </summary>
+    public static (Anchor, Anchor) GetNeighboringCorners(Anchor corner)
+    {
+        return corner switch
+        {
+            Anchor.TopLeft => (Anchor.TopRight, Anchor.BottomLeft),
+            Anchor.TopRight => (Anchor.BottomRight, Anchor.TopLeft),
+            Anchor.BottomLeft => (Anchor.TopLeft, Anchor.BottomRight),
+            Anchor.BottomRight => (Anchor.BottomLeft, Anchor.TopRight),
+            _ => throw new ArgumentException($"{corner} is not a corner anchor"),
+        };
+    }
+
+    public static ShapeCorners UpdateCorner(ShapeCorners original, Anchor corner, VecD newPos)
+    {
+        if (corner == Anchor.TopLeft)
+            original.TopLeft = newPos;
+        else if (corner == Anchor.BottomLeft)
+            original.BottomLeft = newPos;
+        else if (corner == Anchor.TopRight)
+            original.TopRight = newPos;
+        else if (corner == Anchor.BottomRight)
+            original.BottomRight = newPos;
+        else
+            throw new ArgumentException($"{corner} is not a corner");
+        return original;
+    }
+
+    public static VecD GetAnchorPosition(ShapeCorners corners, Anchor anchor)
+    {
+        return anchor switch
+        {
+            Anchor.TopLeft => corners.TopLeft,
+            Anchor.BottomRight => corners.BottomRight,
+            Anchor.TopRight => corners.TopRight,
+            Anchor.BottomLeft => corners.BottomLeft,
+            Anchor.Top => corners.TopLeft.Lerp(corners.TopRight, 0.5),
+            Anchor.Bottom => corners.BottomLeft.Lerp(corners.BottomRight, 0.5),
+            Anchor.Left => corners.TopLeft.Lerp(corners.BottomLeft, 0.5),
+            Anchor.Right => corners.BottomRight.Lerp(corners.TopRight, 0.5),
+            _ => throw new ArgumentException($"{anchor} is not a corner or a side"),
+        };
+    }
+
+    public static Anchor? GetAnchorInPosition(VecD pos, ShapeCorners corners, VecD origin, double zoomboxScale, double sizeMult = 1)
+    {
+        VecD topLeft = corners.TopLeft;
+        VecD topRight = corners.TopRight;
+        VecD bottomLeft = corners.BottomLeft;
+        VecD bottomRight = corners.BottomRight;
+
+        // corners
+        if (IsWithinAnchor(topLeft, pos, zoomboxScale, sizeMult))
+            return Anchor.TopLeft;
+        if (IsWithinAnchor(topRight, pos, zoomboxScale, sizeMult))
+            return Anchor.TopRight;
+        if (IsWithinAnchor(bottomLeft, pos, zoomboxScale, sizeMult))
+            return Anchor.BottomLeft;
+        if (IsWithinAnchor(bottomRight, pos, zoomboxScale, sizeMult))
+            return Anchor.BottomRight;
+
+        // sides
+        if (IsWithinAnchor((bottomLeft - topLeft) / 2 + topLeft, pos, zoomboxScale, sizeMult))
+            return Anchor.Left;
+        if (IsWithinAnchor((bottomRight - topRight) / 2 + topRight, pos, zoomboxScale, sizeMult))
+            return Anchor.Right;
+        if (IsWithinAnchor((topLeft - topRight) / 2 + topRight, pos, zoomboxScale, sizeMult))
+            return Anchor.Top;
+        if (IsWithinAnchor((bottomLeft - bottomRight) / 2 + bottomRight, pos, zoomboxScale, sizeMult))
+            return Anchor.Bottom;
+
+        // origin
+        if (IsWithinAnchor(origin, pos, zoomboxScale, sizeMult))
+            return Anchor.Origin;
+        return null;
+    }
+
+    public static bool IsWithinAnchor(VecD anchorPos, VecD mousePos, double zoomboxScale, double sizeMult = 1)
+    {
+        var delta = (anchorPos - mousePos).Abs();
+        double scaled = AnchorSize * sizeMult / zoomboxScale / 2;
+        return delta.X < scaled && delta.Y < scaled;
+    }
+
+    public static bool IsWithinTransformHandle(VecD handlePos, VecD mousePos, double zoomboxScale)
+    {
+        var delta = (handlePos - mousePos).Abs();
+        double scaled = MoveHandleSize / zoomboxScale / 2;
+        return delta.X < scaled && delta.Y < scaled;
+    }
+
+    public static VecD GetDragHandlePos(ShapeCorners corners, double zoomboxScale)
+    {
+        VecD max = new(
+            Math.Max(Math.Max(corners.TopLeft.X, corners.TopRight.X), Math.Max(corners.BottomLeft.X, corners.BottomRight.X)),
+            Math.Max(Math.Max(corners.TopLeft.Y, corners.TopRight.Y), Math.Max(corners.BottomLeft.Y, corners.BottomRight.Y)));
+        return max + new VecD(MoveHandleSize / zoomboxScale, MoveHandleSize / zoomboxScale);
+    }
+}

+ 10 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformSideFreedom.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Views.UserControls.Overlays.TransformOverlay;
+#nullable enable
+internal enum TransformSideFreedom
+{
+    Locked,
+    ScaleProportionally,
+    Stretch,
+    Shear,
+    Free
+}

+ 22 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformState.cs

@@ -0,0 +1,22 @@
+using System.CodeDom;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+#nullable enable
+namespace PixiEditor.Views.UserControls.Overlays.TransformOverlay;
+internal struct TransformState
+{
+    public bool OriginWasManuallyDragged { get; set; }
+    public VecD Origin { get; set; }
+    public double ProportionalAngle1 { get; set; }
+    public double ProportionalAngle2 { get; set; }
+
+    public bool AlmostEquals(TransformState other, double epsilon = 0.001)
+    {
+        return
+            OriginWasManuallyDragged == other.OriginWasManuallyDragged &&
+            other.Origin.AlmostEquals(Origin, epsilon) &&
+            Math.Abs(ProportionalAngle1 - other.ProportionalAngle1) < epsilon &&
+            Math.Abs(ProportionalAngle2 - other.ProportionalAngle2) < epsilon;
+    }
+}

+ 221 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Overlays/TransformOverlay/TransformUpdateHelper.cs

@@ -0,0 +1,221 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Views.UserControls.Overlays.TransformOverlay;
+#nullable enable
+internal static class TransformUpdateHelper
+{
+    private const double epsilon = 0.00001;
+    public static ShapeCorners? UpdateShapeFromCorner
+        (Anchor targetCorner, TransformCornerFreedom freedom, double propAngle1, double propAngle2, ShapeCorners corners, VecD desiredPos)
+    {
+        if (!TransformHelper.IsCorner(targetCorner))
+            throw new ArgumentException($"{targetCorner} is not a corner");
+
+        if (freedom == TransformCornerFreedom.Locked)
+            return corners;
+
+        if (freedom is TransformCornerFreedom.ScaleProportionally or TransformCornerFreedom.Scale)
+        {
+            // find opposite corners
+            VecD targetPos = TransformHelper.GetAnchorPosition(corners, targetCorner);
+            Anchor opposite = TransformHelper.GetOpposite(targetCorner);
+            VecD oppositePos = TransformHelper.GetAnchorPosition(corners, opposite);
+
+            // constrain desired pos to a "propotional" diagonal line if needed
+            if (freedom == TransformCornerFreedom.ScaleProportionally)
+            {
+                double correctAngle = targetCorner is Anchor.TopLeft or Anchor.BottomRight ? propAngle1 : propAngle2;
+                desiredPos = desiredPos.ProjectOntoLine(oppositePos, oppositePos + VecD.FromAngleAndLength(correctAngle, 1));
+            }
+
+            // find neighboring corners
+            (Anchor leftNeighbor, Anchor rightNeighbor) = TransformHelper.GetNeighboringCorners(targetCorner);
+            VecD leftNeighborPos = TransformHelper.GetAnchorPosition(corners, leftNeighbor);
+            VecD rightNeighborPos = TransformHelper.GetAnchorPosition(corners, rightNeighbor);
+
+            double angle = corners.RectRotation;
+            if (double.IsNaN(angle))
+                angle = 0;
+
+            // find positions of neighboring corners relative to the opposite corner, while also undoing the transform rotation
+            VecD targetTrans = (targetPos - oppositePos).Rotate(-angle);
+            VecD leftNeighTrans = (leftNeighborPos - oppositePos).Rotate(-angle);
+            VecD rightNeighTrans = (rightNeighborPos - oppositePos).Rotate(-angle);
+
+            // find by how much move each corner
+            VecD delta = (desiredPos - targetPos).Rotate(-angle);
+            VecD leftNeighDelta, rightNeighDelta;
+            if (corners.IsPartiallyDegenerate)
+            {
+                // handle cases where we'd need to scale by infinity
+                leftNeighDelta = TransferZeros(leftNeighTrans, delta);
+                rightNeighDelta = TransferZeros(rightNeighTrans, delta);
+            }
+            else
+            {
+                // handle normal cases
+                VecD desiredTrans = (desiredPos - oppositePos).Rotate(-angle);
+                VecD scaling = desiredTrans.Divide(targetTrans);
+                leftNeighDelta = leftNeighTrans.Multiply(scaling) - leftNeighTrans;
+                rightNeighDelta = rightNeighTrans.Multiply(scaling) - rightNeighTrans;
+            }
+
+            // handle cases where the transform overlay is squished into a line or a single point
+            bool squishedWithLeft = leftNeighTrans.TaxicabLength < epsilon;
+            bool squishedWithRight = rightNeighTrans.TaxicabLength < epsilon;
+            if (squishedWithLeft && squishedWithRight)
+            {
+                leftNeighDelta = TransferZeros(new(0, 1), delta);
+                rightNeighDelta = TransferZeros(new(1, 0), delta);
+            }
+            else if (squishedWithLeft)
+            {
+                leftNeighDelta = TransferZeros(SwapAxes(rightNeighTrans), delta);
+            }
+            else if (squishedWithRight)
+            {
+                rightNeighDelta = TransferZeros(SwapAxes(leftNeighTrans), delta);
+            }
+            
+            // move the corners, while reapplying the transform rotation
+            corners = TransformHelper.UpdateCorner(corners, targetCorner,
+                (targetTrans + delta).Rotate(angle) + oppositePos);
+            corners = TransformHelper.UpdateCorner(corners, leftNeighbor,
+                (leftNeighTrans + leftNeighDelta).Rotate(angle) + oppositePos);
+            corners = TransformHelper.UpdateCorner(corners, rightNeighbor,
+                (rightNeighTrans + rightNeighDelta).Rotate(angle) + oppositePos);
+
+            return corners;
+        }
+
+        if (freedom == TransformCornerFreedom.Free)
+        {
+            ShapeCorners newCorners = TransformHelper.UpdateCorner(corners, targetCorner, desiredPos);
+            return newCorners.IsLegal ? newCorners : null;
+        }
+        throw new ArgumentException($"Freedom degree {freedom} is not supported");
+    }
+
+    private static VecD SwapAxes(VecD vec) => new VecD(vec.Y, vec.X);
+
+    private static VecD TransferZeros(VecD from, VecD to)
+    {
+        if (Math.Abs(from.X) < epsilon)
+            to.X = 0;
+        if (Math.Abs(from.Y) < epsilon)
+            to.Y = 0;
+        return to;
+    }
+
+    public static ShapeCorners? UpdateShapeFromSide
+        (Anchor targetSide, TransformSideFreedom freedom, double propAngle1, double propAngle2, ShapeCorners corners, VecD desiredPos)
+    {
+        if (!TransformHelper.IsSide(targetSide))
+            throw new ArgumentException($"{targetSide} is not a side");
+
+        if (freedom == TransformSideFreedom.Locked)
+            return corners;
+
+        if (freedom is TransformSideFreedom.ScaleProportionally)
+        {
+            var targetPos = TransformHelper.GetAnchorPosition(corners, targetSide);
+            var opposite = TransformHelper.GetOpposite(targetSide);
+            var oppositePos = TransformHelper.GetAnchorPosition(corners, opposite);
+
+            desiredPos = desiredPos.ProjectOntoLine(targetPos, oppositePos);
+
+            VecD thing = targetPos - oppositePos;
+            thing = VecD.FromAngleAndLength(thing.Angle, 1 / thing.Length);
+            double scalingFactor = (desiredPos - oppositePos) * thing;
+            if (!double.IsNormal(scalingFactor))
+                return corners;
+
+            if (corners.IsRect)
+            {
+                var delta = desiredPos - targetPos;
+                var center = oppositePos.Lerp(desiredPos, 0.5);
+
+                var (leftCorn, rightCorn) = TransformHelper.GetCornersOnSide(targetSide);
+                var (leftOppCorn, _) = TransformHelper.GetNeighboringCorners(leftCorn);
+                var (_, rightOppCorn) = TransformHelper.GetNeighboringCorners(rightCorn);
+
+                var leftCornPos = TransformHelper.GetAnchorPosition(corners, leftCorn);
+                var rightCornPos = TransformHelper.GetAnchorPosition(corners, rightCorn);
+                var leftOppCornPos = TransformHelper.GetAnchorPosition(corners, leftOppCorn);
+                var rightOppCornPos = TransformHelper.GetAnchorPosition(corners, rightOppCorn);
+
+                var (leftAngle, rightAngle) = leftCorn is Anchor.TopLeft or Anchor.BottomRight ? (propAngle1, propAngle2) : (propAngle2, propAngle1);
+
+                var updLeftCorn = TransformHelper.TwoLineIntersection(leftCornPos + delta, rightCornPos + delta, center, center + VecD.FromAngleAndLength(leftAngle, 1));
+                var updRightCorn = TransformHelper.TwoLineIntersection(leftCornPos + delta, rightCornPos + delta, center, center + VecD.FromAngleAndLength(rightAngle, 1));
+                var updLeftOppCorn = TransformHelper.TwoLineIntersection(leftOppCornPos, rightOppCornPos, center, center + VecD.FromAngleAndLength(rightAngle, 1));
+                var updRightOppCorn = TransformHelper.TwoLineIntersection(leftOppCornPos, rightOppCornPos, center, center + VecD.FromAngleAndLength(leftAngle, 1));
+
+                if (updLeftCorn is null || updRightCorn is null || updLeftOppCorn is null || updRightOppCorn is null)
+                    goto fallback;
+
+                corners = TransformHelper.UpdateCorner(corners, leftCorn, (VecD)updLeftCorn);
+                corners = TransformHelper.UpdateCorner(corners, rightCorn, (VecD)updRightCorn);
+                corners = TransformHelper.UpdateCorner(corners, leftOppCorn, (VecD)updLeftOppCorn);
+                corners = TransformHelper.UpdateCorner(corners, rightOppCorn, (VecD)updRightOppCorn);
+
+                return corners;
+            }
+fallback:
+            corners.TopLeft = (corners.TopLeft - oppositePos) * scalingFactor + oppositePos;
+            corners.BottomRight = (corners.BottomRight - oppositePos) * scalingFactor + oppositePos;
+            corners.TopRight = (corners.TopRight - oppositePos) * scalingFactor + oppositePos;
+            corners.BottomLeft = (corners.BottomLeft - oppositePos) * scalingFactor + oppositePos;
+
+            if (scalingFactor < 0)
+            {
+                corners.TopLeft = corners.TopLeft.ReflectAcrossLine(targetPos, oppositePos);
+                corners.BottomRight = corners.BottomRight.ReflectAcrossLine(targetPos, oppositePos);
+                corners.TopRight = corners.TopRight.ReflectAcrossLine(targetPos, oppositePos);
+                corners.BottomLeft = corners.BottomLeft.ReflectAcrossLine(targetPos, oppositePos);
+            }
+
+            return corners;
+        }
+
+        if (freedom is TransformSideFreedom.Shear or TransformSideFreedom.Stretch or TransformSideFreedom.Free)
+        {
+            var (leftCorner, rightCorner) = TransformHelper.GetCornersOnSide(targetSide);
+            var leftCornerPos = TransformHelper.GetAnchorPosition(corners, leftCorner);
+            var rightCornerPos = TransformHelper.GetAnchorPosition(corners, rightCorner);
+            var targetPos = TransformHelper.GetAnchorPosition(corners, targetSide);
+
+            var opposite = TransformHelper.GetOpposite(targetSide);
+            var oppPos = TransformHelper.GetAnchorPosition(corners, opposite);
+
+            if (freedom == TransformSideFreedom.Shear)
+            {
+                desiredPos = desiredPos.ProjectOntoLine(leftCornerPos, rightCornerPos);
+            }
+            else if (freedom == TransformSideFreedom.Stretch)
+            {
+                if ((targetPos - oppPos).TaxicabLength > epsilon)
+                    desiredPos = desiredPos.ProjectOntoLine(targetPos, oppPos);
+                else
+                    desiredPos = desiredPos.ProjectOntoLine(targetPos, (leftCornerPos - targetPos).Rotate(Math.PI / 2) + targetPos);
+            }
+
+            var delta = desiredPos - targetPos;
+            var newCorners = TransformHelper.UpdateCorner(corners, leftCorner, leftCornerPos + delta);
+            newCorners = TransformHelper.UpdateCorner(newCorners, rightCorner, rightCornerPos + delta);
+
+            return newCorners.IsLegal ? newCorners : null;
+        }
+        throw new ArgumentException($"Freedom degree {freedom} is not supported");
+    }
+
+    public static ShapeCorners UpdateShapeFromRotation(ShapeCorners corners, VecD origin, double angle)
+    {
+        corners.TopLeft = corners.TopLeft.Rotate(angle, origin);
+        corners.TopRight = corners.TopRight.Rotate(angle, origin);
+        corners.BottomLeft = corners.BottomLeft.Rotate(angle, origin);
+        corners.BottomRight = corners.BottomRight.Rotate(angle, origin);
+        return corners;
+    }
+}

+ 2 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Root/ReferenceLayerChangeInfos/SetReferenceLayer_ChangeInfo.cs

@@ -3,4 +3,5 @@ using PixiEditor.DrawingApi.Core.Numerics;
 
 
 namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
 namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
 
 
-public record class SetReferenceLayer_ChangeInfo(ImmutableArray<byte> ImagePbgra32Bytes, VecI ImageSize, ShapeCorners Shape) : IChangeInfo;
+// TODO: Make sure Pbgra8888 is all right
+public record class SetReferenceLayer_ChangeInfo(ImmutableArray<byte> ImagePbgra8888Bytes, VecI ImageSize, ShapeCorners Shape) : IChangeInfo;

+ 4 - 0
src/PixiEditor.OperatingSystem/IOperatingSystem.cs

@@ -6,6 +6,7 @@ public interface IOperatingSystem
     public string Name { get; }
     public string Name { get; }
 
 
     public IInputKeys InputKeys { get; }
     public IInputKeys InputKeys { get; }
+    public IProcessUtility ProcessUtility { get; }
 
 
     protected static void SetCurrent(IOperatingSystem operatingSystem)
     protected static void SetCurrent(IOperatingSystem operatingSystem)
     {
     {
@@ -16,4 +17,7 @@ public interface IOperatingSystem
 
 
         Current = operatingSystem;
         Current = operatingSystem;
     }
     }
+
+    public void OpenHyperlink(string url);
+    public void OpenFolder(string path);
 }
 }

Some files were not shown because too many files changed in this diff