Browse Source

aaaaaa, when does this madness end

Krzysztof Krysiński 2 years ago
parent
commit
02a7d95f59
63 changed files with 2932 additions and 155 deletions
  1. 80 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ClipboardHelper.cs
  2. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Constants/ClipboardDataFormats.cs
  3. 23 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/BitmapExtensions.cs
  4. 18 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/DirectoryExtensions.cs
  5. 14 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/LockedFramebufferExtensions.cs
  6. 14 9
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SupportedFilesHelper.cs
  7. 4 4
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SurfaceHelpers.cs
  8. 10 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/UI/ExecutionTrigger.cs
  9. 1 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/Command.cs
  10. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutsTemplate.cs
  11. 291 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/ClipboardController.cs
  12. 110 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/KeyboardInputFilter.cs
  13. 61 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/MouseInputFilter.cs
  14. 17 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/MouseOnCanvasEventArgs.cs
  15. 50 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/MouseUpdateController.cs
  16. 69 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/ShortcutController.cs
  17. 12 12
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentUpdater.cs
  18. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentEventsModule.cs
  19. 53 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/ExternalServices/LospecPaletteFetcher.cs
  20. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IStructureMemberHandler.cs
  21. 1 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ITransformHandler.cs
  22. 205 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/Exporter.cs
  23. 8 4
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/FileTypeDialogData.cs
  24. 5 3
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/FileTypeDialogDataSet.cs
  25. 175 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/Importer.cs
  26. 64 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/ClsFileParser.cs
  27. 96 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/GimpGplParser.cs
  28. 70 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/HexPaletteParser.cs
  29. 21 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/JascPalFile/JascFileException.cs
  30. 73 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/JascPalFile/JascFileParser.cs
  31. 81 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/PaintNetTxtParser.cs
  32. 43 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/PixiPaletteParser.cs
  33. 130 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/PngPaletteParser.cs
  34. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/SavingNotSupportedException.cs
  35. 349 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/LocalPalettesFetcher.cs
  36. 48 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/Palette.cs
  37. 12 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/PaletteFileType.cs
  38. 10 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/PaletteList.cs
  39. 26 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/PaletteObject.cs
  40. 10 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/RefreshType.cs
  41. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/SortingType.cs
  42. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/MemberPreviewUpdater.cs
  43. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/PixiEditor.Avalonia.csproj
  44. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentManagerViewModel.cs
  45. 1 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/TransformOverlays/DocumentTransformViewModel.cs
  46. 23 15
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ClipboardViewModel.cs
  47. 5 4
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ColorsViewModel.cs
  48. 32 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ExtensionsViewModel.cs
  49. 28 22
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/FileViewModel.cs
  50. 28 38
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/IoViewModel.cs
  51. 27 15
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/LayersViewModel.cs
  52. 60 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/RegistryViewModel.cs
  53. 55 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/SearchViewModel.cs
  54. 103 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/StylusViewModel.cs
  55. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ToolsViewModel.cs
  56. 56 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ViewportWindowViewModel.cs
  57. 190 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/WindowViewModel.cs
  58. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/ColorPickerToolViewModel.cs
  59. 5 5
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/LassoToolViewModel.cs
  60. 5 5
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/SelectToolViewModel.cs
  61. 21 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/ViewModelBase.cs
  62. 1 6
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/ViewModelMain.cs
  63. 1 0
      src/PixiEditor/ViewModels/SubViewModels/Main/StylusViewModel.cs

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

@@ -0,0 +1,80 @@
+using System.Threading.Tasks;
+using System.Windows;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Helpers;
+
+internal static class ClipboardHelper
+{
+    public static async Task<bool> TrySetDataObject(DataObject obj)
+    {
+        try
+        {
+            if(Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+            {
+                 await desktop.MainWindow.Clipboard.SetDataObjectAsync(obj);
+                 return true;
+            }
+
+            return false;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    public static async Task<object> TryGetDataObject(string format)
+    {
+        try
+        {
+            if(Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop){
+                return await desktop.MainWindow.Clipboard.GetDataAsync(format);
+            }
+
+            return null;
+        }
+        catch
+        {
+            return null;
+        }
+    }
+
+    public static async Task<bool> TryClear()
+    {
+        try
+        {
+            if(Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+            {
+                await desktop.MainWindow.Clipboard.ClearAsync();
+                return true;
+            }
+
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+    
+    public static VecI GetVecI(this DataObject data, string format)
+    {
+        if (!data.Contains(format))
+            return VecI.NegativeOne;
+
+        byte[] bytes = (byte[])data.Get(format);
+
+        if (bytes is { Length: < 8 })
+            return VecI.NegativeOne;
+
+        return VecI.FromBytes(bytes);
+    }
+
+    public static void SetVecI(this DataObject data, string format, VecI value) => data.Set(format, value.ToByteArray());
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Constants/ClipboardDataFormats.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Avalonia.Helpers;
+
+public static class ClipboardDataFormats
+{
+    public static string Dib = "DeviceIndependentBitmap";
+    public static string Bitmap = "Bitmap";
+    public static string Png = "PNG";
+}

+ 23 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/BitmapExtensions.cs

@@ -0,0 +1,23 @@
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media.Imaging;
+
+namespace PixiEditor.Avalonia.Helpers.Extensions;
+
+public static class BitmapExtensions
+{
+    public static byte[] ExtractPixels(this Bitmap source)
+    {
+        var size = source.PixelSize;
+        var stride = size.Width * 4;
+        int bufferSize = stride * size.Height;
+
+        byte[] target = new byte[bufferSize];
+
+        IntPtr ptr = Marshal.UnsafeAddrOfPinnedArrayElement(target, 0);
+
+        source.CopyPixels(new PixelRect(0, 0, size.Width, size.Height), ptr, bufferSize, stride);
+
+        return target;
+    }
+}

+ 18 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/Extensions/DirectoryExtensions.cs

@@ -0,0 +1,18 @@
+using System.Linq;
+
+namespace PixiEditor.Helpers.Extensions;
+
+internal static class DirectoryExtensions
+{
+    /// <summary>
+    ///     Gets files in directory with multiple filters.
+    /// </summary>
+    /// <param name="sourceFolder">Folder to get files from.</param>
+    /// <param name="filters">Filters separated by '|' character.</param>
+    /// <param name="searchOption">Search option for directory.</param>
+    /// <returns>List of file paths found.</returns>
+    public static string[] GetFiles(string sourceFolder, string filters, System.IO.SearchOption searchOption)
+    {
+        return filters.Split('|').SelectMany(filter => System.IO.Directory.GetFiles(sourceFolder, $"*{filter}", searchOption)).ToArray();
+    }
+}

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

@@ -51,4 +51,18 @@ public static class LockedFramebufferExtensions
             pbgra8888Bytes.AsSpan(srcRowStartIndex, endOffset - startOffset).CopyTo(pixels.Slice(startOffset));
         }
     }
+
+    public static void WritePixel(this ILockedFramebuffer framebuffer, int x, int y, Color color)
+    {
+        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;
+            zero[offset + 3] = color.A;
+            zero[offset + 2] = color.R;
+            zero[offset + 1] = color.G;
+            zero[offset] = color.B;
+        }
+    }
 }

+ 14 - 9
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SupportedFilesHelper.cs

@@ -1,6 +1,7 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
+using Avalonia.Platform.Storage;
 using PixiEditor.Models.Enums;
 using PixiEditor.Models.Files;
 using PixiEditor.Models.IO;
@@ -87,31 +88,35 @@ internal class SupportedFilesHelper
         return allExts;
     }
 
-    public static string BuildSaveFilter(bool includePixi)
+    public static List<FilePickerFileType> BuildSaveFilter(bool includePixi)
     {
         var allSupportedExtensions = GetAllSupportedFileTypes(includePixi);
-        var filter = string.Join("|", allSupportedExtensions.Select(i => i.SaveFilter));
+        var filter = allSupportedExtensions.Select(i => i.SaveFilter).ToList();
 
         return filter;
     }
 
-    public static FileType GetSaveFileTypeFromFilterIndex(bool includePixi, int filterIndex)
+    public static FileType GetSaveFileType(bool includePixi, IStorageFile file)
     {
         var allSupportedExtensions = GetAllSupportedFileTypes(includePixi);
-        //filter index starts at 1 for some reason
-        int index = filterIndex - 1;
-        if (allSupportedExtensions.Count <= index)
+
+        if (file is null)
             return FileType.Unset;
-        return allSupportedExtensions[index].FileType;
+
+        string extension = Path.GetExtension(file.Path.AbsolutePath);
+        return allSupportedExtensions.Single(i => i.Extensions.Contains(extension)).FileType;
     }
 
-    public static string BuildOpenFilter()
+    public static List<FilePickerFileType> BuildOpenFilter()
     {
         var any = new FileTypeDialogDataSet(FileTypeDialogDataSet.SetKind.Any).GetFormattedTypes();
         var pixi = new FileTypeDialogDataSet(FileTypeDialogDataSet.SetKind.Pixi).GetFormattedTypes();
         var images = new FileTypeDialogDataSet(FileTypeDialogDataSet.SetKind.Images).GetFormattedTypes();
 
-        var filter = any + "|" + pixi + "|" + images;
+        var filter = new List<FilePickerFileType>();
+        filter.AddRange(any);
+        filter.AddRange(pixi);
+        filter.AddRange(images);
         return filter;
     }
 }

+ 4 - 4
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/SurfaceHelpers.cs

@@ -16,14 +16,14 @@ 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)
+        if (original.PixelSize.Width <= 0 || original.PixelSize.Height <= 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];
+        int stride = (original.PixelSize.Width * original.Format.BitsPerPixel + 7) / 8;
+        byte[] pixels = new byte[stride * original.PixelSize.Height];
         original.CopyPixels(pixels, stride, 0);
 
-        Surface surface = new Surface(new VecI(original.PixelWidth, original.PixelHeight));
+        Surface surface = new Surface(new VecI(original.PixelSize.Width, original.PixelSize.Height));
         surface.DrawBytes(surface.Size, pixels, color, alpha);
         return surface;
     }*/

+ 10 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/UI/ExecutionTrigger.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Helpers;
+
+internal class ExecutionTrigger<T>
+{
+    public event EventHandler<T> Triggered;
+    public void Execute(object sender, T args)
+    {
+        Triggered?.Invoke(sender, args);
+    }
+}

+ 1 - 1
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/Command.cs

@@ -49,7 +49,7 @@ internal abstract partial class Command : ObservableObject
     {
         Methods = new(this, onExecute, canExecute);
         ILocalizationProvider.Current.OnLanguageChanged += OnLanguageChanged;
-        /*InputLanguageManager.Current.InputLanguageChanged += (_, _) => this.RaisePropertyChanged(nameof(Shortcut)); TODO: Didn't find implementation of this in Avalonia*/
+        /*InputLanguageManager.Current.InputLanguageChanged += (_, _) => this.OnPropertyChanged(nameof(Shortcut)); TODO: Didn't find implementation of this in Avalonia*/
     }
 
     private void OnLanguageChanged(Language obj)

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutsTemplate.cs

@@ -50,9 +50,9 @@ public sealed class Shortcut
         Commands = new List<string> { command };
     }
     
-    public Shortcut(Key key, KeyModifiers modifierKeys, string command)
+    public Shortcut(Key key, KeyModifiers KeyModifiers, string command)
     {
-        KeyCombination = new KeyCombination(key, modifierKeys);
+        KeyCombination = new KeyCombination(key, KeyModifiers);
         Commands = new List<string> { command };
     }
 }

+ 291 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/ClipboardController.cs

@@ -0,0 +1,291 @@
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Avalonia.Input;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using ChunkyImageLib;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Parser;
+using PixiEditor.Parser.Deprecated;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.Models.Controllers;
+
+#nullable enable
+internal static class ClipboardController
+{
+    private const string PositionFormat = "PixiEditor.Position";
+    
+    public static readonly string TempCopyFilePath = Path.Join(
+        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+        "PixiEditor",
+        "Copied.png");
+
+    /// <summary>
+    ///     Copies the selection to clipboard in PNG, Bitmap and DIB formats.
+    /// </summary>
+    public static async Task CopyToClipboard(DocumentViewModel document)
+    {
+        if (!(await ClipboardHelper.TryClear()))
+            return;
+
+        var surface = document.MaybeExtractSelectedArea();
+        if (surface.IsT0)
+            return;
+        if (surface.IsT1)
+        {
+            NoticeDialog.Show("SELECTED_AREA_EMPTY", "NOTHING_TO_COPY");
+            return;
+        }
+
+        var (actuallySurface, area) = surface.AsT2;
+        DataObject data = new DataObject();
+
+        using (ImgData pngData = actuallySurface.DrawingSurface.Snapshot().Encode())
+        {
+            // Stream should not be disposed
+            MemoryStream pngStream = new MemoryStream();
+            await pngData.AsStream().CopyToAsync(pngStream);
+
+            data.Set(ClipboardDataFormats.Png, pngStream); // PNG, supports transparency
+
+            pngStream.Position = 0;
+            Directory.CreateDirectory(Path.GetDirectoryName(TempCopyFilePath)!);
+            using FileStream fileStream = new FileStream(TempCopyFilePath, FileMode.Create, FileAccess.Write);
+            await pngStream.CopyToAsync(fileStream);
+            data.SetFileDropList(new StringCollection() { TempCopyFilePath });
+        }
+
+        WriteableBitmap finalBitmap = actuallySurface.ToWriteableBitmap();
+        data.Set(ClipboardDataFormats.Bitmap, finalBitmap); // Bitmap, no transparency
+        data.Set(ClipboardDataFormats.Dib, finalBitmap); // DIB format, no transparency
+
+        if (area.Size != document.SizeBindable && area.Pos != VecI.Zero)
+        {
+            data.SetVecI(PositionFormat, area.Pos);
+        }
+
+        await ClipboardHelper.TrySetDataObject(data);
+    }
+
+    /// <summary>
+    ///     Pastes image from clipboard into new layer.
+    /// </summary>
+    public static bool TryPaste(DocumentViewModel document, DataObject data, bool pasteAsNew = false)
+    {
+        List<DataImage> images = GetImage(data);
+        if (images.Count == 0)
+            return false;
+
+        if (images.Count == 1)
+        {
+            var dataImage = images[0];
+            var position = dataImage.position;
+
+            if (document.SizeBindable.X < position.X || document.SizeBindable.Y < position.Y)
+            {
+                position = VecI.Zero;
+            }
+
+            if (pasteAsNew)
+            {
+                var guid = document.Operations.CreateStructureMember(StructureMemberType.Layer, "New Layer", false);
+
+                if (guid == null)
+                {
+                    return false;
+                }
+
+                document.Operations.SetSelectedMember(guid.Value);
+                document.Operations.PasteImageWithTransform(dataImage.image, position, guid.Value, false);
+            }
+            else
+            {
+                document.Operations.PasteImageWithTransform(dataImage.image, position);
+            }
+
+            return true;
+        }
+
+        document.Operations.PasteImagesAsLayers(images);
+        return true;
+    }
+
+    /// <summary>
+    ///     Pastes image from clipboard into new layer.
+    /// </summary>
+    public static bool TryPasteFromClipboard(DocumentViewModel document, bool pasteAsNew = false) =>
+        TryPaste(document, ClipboardHelper.TryGetDataObject(), pasteAsNew);
+
+    public static List<DataImage> GetImagesFromClipboard() => GetImage(ClipboardHelper.TryGetDataObject());
+
+    /// <summary>
+    /// Gets images from clipboard, supported PNG, Dib and Bitmap.
+    /// </summary>
+    public static List<DataImage> GetImage(DataObject? data)
+    {
+        List<DataImage> surfaces = new();
+
+        if (data == null)
+            return surfaces;
+
+        if (TryExtractSingleImage(data, out var singleImage))
+        {
+            surfaces.Add(new DataImage(singleImage, data.GetVecI(PositionFormat)));
+            return surfaces;
+        }
+
+        if (!data.GetDataPresent(DataFormats.FileDrop))
+        {
+            return surfaces;
+        }
+
+        foreach (string? path in data.GetFileDropList())
+        {
+            if (path is null || !Importer.IsSupportedFile(path))
+                continue;
+            try
+            {
+                Surface imported;
+
+                if (Path.GetExtension(path) == ".pixi")
+                {
+                    using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
+
+                    try
+                    {
+                        imported = Surface.Load(PixiParser.Deserialize(path).PreviewImage);
+                    }
+                    catch (InvalidFileException e)
+                    {
+                        // Check if it could be a old file
+                        if (!e.Message.StartsWith("Header"))
+                        {
+                            throw;
+                        }
+
+                        stream.Position = 0;
+                        using var bitmap = DepractedPixiParser.Deserialize(stream).RenderOldDocument();
+                        var size = new VecI(bitmap.Width, bitmap.Height);
+                        imported = new Surface(size);
+                        imported.DrawBytes(size, bitmap.Bytes, ColorType.RgbaF32, AlphaType.Premul);
+
+                        System.Diagnostics.Debug.Write(imported.ToString());
+                    }
+                }
+                else
+                {
+                    imported = Surface.Load(path);
+                }
+
+                string filename = Path.GetFullPath(path);
+                surfaces.Add(new DataImage(filename, imported, data.GetVecI(PositionFormat)));
+            }
+            catch
+            {
+                continue;
+            }
+        }
+
+        return surfaces;
+    }
+
+    [Evaluator.CanExecute("PixiEditor.Clipboard.HasImageInClipboard")]
+    public static bool IsImageInClipboard() => IsImage(ClipboardHelper.TryGetDataObject());
+
+    public static bool IsImage(DataObject? dataObject)
+    {
+        if (dataObject == null)
+            return false;
+
+        try
+        {
+            var files = dataObject.GetFileDropList();
+            if (files != null)
+            {
+                foreach (var file in files)
+                {
+                    if (Importer.IsSupportedFile(file))
+                    {
+                        return true;
+                    }
+                }
+            }
+        }
+        catch (COMException)
+        {
+            return false;
+        }
+
+        return HasData(dataObject, "PNG", DataFormats.Dib, DataFormats.Bitmap);
+    }
+
+    private static BitmapSource FromPNG(DataObject data)
+    {
+        MemoryStream pngStream = (MemoryStream)data.GetData("PNG");
+        PngBitmapDecoder decoder = new PngBitmapDecoder(pngStream, BitmapCreateOptions.IgnoreImageCache, BitmapCacheOption.OnLoad);
+
+        return decoder.Frames[0];
+    }
+
+    private static bool HasData(DataObject dataObject, params string[] formats) => formats.Any(dataObject.GetDataPresent);
+    
+    private static bool TryExtractSingleImage(DataObject data, [NotNullWhen(true)] out Surface? result)
+    {
+        try
+        {
+            BitmapSource source;
+
+            if (data.GetDataPresent("PNG"))
+            {
+                source = FromPNG(data);
+            }
+            else if (HasData(data, DataFormats.Dib, DataFormats.Bitmap))
+            {
+                source = Clipboard.GetImage();
+            }
+            else
+            {
+                result = null;
+                return false;
+            }
+
+            if (source.Format.IsSkiaSupported())
+            {
+                result = SurfaceHelpers.FromBitmapSource(source);
+            }
+            else
+            {
+                FormatConvertedBitmap newFormat = new FormatConvertedBitmap();
+                newFormat.BeginInit();
+                newFormat.Source = source;
+                newFormat.DestinationFormat = PixelFormats.Bgra32;
+                newFormat.EndInit();
+
+                result = SurfaceHelpers.FromBitmapSource(newFormat);
+            }
+
+            return true;
+        }
+        catch { }
+
+        result = null;
+        return false;
+    }
+
+    public record struct DataImage(string? name, Surface image, VecI position)
+    {
+        public DataImage(Surface image, VecI position) : this(null, image, position) { }
+    }
+}

+ 110 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/KeyboardInputFilter.cs

@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.Events;
+
+namespace PixiEditor.Models.Controllers;
+#nullable enable
+internal class KeyboardInputFilter
+{
+    /// <summary> Works like a regular keydown event, but filtered </summary>
+    public EventHandler<FilteredKeyEventArgs>? OnAnyKeyDown;
+
+    /// <summary> Works like a regular keydown event, but filtered </summary>
+    public EventHandler<FilteredKeyEventArgs>? OnAnyKeyUp;
+
+    /// <summary> Ignores duplicate modifier keys </summary>
+    public EventHandler<FilteredKeyEventArgs>? OnConvertedKeyDown;
+
+    /// <summary> Ignores duplicate modifier keys </summary>
+    public EventHandler<FilteredKeyEventArgs>? OnConvertedKeyUp;
+
+    private Dictionary<Key, KeyStates> keyboardState = new();
+    private Dictionary<Key, KeyStates> converterdKeyboardState = new();
+
+    private static bool UpdateKeyState(Key key, KeyStates state, Dictionary<Key, KeyStates> keyboardState)
+    {
+        if (!keyboardState.ContainsKey(key))
+        {
+            keyboardState.Add(key, state);
+            return true;
+        }
+        bool result = keyboardState[key] != state;
+        keyboardState[key] = state;
+        return result;
+    }
+
+    private Key ConvertRightKeys(Key key)
+    {
+        if (key == Key.RightAlt)
+            return Key.LeftAlt;
+        if (key == Key.RightCtrl)
+            return Key.LeftCtrl;
+        if (key == Key.RightShift)
+            return Key.LeftShift;
+        return key;
+    }
+
+    public void DeactivatedInlet(object? sender, EventArgs e)
+    {
+        foreach (var (key, state) in keyboardState)
+        {
+            if (state != KeyStates.Down)
+                continue;
+
+            UpdateKeyState(key, KeyStates.None, keyboardState);
+            Key convKey = ConvertRightKeys(key);
+            bool raiseConverted = UpdateKeyState(convKey, KeyStates.None, converterdKeyboardState);
+
+            var (shift, ctrl, alt) = GetModifierStates();
+            OnAnyKeyUp?.Invoke(this, new FilteredKeyEventArgs(key, key, KeyStates.None, false, shift, ctrl, alt));
+            if (raiseConverted)
+                OnConvertedKeyUp?.Invoke(this, new FilteredKeyEventArgs(key, key, KeyStates.None, false, shift, ctrl, alt));
+        }
+    }
+
+    private (bool shift, bool ctrl, bool alt) GetModifierStates()
+    {
+        bool shift = converterdKeyboardState.TryGetValue(Key.LeftShift, out KeyStates shiftKey) ? shiftKey == KeyStates.Down : false;
+        bool ctrl = converterdKeyboardState.TryGetValue(Key.LeftCtrl, out KeyStates ctrlKey) ? ctrlKey == KeyStates.Down : false;
+        bool alt = converterdKeyboardState.TryGetValue(Key.LeftAlt, out KeyStates altKey) ? altKey == KeyStates.Down : false;
+        return (shift, ctrl, alt);
+    }
+
+    public void KeyDownInlet(KeyEventArgs args)
+    {
+        Key key = args.Key;
+        /*if (key == Key.System) TODO: Validate if this is not needed
+            key = args.SystemKey;*/
+
+        MaybeUpdateKeyState(key, args.IsRepeat, KeyStates.Down, keyboardState, OnAnyKeyDown);
+        key = ConvertRightKeys(key);
+        MaybeUpdateKeyState(key, args.IsRepeat, KeyStates.Down, converterdKeyboardState, OnConvertedKeyDown);
+    }
+
+    public void KeyUpInlet(KeyEventArgs args)
+    {
+        Key key = args.Key;
+        /*if (key == Key.System) TODO: Validate if this is not needed
+            key = args.SystemKey;*/
+
+        MaybeUpdateKeyState(key, args.IsRepeat, KeyStates.None, keyboardState, OnAnyKeyUp);
+        key = ConvertRightKeys(key);
+        MaybeUpdateKeyState(key, args.IsRepeat, KeyStates.None, converterdKeyboardState, OnConvertedKeyUp);
+    }
+
+    private void MaybeUpdateKeyState(
+        Key key,
+        bool isRepeatFromArgs,
+        KeyStates newKeyState,
+        Dictionary<Key, KeyStates> targetKeyboardState,
+        EventHandler<FilteredKeyEventArgs>? eventToRaise)
+    {
+        bool keyWasUpdated = UpdateKeyState(key, newKeyState, targetKeyboardState);
+        bool isRepeat = isRepeatFromArgs && !keyWasUpdated;
+        if (!isRepeat && !keyWasUpdated)
+            return;
+        var (shift, ctrl, alt) = GetModifierStates();
+        eventToRaise?.Invoke(this, new FilteredKeyEventArgs(key, key, newKeyState, isRepeat, shift, ctrl, alt));
+    }
+}

+ 61 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/MouseInputFilter.cs

@@ -0,0 +1,61 @@
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Events;
+
+namespace PixiEditor.Models.Controllers;
+#nullable enable
+internal class MouseInputFilter
+{
+    public EventHandler<MouseOnCanvasEventArgs> OnMouseDown;
+    public EventHandler<VecD> OnMouseMove;
+    public EventHandler<MouseButton> OnMouseUp;
+
+
+    private Dictionary<MouseButton, bool> buttonStates = new()
+    {
+        [MouseButton.Left] = false,
+        [MouseButton.Right] = false,
+        [MouseButton.Middle] = false,
+    };
+
+    public void MouseDownInlet(object args) => MouseDownInlet((MouseOnCanvasEventArgs)args);
+    public void MouseDownInlet(MouseOnCanvasEventArgs args)
+    {
+        var button = args.Button;
+
+        if (button is MouseButton.XButton1 or MouseButton.XButton2)
+            return;
+        if (buttonStates[button])
+            return;
+        buttonStates[button] = true;
+
+        OnMouseDown?.Invoke(this, args);
+    }
+
+    public void MouseMoveInlet(object args) => OnMouseMove?.Invoke(this, (VecD)args);
+
+    public void MouseUpInlet(object args) => MouseUpInlet((MouseButton)args);
+    public void MouseUpInlet(object? sender, Point p, MouseButton button) => MouseUpInlet(button);
+    public void MouseUpInlet(MouseButton button)
+    {
+        if (button is MouseButton.XButton1 or MouseButton.XButton2)
+            return;
+        if (!buttonStates[button])
+            return;
+        buttonStates[button] = false;
+
+        OnMouseUp?.Invoke(this, button);
+    }
+
+    public void DeactivatedInlet(object? sender, EventArgs e)
+    {
+        MouseUpInlet(MouseButton.Left);
+        MouseUpInlet(MouseButton.Middle);
+        MouseUpInlet(MouseButton.Right);
+    }
+}

+ 17 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/MouseOnCanvasEventArgs.cs

@@ -0,0 +1,17 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Models.Events;
+internal class MouseOnCanvasEventArgs : EventArgs
+{
+    public MouseOnCanvasEventArgs(MouseButton button, VecD positionOnCanvas)
+    {
+        Button = button;
+        PositionOnCanvas = positionOnCanvas;
+    }
+
+    public MouseButton Button { get; }
+    public VecD PositionOnCanvas { get; }
+}

+ 50 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/InputDevice/MouseUpdateController.cs

@@ -0,0 +1,50 @@
+using System.Timers;
+using System.Windows;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace PixiEditor.Models.Controllers;
+
+public class MouseUpdateController : IDisposable
+{
+    private const int MouseUpdateIntervalMs = 7;  // 7ms ~= 142 Hz
+    
+    private readonly System.Timers.Timer _timer;
+    
+    private InputElement element;
+    
+    private Action mouseMoveHandler;
+    
+    public MouseUpdateController(InputElement uiElement, Action onMouseMove)
+    {
+        mouseMoveHandler = onMouseMove;
+        element = uiElement;
+        
+        _timer = new System.Timers.Timer(MouseUpdateIntervalMs);
+        _timer.AutoReset = true;
+        _timer.Elapsed += TimerOnElapsed;
+        
+        element.AddHandler(InputElement.PointerMovedEvent, OnMouseMove);
+    }
+
+    private void TimerOnElapsed(object sender, ElapsedEventArgs e)
+    {
+        _timer.Stop();
+        element.AddHandler(InputElement.PointerMovedEvent, OnMouseMove);
+    }
+
+    private void OnMouseMove(object sender, PointerEventArgs e)
+    {
+        element.RemoveHandler(InputElement.PointerMovedEvent, OnMouseMove);
+        _timer.Start();
+        mouseMoveHandler();
+    }
+
+    public void Dispose()
+    {
+        _timer.Dispose();
+        element.RemoveHandler(InputElement.PointerMovedEvent, OnMouseMove);
+    }
+}

+ 69 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Controllers/ShortcutController.cs

@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Input;
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Commands;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.ViewModels.SubViewModels.Tools;
+
+namespace PixiEditor.Models.Controllers;
+
+internal class ShortcutController
+{
+    public static bool ShortcutExecutionBlocked => _shortcutExecutionBlockers.Count > 0;
+
+    private static readonly List<string> _shortcutExecutionBlockers = new List<string>();
+
+    public IEnumerable<Command> LastCommands { get; private set; }
+
+    public Dictionary<KeyCombination, ToolViewModel> TransientShortcuts { get; set; } = new();
+
+    public static void BlockShortcutExecution(string blocker)
+    {
+        if (_shortcutExecutionBlockers.Contains(blocker)) return;
+        _shortcutExecutionBlockers.Add(blocker);
+    }
+
+    public static void UnblockShortcutExecution(string blocker)
+    {
+        if (!_shortcutExecutionBlockers.Contains(blocker)) return;
+        _shortcutExecutionBlockers.Remove(blocker);
+    }
+
+    public static void UnblockShortcutExecutionAll()
+    {
+        _shortcutExecutionBlockers.Clear();
+    }
+
+    public KeyCombination GetToolShortcut<T>()
+    {
+        return GetToolShortcut(typeof(T));
+    }
+
+    public KeyCombination GetToolShortcut(Type type)
+    {
+        return CommandController.Current.Commands.First(x => x is Command.ToolCommand tool && tool.ToolType == type).Shortcut;
+    }
+
+    public void KeyPressed(Key key, KeyModifiers modifiers)
+    {
+        KeyCombination shortcut = new(key, modifiers);
+
+        if (!ShortcutExecutionBlocked)
+        {
+            var commands = CommandController.Current.Commands[shortcut];
+
+            if (!commands.Any())
+            {
+                return;
+            }
+
+            LastCommands = commands;
+
+            foreach (var command in CommandController.Current.Commands[shortcut])
+            {
+                command.Execute();
+            }
+        }
+    }
+}

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

@@ -34,7 +34,7 @@ internal class DocumentUpdater
     public void AfterUndoBoundaryPassed()
     {
         //TODO: Make sure AllChangesSaved trigger raise property changed itself
-        //doc.RaisePropertyChanged(nameof(doc.AllChangesSaved));
+        //doc.OnPropertyChanged(nameof(doc.AllChangesSaved));
     }
 
     /// <summary>
@@ -163,7 +163,7 @@ internal class DocumentUpdater
             return;
         member.Selection = StructureMemberSelectionType.None;
         // TODO: Make sure Selection raises property changed internally
-        //member.RaisePropertyChanged(nameof(member.Selection));
+        //member.OnPropertyChanged(nameof(member.Selection));
         doc.RemoveSoftSelectedMember(member);
     }
 
@@ -174,7 +174,7 @@ internal class DocumentUpdater
             if (oldMember.Selection == StructureMemberSelectionType.Hard)
                 continue;
             oldMember.Selection = StructureMemberSelectionType.None;
-            //oldMember.RaisePropertyChanged(nameof(oldMember.Selection));
+            //oldMember.OnPropertyChanged(nameof(oldMember.Selection));
         }
         doc.ClearSoftSelectedMembers();
     }
@@ -185,7 +185,7 @@ internal class DocumentUpdater
         if (member is null || member.Selection == StructureMemberSelectionType.Hard)
             return;
         member.Selection = StructureMemberSelectionType.Soft;
-        //member.RaisePropertyChanged(nameof(member.Selection));
+        //member.OnPropertyChanged(nameof(member.Selection));
         doc.AddSoftSelectedMember(member);
     }
 
@@ -194,11 +194,11 @@ internal class DocumentUpdater
         if (doc.SelectedStructureMember is { } oldMember)
         {
             oldMember.Selection = StructureMemberSelectionType.None;
-            //oldMember.RaisePropertyChanged(nameof(oldMember.Selection));
+            //oldMember.OnPropertyChanged(nameof(oldMember.Selection));
         }
         IStructureMemberHandler? member = doc.StructureHelper.FindOrThrow(info.GuidValue);
         member.Selection = StructureMemberSelectionType.Hard;
-        //member.RaisePropertyChanged(nameof(member.Selection));
+        //member.OnPropertyChanged(nameof(member.Selection));
         doc.SetSelectedMember(member);
     }
 
@@ -253,7 +253,7 @@ internal class DocumentUpdater
 
         memberVm.SetHasMask(info.HasMask);
         // TODO: Make sure HasMask raises property changed internally
-        //memberVm.RaisePropertyChanged(nameof(memberVm.MaskPreviewBitmap));
+        //memberVm.OnPropertyChanged(nameof(memberVm.MaskPreviewBitmap));
         if (!info.HasMask && memberVm is ILayerHandler layer)
             layer.ShouldDrawOnMask = false;
     }
@@ -292,8 +292,8 @@ internal class DocumentUpdater
         doc.PreviewSurface = WriteableBitmapHelpers.CreateDrawingSurface(doc.PreviewBitmap);
 
         // TODO: Make sure property changed events are raised internally
-        /*doc.RaisePropertyChanged(nameof(doc.LazyBitmaps));
-        doc.RaisePropertyChanged(nameof(doc.PreviewBitmap));*/
+        /*doc.OnPropertyChanged(nameof(doc.LazyBitmaps));
+        doc.OnPropertyChanged(nameof(doc.PreviewBitmap));*/
 
         //doc.InternalRaiseSizeChanged(new(doc, oldSize, info.Size));
     }
@@ -339,15 +339,15 @@ internal class DocumentUpdater
         {
             doc.SelectedStructureMember.Selection = StructureMemberSelectionType.None;
             // TODO: Make sure property changed events are raised internally
-            //doc.SelectedStructureMember.RaisePropertyChanged(nameof(doc.SelectedStructureMember.Selection));
+            //doc.SelectedStructureMember.OnPropertyChanged(nameof(doc.SelectedStructureMember.Selection));
         }
 
         doc.SetSelectedMember(memberVM);
         memberVM.Selection = StructureMemberSelectionType.Hard;
 
         // TODO: Make sure property changed events are raised internally
-        /*doc.RaisePropertyChanged(nameof(doc.SelectedStructureMember));
-        doc.RaisePropertyChanged(nameof(memberVM.Selection));*/
+        /*doc.OnPropertyChanged(nameof(doc.SelectedStructureMember));
+        doc.OnPropertyChanged(nameof(memberVM.Selection));*/
 
         //doc.InternalRaiseLayersChanged(new LayersChangedEventArgs(info.GuidValue, LayerAction.Add));
     }

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

@@ -23,12 +23,12 @@ internal class DocumentEventsModule
     public void OnConvertedKeyDown(FilteredKeyEventArgs args)
     {
         Internals.ChangeController.ConvertedKeyDownInlet(args.Key);
-        DocumentsHandler.TransformHandler.ModifierKeysInlet(args.IsShiftDown, args.IsCtrlDown, args.IsAltDown);
+        DocumentsHandler.TransformHandler.KeyModifiersInlet(args.IsShiftDown, args.IsCtrlDown, args.IsAltDown);
     }
     public void OnConvertedKeyUp(FilteredKeyEventArgs args)
     {
         Internals.ChangeController.ConvertedKeyUpInlet(args.Key);
-        DocumentsHandler.TransformHandler.ModifierKeysInlet(args.IsShiftDown, args.IsCtrlDown, args.IsAltDown);
+        DocumentsHandler.TransformHandler.KeyModifiersInlet(args.IsShiftDown, args.IsCtrlDown, args.IsAltDown);
     }
 
     public void OnCanvasLeftMouseButtonDown(VecD pos) => Internals.ChangeController.LeftMouseButtonDownInlet(pos);

+ 53 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/ExternalServices/LospecPaletteFetcher.cs

@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Models.DataHolders.Palettes;
+using PixiEditor.Models.Dialogs;
+
+namespace PixiEditor.Models.ExternalServices;
+
+internal static class LospecPaletteFetcher
+{
+    public const string LospecApiUrl = "https://lospec.com/palette-list";
+
+    public static async Task<Palette> FetchPalette(string slug)
+    {
+        try
+        {
+            using HttpClient client = new HttpClient();
+            string url = @$"{LospecApiUrl}/{slug}.json";
+
+            HttpResponseMessage response = await client.GetAsync(url);
+            if (response.StatusCode == HttpStatusCode.OK)
+            {
+                string content = await response.Content.ReadAsStringAsync();
+                var obj = JsonConvert.DeserializeObject<PaletteObject>(content);
+
+                if (obj is { Colors: not null })
+                {
+                    ReadjustColors(obj.Colors);
+                }
+
+                return obj.ToPalette();
+            }
+        }
+        catch (HttpRequestException)
+        {
+            NoticeDialog.Show("FAILED_DOWNLOAD_PALETTE", "ERROR");
+            return null;
+        }
+
+        return null;
+    }
+
+    private static void ReadjustColors(List<string> colors)
+    {
+        for (int i = 0; i < colors.Count; i++)
+        {
+            colors[i] = colors[i].Insert(0, "#");
+        }
+    }
+}

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

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

+ 1 - 1
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ITransformHandler.cs

@@ -6,7 +6,7 @@ namespace PixiEditor.Models.Containers;
 
 internal interface ITransformHandler : IHandler
 {
-    public void ModifierKeysInlet(bool argsIsShiftDown, bool argsIsCtrlDown, bool argsIsAltDown);
+    public void KeyModifiersInlet(bool argsIsShiftDown, bool argsIsCtrlDown, bool argsIsAltDown);
     public void ShowTransform(DocumentTransformMode transformMode, bool b, ShapeCorners shapeCorners, bool b1);
     public void HideTransform();
     public void Undo();

+ 205 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/Exporter.cs

@@ -0,0 +1,205 @@
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Runtime.InteropServices;
+using System.Security;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Platform.Storage;
+using ChunkyImageLib;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Files;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.Models.IO;
+
+internal enum DialogSaveResult
+{
+    Success = 0,
+    InvalidPath = 1,
+    ConcurrencyError = 2,
+    SecurityError = 3,
+    IoError = 4,
+    UnknownError = 5,
+    Cancelled = 6,
+}
+
+internal enum SaveResult
+{
+    Success = 0,
+    InvalidPath = 1,
+    ConcurrencyError = 2,
+    SecurityError = 3,
+    IoError = 4,
+    UnknownError = 5,
+}
+
+internal class ExporterResult
+{
+    public DialogSaveResult Result { get; set; }
+    public string Path { get; set; }
+
+    public ExporterResult(DialogSaveResult result, string path)
+    {
+        Result = result;
+        Path = path;
+    }
+}
+
+internal class Exporter
+{
+    /// <summary>
+    /// Attempts to save file using a SaveFileDialog
+    /// </summary>
+    public static async Task<ExporterResult> TrySaveWithDialog(DocumentViewModel document, VecI? exportSize = null)
+    {
+        ExporterResult result = new(DialogSaveResult.UnknownError, null);
+
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+        {
+            var file = await desktop.MainWindow.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+            {
+                FileTypeChoices = SupportedFilesHelper.BuildSaveFilter(true), DefaultExtension = "pixi"
+            });
+
+            if (file is null)
+            {
+                result.Result = DialogSaveResult.Cancelled;
+                return result;
+            }
+
+            var fileType = SupportedFilesHelper.GetSaveFileType(true, file);
+
+            var saveResult = TrySaveUsingDataFromDialog(document, file.Path.AbsolutePath, fileType, out string fixedPath, exportSize);
+            if (saveResult == SaveResult.Success)
+            {
+                result.Path = fixedPath;
+            }
+
+            result.Result = (DialogSaveResult)saveResult;
+        }
+
+        return result;
+    }
+
+    /// <summary>
+    /// Takes data as returned by SaveFileDialog and attempts to use it to save the document
+    /// </summary>
+    public static SaveResult TrySaveUsingDataFromDialog(DocumentViewModel document, string pathFromDialog, FileType fileTypeFromDialog, out string finalPath, VecI? exportSize = null)
+    {
+        finalPath = SupportedFilesHelper.FixFileExtension(pathFromDialog, fileTypeFromDialog);
+        var saveResult = TrySave(document, finalPath, exportSize);
+        if (saveResult != SaveResult.Success)
+            finalPath = "";
+
+        return saveResult;
+    }
+
+    /// <summary>
+    /// Attempts to save the document into the given location, filetype is inferred from path
+    /// </summary>
+    public static SaveResult TrySave(DocumentViewModel document, string pathWithExtension, VecI? exportSize = null)
+    {
+        string directory = Path.GetDirectoryName(pathWithExtension);
+        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+            return SaveResult.InvalidPath;
+
+        var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithExtension));
+
+        if (typeFromPath != FileType.Pixi)
+        {
+            var maybeBitmap = document.MaybeRenderWholeImage();
+            if (maybeBitmap.IsT0)
+                return SaveResult.ConcurrencyError;
+            var bitmap = maybeBitmap.AsT1;
+
+            if (!encodersFactory.ContainsKey(typeFromPath))
+            {
+                return SaveResult.UnknownError;
+            }
+
+            return TrySaveAs(encodersFactory[typeFromPath](), pathWithExtension, bitmap, exportSize);
+        }
+        else
+        {
+            Parser.PixiParser.Serialize(document.ToSerializable(), pathWithExtension);
+        }
+
+        return SaveResult.Success;
+    }
+
+    static Dictionary<FileType, Func<BitmapEncoder>> encodersFactory = new Dictionary<FileType, Func<BitmapEncoder>>();
+
+    static Exporter()
+    {
+        encodersFactory[FileType.Png] = () => new PngBitmapEncoder();
+        encodersFactory[FileType.Jpeg] = () => new JpegBitmapEncoder();
+        encodersFactory[FileType.Bmp] = () => new BmpBitmapEncoder();
+        encodersFactory[FileType.Gif] = () => new GifBitmapEncoder();
+    }
+
+    public static void SaveAsGZippedBytes(string path, Surface surface)
+    {
+        SaveAsGZippedBytes(path, surface, new RectI(VecI.Zero, surface.Size));
+    }
+
+    public static void SaveAsGZippedBytes(string path, Surface surface, RectI rectToSave)
+    {
+        var imageInfo = new ImageInfo(rectToSave.Width, rectToSave.Height, ColorType.RgbaF16);
+        var unmanagedBuffer = Marshal.AllocHGlobal(rectToSave.Width * rectToSave.Height * 8);
+        //+8 bytes for width and height
+        var bytes = new byte[rectToSave.Width * rectToSave.Height * 8 + 8];
+        try
+        {
+            surface.DrawingSurface.ReadPixels(imageInfo, unmanagedBuffer, rectToSave.Width * 8, rectToSave.Left, rectToSave.Top);
+            Marshal.Copy(unmanagedBuffer, bytes, 8, rectToSave.Width * rectToSave.Height * 8);
+        }
+        finally
+        {
+            Marshal.FreeHGlobal(unmanagedBuffer);
+        }
+
+        BitConverter.GetBytes(rectToSave.Width).CopyTo(bytes, 0);
+        BitConverter.GetBytes(rectToSave.Height).CopyTo(bytes, 4);
+        using FileStream outputStream = new(path, FileMode.Create);
+        using GZipStream compressedStream = new GZipStream(outputStream, CompressionLevel.Fastest);
+        compressedStream.Write(bytes);
+    }
+
+    /// <summary>
+    /// Saves image to PNG file. Messes with the passed bitmap.
+    /// </summary>
+    private static SaveResult TrySaveAs(BitmapEncoder encoder, string savePath, Surface bitmap, VecI? exportSize)
+    {
+        try
+        {
+            if (exportSize is not null && exportSize != bitmap.Size)
+                bitmap = bitmap.ResizeNearestNeighbor((VecI)exportSize);
+
+            if (encoder is (JpegBitmapEncoder or BmpBitmapEncoder))
+                bitmap.DrawingSurface.Canvas.DrawColor(Colors.White, DrawingApi.Core.Surface.BlendMode.Multiply);
+
+            using var stream = new FileStream(savePath, FileMode.Create);
+            encoder.Frames.Add(BitmapFrame.Create(bitmap.ToWriteableBitmap()));
+            encoder.Save(stream);
+        }
+        catch (SecurityException)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (IOException)
+        {
+            return SaveResult.IoError;
+        }
+        catch
+        {
+            return SaveResult.UnknownError;
+        }
+
+        return SaveResult.Success;
+    }
+}

+ 8 - 4
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/FileTypeDialogData.cs

@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using System.Linq;
+using Avalonia.Platform.Storage;
 using PixiEditor.Models.Enums;
 using PixiEditor.Models.Files;
 
@@ -38,14 +39,17 @@ internal class FileTypeDialogData
             DisplayName = FileType.ToString() + " Images";
     }
 
-    public string SaveFilter
+    public FilePickerFileType SaveFilter
     {
-        get { return DisplayName + "|" + GetExtensionFormattedForDialog(PrimaryExtension); }
+        get
+        {
+            return new FilePickerFileType(DisplayName) { Patterns = ExtensionsFormattedForDialog };
+        }
     }
 
-    public string ExtensionsFormattedForDialog
+    public List<string> ExtensionsFormattedForDialog
     {
-        get { return string.Join(";", Extensions.Select(i => GetExtensionFormattedForDialog(i))); }
+        get { return Extensions.Select(GetExtensionFormattedForDialog).ToList(); }
     }
 
     string GetExtensionFormattedForDialog(string extension)

+ 5 - 3
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/FileTypeDialogDataSet.cs

@@ -1,7 +1,7 @@
 using System.Collections.Generic;
 using System.Linq;
+using Avalonia.Platform.Storage;
 using PixiEditor.Helpers;
-using PixiEditor.Models.Enums;
 using PixiEditor.Models.Files;
 
 namespace PixiEditor.Models.IO;
@@ -30,6 +30,7 @@ internal class FileTypeDialogDataSet
             Init("Image Files", allSupportedExtensions, FileType.Pixi);
         }
     }
+
     public FileTypeDialogDataSet(string displayName, IEnumerable<FileTypeDialogData> fileTypes, FileType? fileTypeToSkip = null)
     {
         Init(displayName, fileTypes, fileTypeToSkip);
@@ -45,8 +46,9 @@ internal class FileTypeDialogDataSet
         this.displayName = displayName;
     }
 
-    public string GetFormattedTypes()
+    public FilePickerFileType[] GetFormattedTypes()
     {
-        return displayName + " |" + string.Join(";", this.fileTypes.Select(i => i.ExtensionsFormattedForDialog));
+        FilePickerFileType[] types = fileTypes.Select(i => i.SaveFilter).ToArray();
+        return types;
     }
 }

+ 175 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/Importer.cs

@@ -0,0 +1,175 @@
+using System.IO;
+using System.IO.Compression;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using ChunkyImageLib;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Avalonia.Exceptions.Exceptions;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using PixiEditor.DrawingApi.Core.Surface.PaintImpl;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Parser;
+using PixiEditor.Parser.Deprecated;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using Bitmap = Avalonia.Media.Imaging.Bitmap;
+using BlendMode = PixiEditor.DrawingApi.Core.Surface.BlendMode;
+
+namespace PixiEditor.Models.IO;
+
+internal class Importer : ObservableObject
+{
+    /// <summary>
+    ///     Imports image from path and resizes it to given dimensions.
+    /// </summary>
+    /// <param name="path">Path of the image.</param>
+    /// <param name="size">New size of the image.</param>
+    /// <returns>WriteableBitmap of imported image.</returns>
+    public static Surface? ImportImage(string path, VecI size)
+    {
+        if (!Path.Exists(path)) return null;
+        Surface original = Surface.Load(path);
+        if (original.Size == size || size == VecI.NegativeOne)
+        {
+            return original;
+        }
+
+        Surface resized = original.ResizeNearestNeighbor(size);
+        original.Dispose();
+        return resized;
+    }
+
+    public static WriteableBitmap ImportWriteableBitmap(string path)
+    {
+        try
+        {
+            Uri uri = new Uri(path, UriKind.RelativeOrAbsolute);
+            Bitmap bitmap = new Bitmap(path);
+
+            using MemoryStream stream = new();
+            bitmap.Save(stream);
+
+            IntPtr ptr = Marshal.AllocHGlobal((int)stream.Length);
+
+            WriteableBitmap writeableBitmap = new(bitmap.Format.Value, AlphaFormat.Premul, ptr, bitmap.PixelSize, bitmap.Dpi, bitmap.PixelSize.Width * 4);
+
+            return writeableBitmap;
+        }
+        catch (NotSupportedException e)
+        {
+            throw new InvalidFileTypeException(new LocalizedString("FILE_EXTENSION_NOT_SUPPORTED", Path.GetExtension(path)), e);
+        }
+        /*catch (FileFormatException e) TODO: Not found in Avalonia
+        {
+            throw new CorruptedFileException("FAILED_TO_OPEN_FILE", e);
+        }*/
+        catch (Exception e)
+        {
+            throw new RecoverableException("ERROR_IMPORTING_IMAGE", e);
+        }
+    }
+
+    public static DocumentViewModel ImportDocument(string path, bool associatePath = true)
+    {
+        try
+        {
+            var doc = PixiParser.Deserialize(path).ToDocument();
+            
+            if (associatePath)
+            {
+                doc.FullFilePath = path;
+            }
+
+            return doc;
+        }
+        catch (InvalidFileException)
+        {
+            try
+            {
+                var doc = DepractedPixiParser.Deserialize(path).ToDocument();
+                
+                if (associatePath)
+                {
+                    doc.FullFilePath = path;
+                }
+
+                return doc;
+            }
+            catch (InvalidFileException e)
+            {
+                throw new CorruptedFileException("FAILED_TO_OPEN_FILE", e);
+            }
+        }
+    }
+
+    public static DocumentViewModel ImportDocument(byte[] file, string? originalFilePath)
+    {
+        try
+        {
+            var doc = PixiParser.Deserialize(file).ToDocument();
+            doc.FullFilePath = originalFilePath;
+            return doc;
+        }
+        catch (InvalidFileException)
+        {
+            try
+            {
+                var doc = DepractedPixiParser.Deserialize(file).ToDocument();
+                doc.FullFilePath = originalFilePath;
+                return doc;
+            }
+            catch (InvalidFileException e)
+            {
+                throw new CorruptedFileException("FAILED_TO_OPEN_FILE", e);
+            }
+        }
+    }
+
+    public static WriteableBitmap GetPreviewBitmap(string path)
+    {
+        if (!IsSupportedFile(path))
+        {
+            throw new InvalidFileTypeException(new LocalizedString("FILE_EXTENSION_NOT_SUPPORTED", Path.GetExtension(path)));
+        }
+        return Path.GetExtension(path) != ".pixi" ? ImportWriteableBitmap(path) : PixiParser.Deserialize(path).ToDocument().PreviewBitmap;
+    }
+
+    public static bool IsSupportedFile(string path)
+    {
+        return SupportedFilesHelper.IsSupportedFile(path);
+    }
+
+    public static Surface LoadFromGZippedBytes(string path)
+    {
+        using FileStream compressedData = new(path, FileMode.Open);
+        using GZipStream uncompressedData = new(compressedData, CompressionMode.Decompress);
+        using MemoryStream resultBytes = new();
+        uncompressedData.CopyTo(resultBytes);
+
+        byte[] bytes = resultBytes.ToArray();
+        int width = BitConverter.ToInt32(bytes, 0);
+        int height = BitConverter.ToInt32(bytes, 4);
+
+        ImageInfo info = new ImageInfo(width, height, ColorType.RgbaF16);
+        IntPtr ptr = Marshal.AllocHGlobal(bytes.Length - 8);
+        try
+        {
+            Marshal.Copy(bytes, 8, ptr, bytes.Length - 8);
+            Pixmap map = new(info, ptr);
+            DrawingSurface surface = DrawingSurface.Create(map);
+            Surface finalSurface = new Surface(new VecI(width, height));
+            using Paint paint = new() { BlendMode = BlendMode.Src };
+            surface.Draw(finalSurface.DrawingSurface.Canvas, 0, 0, paint);
+            return finalSurface;
+        }
+        finally
+        {
+            Marshal.FreeHGlobal(ptr);
+        }
+    }
+}

+ 64 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/ClsFileParser.cs

@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using CLSEncoderDecoder;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+
+namespace PixiEditor.Models.IO.PaletteParsers;
+
+internal class ClsFileParser : PaletteFileParser
+{
+    public override string FileName { get; } = "Clip Studio Paint Color Set";
+
+    public override string[] SupportedFileExtensions { get; } = { ".cls" };
+
+    public override async Task<PaletteFileData> Parse(string path)
+    {
+        return await Task.Run(() =>
+        {
+            ClsColorSet set;
+            try
+            {
+                set = ClsColorSet.Load(path);
+            }
+            catch
+            {
+                return PaletteFileData.Corrupted;
+            }
+
+            PaletteFileData data = new(
+                set.Utf8Name,
+                set.Colors
+                    .Where(static color => color.Alpha > 0)
+                    .Select(static color => new PaletteColor(color.Red, color.Green, color.Blue))
+                    .ToArray()
+            );
+            return data;
+        });
+    }
+
+    public override async Task<bool> Save(string path, PaletteFileData data)
+    {
+        if (data?.Colors == null || data.Colors.Length <= 0) return false;
+
+        string name = data.Title;
+        List<ClsColor> colors = data.Colors
+            .Select(color => new ClsColor(color.R, color.G, color.B, 255)).ToList();
+        await Task.Run(() =>
+        {
+            if (name.Length == 0)
+                name = Path.GetFileNameWithoutExtension(path);
+            if (name.Length > 64)
+                name = name.Substring(0, 64);
+            ClsColorSet set = new(colors, name);
+            set.Save(path);
+        });
+
+        return true;
+
+    }
+
+}

+ 96 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/GimpGplParser.cs

@@ -0,0 +1,96 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+
+namespace PixiEditor.Models.IO.PaletteParsers;
+
+internal class GimpGplParser : PaletteFileParser
+{
+    public override string FileName { get; } = "GIMP Palette";
+    public override string[] SupportedFileExtensions { get; } = { ".gpl" };
+
+    public override async Task<PaletteFileData> Parse(string path)
+    {
+        try
+        {
+            return await ParseFile(path);
+        }
+        catch
+        {
+            return PaletteFileData.Corrupted;
+        }
+    }
+
+    private async Task<PaletteFileData> ParseFile(string path)
+    {
+        var lines = await ReadTextLines(path);
+        string name = Path.GetFileNameWithoutExtension(path);
+
+        lines = lines.Where(x => !x.StartsWith("#") && !String.Equals(x.Trim(), "GIMP Palette", StringComparison.CurrentCultureIgnoreCase)).ToArray();
+
+        if(lines.Length == 0) return PaletteFileData.Corrupted;
+
+        List<PaletteColor> colors = new();
+        char[] separators = new[] { '\t', ' ' };
+        foreach (var colorLine in lines)
+        {
+            var colorParts = colorLine.Split(separators, StringSplitOptions.RemoveEmptyEntries);
+
+            if (colorParts.Length < 3)
+            {
+                continue;
+            }
+
+            if(colorParts.Length < 3) continue;
+
+            bool parsed = false;
+
+            parsed = byte.TryParse(colorParts[0], out byte r);
+            if(!parsed) continue;
+
+            parsed = byte.TryParse(colorParts[1], out byte g);
+            if(!parsed) continue;
+
+            parsed = byte.TryParse(colorParts[2], out byte b);
+            if(!parsed) continue;
+
+            var color = new PaletteColor(r, g, b); // alpha is ignored in PixiEditor
+            if (colors.Contains(color)) continue;
+
+            colors.Add(color);
+        }
+
+        return new PaletteFileData(name, colors.ToArray());
+    }
+
+    public override async Task<bool> Save(string path, PaletteFileData data)
+    {
+        StringBuilder sb = new();
+        string name = string.IsNullOrEmpty(data.Title) ? Path.GetFileNameWithoutExtension(path) : data.Title;
+        sb.AppendLine("GIMP Palette");
+        sb.AppendLine($"#Name: {name}");
+        sb.AppendLine($"#Colors {data.Colors.Length}");
+        sb.AppendLine("#Made with PixiEditor");
+        sb.AppendLine("#");
+        foreach (var color in data.Colors)
+        {
+            string hex = $"{color.R:X}{color.G:X}{color.B:X}";
+            sb.AppendLine($"{color.R}\t{color.G}\t{color.B}\t{hex}");
+        }
+
+        try
+        {
+            await File.WriteAllTextAsync(path, sb.ToString());
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+}

+ 70 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/HexPaletteParser.cs

@@ -0,0 +1,70 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+
+namespace PixiEditor.Models.IO.PaletteParsers;
+
+internal class HexPaletteParser : PaletteFileParser
+{
+    public override string FileName { get; } = "Hex Palette";
+    public override string[] SupportedFileExtensions { get; } = { ".hex" };
+    public override async Task<PaletteFileData> Parse(string path)
+    {
+        try
+        {
+            return await ParseFile(path);
+        }
+        catch
+        {
+            return PaletteFileData.Corrupted;
+        }
+    }
+
+    private async Task<PaletteFileData> ParseFile(string path)
+    {
+        var lines = await ReadTextLines(path);
+        string name = Path.GetFileNameWithoutExtension(path);
+
+        List<PaletteColor> colors = new();
+        foreach (var colorLine in lines)
+        {
+            if (colorLine.Length < 6)
+                continue;
+
+            byte r = byte.Parse(colorLine.Substring(0, 2), NumberStyles.HexNumber);
+            byte g = byte.Parse(colorLine.Substring(2, 2), NumberStyles.HexNumber);
+            byte b = byte.Parse(colorLine.Substring(4, 2), NumberStyles.HexNumber);
+            var color = new PaletteColor(r, g, b); // alpha is ignored in PixiEditor
+            if (colors.Contains(color)) continue;
+
+            colors.Add(color);
+        }
+
+        return new PaletteFileData(name, colors.ToArray());
+    }
+
+    public override async Task<bool> Save(string path, PaletteFileData data)
+    {
+        StringBuilder sb = new();
+        foreach (var color in data.Colors)
+        {
+            string hex = $"{color.R:X2}{color.G:X2}{color.B:X2}";
+            sb.AppendLine(hex);
+        }
+
+        try
+        {
+            await File.WriteAllTextAsync(path, sb.ToString());
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+}

+ 21 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/JascPalFile/JascFileException.cs

@@ -0,0 +1,21 @@
+using System.Runtime.Serialization;
+using PixiEditor.Avalonia.Exceptions.Exceptions;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.Models.IO.PaletteParsers.JascPalFile;
+
+
+internal class JascFileException : RecoverableException
+{
+    public JascFileException() { }
+
+    public JascFileException(LocalizedString displayMessage) : base(displayMessage) { }
+
+    public JascFileException(LocalizedString displayMessage, Exception innerException) : base(displayMessage, innerException) { }
+
+    public JascFileException(LocalizedString displayMessage, string exceptionMessage) : base(displayMessage, exceptionMessage) { }
+
+    public JascFileException(LocalizedString displayMessage, string exceptionMessage, Exception innerException) : base(displayMessage, exceptionMessage, innerException) { }
+
+    protected JascFileException(SerializationInfo info, StreamingContext context) : base(info, context) { }
+}

+ 73 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/JascPalFile/JascFileParser.cs

@@ -0,0 +1,73 @@
+using System.IO;
+using System.Threading.Tasks;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+
+namespace PixiEditor.Models.IO.PaletteParsers.JascPalFile;
+
+/// <summary>
+///     This class is responsible for parsing JASC-PAL files. Which holds the color palette data.
+/// </summary>
+internal class JascFileParser : PaletteFileParser
+{
+    private static readonly string[] _supportedFileExtensions = new string[] { ".pal" };
+    public override string[] SupportedFileExtensions => _supportedFileExtensions;
+    public override string FileName => "Jasc Palette";
+
+    private static async Task<PaletteFileData> ParseFile(string path)
+    {
+        string[] lines = await ReadTextLines(path);
+        string name = Path.GetFileNameWithoutExtension(path);
+        string fileType = lines[0];
+        string magicBytes = lines[1];
+        if (ValidateFile(fileType, magicBytes))
+        {
+            int colorCount = int.Parse(lines[2]);
+            PaletteColor[] colors = new PaletteColor[colorCount];
+            for (int i = 0; i < colorCount; i++)
+            {
+                string[] colorData = lines[i + 3].Split(' ');
+                colors[i] = new PaletteColor(byte.Parse(colorData[0]), byte.Parse(colorData[1]), byte.Parse(colorData[2]));
+            }
+
+            return new PaletteFileData(name, colors);
+        }
+
+        throw new JascFileException("FAILED_TO_OPEN_FILE", "Invalid JASC-PAL file.");
+    }
+
+    public static async Task<bool> SaveFile(string path, PaletteFileData data)
+    {
+        if (data is not { Colors.Length: > 0 }) return false;
+
+        string fileContent = "JASC-PAL\n0100\n" + data.Colors.Length;
+        for (int i = 0; i < data.Colors.Length; i++)
+        {
+            fileContent += "\n" + data.Colors[i].R + " " + data.Colors[i].G + " " + data.Colors[i].B;
+        }
+
+        await File.WriteAllTextAsync(path, fileContent);
+        return true;
+
+    }
+
+    public override async Task<PaletteFileData> Parse(string path)
+    {
+        try
+        {
+            return await ParseFile(path);
+        }
+        catch
+        {
+            return PaletteFileData.Corrupted;
+        }
+    }
+
+    public override async Task<bool> Save(string path, PaletteFileData data) => await SaveFile(path, data);
+
+    private static bool ValidateFile(string fileType, string magicBytes)
+    {
+        return fileType.Length > 7 && fileType[..8].ToUpper() == "JASC-PAL" && magicBytes.Length > 3 && magicBytes[..4] == "0100";
+    }
+}

+ 81 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/PaintNetTxtParser.cs

@@ -0,0 +1,81 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+using PixiEditor.Helpers;
+
+namespace PixiEditor.Models.IO.PaletteParsers;
+
+// https://www.getpaint.net/doc/latest/WorkingWithPalettes.html
+
+internal class PaintNetTxtParser : PaletteFileParser
+{
+    public override string FileName { get; } = "Paint.NET Palette";
+    public override string[] SupportedFileExtensions { get; } = new string[] { ".txt" };
+    public override async Task<PaletteFileData> Parse(string path)
+    {
+        try
+        {
+            return await ParseFile(path);
+        }
+        catch
+        {
+            return PaletteFileData.Corrupted;
+        }
+    }
+
+    private static async Task<PaletteFileData> ParseFile(string path)
+    {
+        var lines = await ReadTextLines(path);
+        string name = Path.GetFileNameWithoutExtension(path);
+
+        lines = lines.Where(x => !x.StartsWith(";")).ToArray();
+
+        List<PaletteColor> colors = new();
+        for (int i = 0; i < lines.Length; i++)
+        {
+            // Color format aarrggbb
+            string colorLine = lines[i];
+            if(colorLine.Length < 8)
+                continue;
+
+            byte a = byte.Parse(colorLine.Substring(0, 2), NumberStyles.HexNumber);
+            byte r = byte.Parse(colorLine.Substring(2, 2), NumberStyles.HexNumber);
+            byte g = byte.Parse(colorLine.Substring(4, 2), NumberStyles.HexNumber);
+            byte b = byte.Parse(colorLine.Substring(6, 2), NumberStyles.HexNumber);
+            var color = new PaletteColor(r, g, b); // alpha is ignored in PixiEditor
+            if(colors.Contains(color)) continue;
+
+            colors.Add(color);
+        }
+
+        return new PaletteFileData(name, colors.ToArray());
+    }
+
+    public override async Task<bool> Save(string path, PaletteFileData data)
+    {
+        StringBuilder sb = new StringBuilder();
+        sb.AppendLine("; Paint.NET Palette File");
+        sb.AppendLine($"; Made using PixiEditor {VersionHelpers.GetCurrentAssemblyVersion().ToString()}");
+        sb.AppendLine($"; {data.Colors.Length} colors");
+        foreach (PaletteColor color in data.Colors)
+        {
+            sb.AppendLine(color.Hex);
+        }
+
+        try
+        {
+            await File.WriteAllTextAsync(path, sb.ToString());
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+}

+ 43 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/PixiPaletteParser.cs

@@ -0,0 +1,43 @@
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+using PixiEditor.Parser;
+
+namespace PixiEditor.Models.IO.PaletteParsers;
+
+internal class PixiPaletteParser : PaletteFileParser
+{
+    public override string FileName { get; } = "Palette from PixiEditor .pixi";
+    public override string[] SupportedFileExtensions { get; } = { ".pixi" };
+    public override async Task<PaletteFileData> Parse(string path)
+    {
+        try
+        {
+            return await ParseFile(path);
+        }
+        catch
+        {
+            return PaletteFileData.Corrupted;
+        }
+    }
+
+    private async Task<PaletteFileData> ParseFile(string path)
+    {
+        var file = await PixiParser.DeserializeAsync(path);
+        if(file.Palette == null) return PaletteFileData.Corrupted;
+
+        string name = Path.GetFileNameWithoutExtension(path);
+
+        return new PaletteFileData(name, file.Palette.Select(x => new PaletteColor(x.R, x.G, x.B)).ToArray());
+    }
+
+    public override bool CanSave => false;
+
+    public override Task<bool> Save(string path, PaletteFileData data)
+    {
+        throw new SavingNotSupportedException("Saving palette as .pixi directly is not supported.");
+    }
+}

+ 130 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/PngPaletteParser.cs

@@ -0,0 +1,130 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.Avalonia.Helpers.Extensions;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+using Color = PixiEditor.DrawingApi.Core.ColorsImpl.Color;
+
+namespace PixiEditor.Models.IO.PaletteParsers;
+
+internal class PngPaletteParser : PaletteFileParser
+{
+    public override string FileName { get; } = "PNG Palette";
+    public override string[] SupportedFileExtensions { get; } = { ".png" };
+
+    public override async Task<PaletteFileData> Parse(string path)
+    {
+           try
+           {
+               return await ParseFile(path);
+           }
+           catch
+           {
+               return PaletteFileData.Corrupted;
+           }
+    }
+
+    private async Task<PaletteFileData> ParseFile(string path)
+    {
+        return await Task.Run(() =>
+        {
+            Bitmap bitmap = new(path);
+
+            PaletteColor[] colors = ExtractFromBitmap(bitmap);
+
+            PaletteFileData data = new(
+                Path.GetFileNameWithoutExtension(path), colors);
+
+            return data;
+        });
+    }
+
+    private PaletteColor[] ExtractFromBitmap(Bitmap bmp)
+    {
+        /*if (bmp.Palette is not null && bmp.Palette.Colors.Count > 0)
+        {
+            return ExtractFromBitmapPalette(bmp.Palette);
+        }*/
+
+        return ExtractFromBitmapSource(bmp);
+    }
+
+    private PaletteColor[] ExtractFromBitmapSource(Bitmap frame)
+    {
+        int width = frame.PixelSize.Width;
+        int height = frame.PixelSize.Height;
+        if (width == 0 || height == 0)
+        {
+            return Array.Empty<PaletteColor>();
+        }
+
+        List<PaletteColor> colors = new();
+
+        byte[] pixels = frame.ExtractPixels();
+        int pixelCount = pixels.Length / 4;
+        for (int i = 0; i < pixelCount; i++)
+        {
+            var color = GetColorFromBytes(pixels, i);
+            if (!colors.Contains(color))
+            {
+                colors.Add(color);
+            }
+        }
+
+        return colors.ToArray();
+    }
+
+    private PaletteColor GetColorFromBytes(byte[] pixels, int i)
+    {
+        return new PaletteColor(pixels[i * 4 + 2], pixels[i * 4 + 1], pixels[i * 4]);
+    }
+
+    // TODO: there is no palette in Bitmap, maybe there is a different way to parse png
+    /*private PaletteColor[] ExtractFromBitmapPalette(BitmapPalette palette)
+    {
+        if (palette.Colors == null || palette.Colors.Count == 0)
+        {
+            return Array.Empty<PaletteColor>();
+        }
+
+        return palette.Colors.Select(color => new PaletteColor(color.R, color.G, color.B)).ToArray();
+    }*/
+
+    public override async Task<bool> Save(string path, PaletteFileData data)
+    {
+        try
+        {
+            await SaveFile(path, data);
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    private async Task SaveFile(string path, PaletteFileData data)
+    {
+        await Task.Run(() =>
+        {
+            WriteableBitmap bitmap = WriteableBitmapHelpers.CreateBitmap(new VecI(data.Colors.Length, 1));
+            using var framebuffer = bitmap.Lock();
+            for (int i = 0; i < data.Colors.Length; i++)
+            {
+                PaletteColor color = data.Colors[i];
+                framebuffer.WritePixel(i, 0, new global::Avalonia.Media.Color(255, color.R, color.G, color.B));
+            }
+
+            bitmap.Save(path);
+        });
+    }
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/IO/PaletteParsers/SavingNotSupportedException.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.IO.PaletteParsers;
+
+public class SavingNotSupportedException : Exception
+{
+    public SavingNotSupportedException(string message) : base(message)
+    {
+    }
+}

+ 349 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/LocalPalettesFetcher.cs

@@ -0,0 +1,349 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.DataHolders.Palettes;
+using PixiEditor.Models.IO;
+using PixiEditor.Models.IO.PaletteParsers.JascPalFile;
+
+namespace PixiEditor.Models.DataProviders;
+
+internal delegate void CacheUpdate(RefreshType refreshType, Palette itemAffected, string oldName);
+
+internal class LocalPalettesFetcher : PaletteListDataSource
+{
+    private List<Palette> cachedPalettes;
+
+    public event CacheUpdate CacheUpdated;
+
+    private List<string> cachedFavoritePalettes;
+
+    private FileSystemWatcher watcher;
+
+    public LocalPalettesFetcher() : base("LOCAL_PALETTE_SOURCE_NAME")
+    {
+    }
+
+    public override void Initialize()
+    {
+        InitDir();
+        watcher = new FileSystemWatcher(Paths.PathToPalettesFolder);
+        watcher.Filter = "*.pal";
+        watcher.Changed += FileSystemChanged;
+        watcher.Deleted += FileSystemChanged;
+        watcher.Renamed += RenamedFile;
+        watcher.Created += FileSystemChanged;
+
+        watcher.EnableRaisingEvents = true;
+        cachedFavoritePalettes = IPreferences.Current.GetLocalPreference<List<string>>(PreferencesConstants.FavouritePalettes);
+
+        IPreferences.Current.AddCallback(PreferencesConstants.FavouritePalettes, updated =>
+        {
+            cachedFavoritePalettes = (List<string>)updated;
+            cachedPalettes.ForEach(x => x.IsFavourite = cachedFavoritePalettes.Contains(x.Name));
+        });
+    }
+
+    public override async Task<List<IPalette>> FetchPaletteList(int startIndex, int count, FilteringSettings filtering)
+    {
+        if (cachedPalettes == null)
+        {
+            await RefreshCacheAll();
+        }
+
+        var filteredPalettes = cachedPalettes.Where(filtering.Filter).OrderByDescending(x => x.IsFavourite).ToArray();
+
+        List<IPalette> result = new List<IPalette>();
+
+        if (startIndex >= filteredPalettes.Length) return result;
+
+        for (int i = 0; i < count; i++)
+        {
+            if (startIndex + i >= filteredPalettes.Length) break;
+            Palette palette = filteredPalettes[startIndex + i];
+            result.Add(palette);
+        }
+
+        return result;
+    }
+
+    public static bool PaletteExists(string paletteName)
+    {
+        string finalFileName = paletteName;
+        if (!paletteName.EndsWith(".pal"))
+        {
+            finalFileName += ".pal";
+        }
+
+        return File.Exists(Path.Join(Paths.PathToPalettesFolder, finalFileName));
+    }
+
+    public static string GetNonExistingName(string currentName, bool appendExtension = false)
+    {
+        string newName = Path.GetFileNameWithoutExtension(currentName);
+
+        if (File.Exists(Path.Join(Paths.PathToPalettesFolder, newName + ".pal")))
+        {
+            int number = 1;
+            while (true)
+            {
+                string potentialName = $"{newName} ({number})";
+                number++;
+                if (File.Exists(Path.Join(Paths.PathToPalettesFolder, potentialName + ".pal")))
+                    continue;
+                newName = potentialName;
+                break;
+            }
+        }
+
+        if (appendExtension)
+            newName += ".pal";
+
+        return newName;
+    }
+
+    public async Task SavePalette(string fileName, PaletteColor[] colors)
+    {
+        watcher.EnableRaisingEvents = false;
+        string path = Path.Join(Paths.PathToPalettesFolder, fileName);
+        InitDir();
+        await JascFileParser.SaveFile(path, new PaletteFileData(colors));
+        watcher.EnableRaisingEvents = true;
+
+        await RefreshCache(RefreshType.Created, path);
+    }
+
+    public async Task DeletePalette(string name)
+    {
+        if (!Directory.Exists(Paths.PathToPalettesFolder)) return;
+        string path = Path.Join(Paths.PathToPalettesFolder, name);
+        if (!File.Exists(path)) return;
+
+        watcher.EnableRaisingEvents = false;
+        File.Delete(path);
+        watcher.EnableRaisingEvents = true;
+
+        await RefreshCache(RefreshType.Deleted, path);
+    }
+
+    public void RenamePalette(string oldFileName, string newFileName)
+    {
+        if (!Directory.Exists(Paths.PathToPalettesFolder))
+            return;
+
+        string oldPath = Path.Join(Paths.PathToPalettesFolder, oldFileName);
+        string newPath = Path.Join(Paths.PathToPalettesFolder, newFileName);
+        if (!File.Exists(oldPath) || File.Exists(newPath))
+            return;
+
+        watcher.EnableRaisingEvents = false;
+        File.Move(oldPath, newPath);
+        watcher.EnableRaisingEvents = true;
+
+        RefreshCacheRenamed(newPath, oldPath);
+    }
+
+    public async Task RefreshCacheAll()
+    {
+        string[] files = DirectoryExtensions.GetFiles(
+            Paths.PathToPalettesFolder,
+            string.Join("|", AvailableParsers.SelectMany(x => x.SupportedFileExtensions)),
+            SearchOption.TopDirectoryOnly);
+        cachedPalettes = await ParseAll(files);
+        CacheUpdated?.Invoke(RefreshType.All, null, null);
+    }
+
+    private async void FileSystemChanged(object sender, FileSystemEventArgs e)
+    {
+        bool waitableExceptionOccured = false;
+        do
+        {
+            try
+            {
+                switch (e.ChangeType)
+                {
+                    case WatcherChangeTypes.Created:
+                        await RefreshCache(RefreshType.Created, e.FullPath);
+                        break;
+                    case WatcherChangeTypes.Deleted:
+                        await RefreshCache(RefreshType.Deleted, e.FullPath);
+                        break;
+                    case WatcherChangeTypes.Changed:
+                        await RefreshCache(RefreshType.Updated, e.FullPath);
+                        break;
+                    case WatcherChangeTypes.Renamed:
+                        // Handled by method below
+                        break;
+                    case WatcherChangeTypes.All:
+                        await RefreshCache(RefreshType.Created, e.FullPath);
+                        break;
+                    default:
+                        throw new ArgumentOutOfRangeException();
+                }
+
+                waitableExceptionOccured = false;
+            }
+            catch (IOException)
+            {
+                waitableExceptionOccured = true;
+                await Task.Delay(100);
+            }
+
+        }
+        while (waitableExceptionOccured);
+    }
+
+    private async Task RefreshCache(RefreshType refreshType, string file)
+    {
+        Palette updated = null;
+        string affectedFileName = null;
+
+        switch (refreshType)
+        {
+            case RefreshType.All:
+                throw new ArgumentException("To handle refreshing all items, use RefreshCacheAll");
+            case RefreshType.Created:
+                updated = await RefreshCacheAdded(file);
+                break;
+            case RefreshType.Updated:
+                updated = await RefreshCacheUpdated(file);
+                break;
+            case RefreshType.Deleted:
+                affectedFileName = RefreshCacheDeleted(file);
+                break;
+            case RefreshType.Renamed:
+                throw new ArgumentException("To handle renaming, use RefreshCacheRenamed");
+            default:
+                throw new ArgumentOutOfRangeException(nameof(refreshType), refreshType, null);
+        }
+        CacheUpdated?.Invoke(refreshType, updated, affectedFileName);
+    }
+
+    private void RefreshCacheRenamed(string newFilePath, string oldFilePath)
+    {
+        string oldFileName = Path.GetFileName(oldFilePath);
+        int index = cachedPalettes.FindIndex(p => p.FileName == oldFileName);
+        if (index == -1) return;
+
+        Palette palette = cachedPalettes[index];
+        palette.FileName = Path.GetFileName(newFilePath);
+        palette.Name = Path.GetFileNameWithoutExtension(newFilePath);
+
+        CacheUpdated?.Invoke(RefreshType.Renamed, palette, oldFileName);
+    }
+
+    private string RefreshCacheDeleted(string filePath)
+    {
+        string fileName = Path.GetFileName(filePath);
+        int index = cachedPalettes.FindIndex(p => p.FileName == fileName);
+        if (index == -1) return null;
+
+        cachedPalettes.RemoveAt(index);
+        return fileName;
+    }
+
+    private async Task<Palette> RefreshCacheItem(string file, Action<Palette> action)
+    {
+        if (File.Exists(file))
+        {
+            string extension = Path.GetExtension(file);
+            var foundParser = AvailableParsers.FirstOrDefault(x => x.SupportedFileExtensions.Contains(extension));
+            if (foundParser != null)
+            {
+                var newPalette = await foundParser.Parse(file);
+                if (newPalette is { IsCorrupted: false })
+                {
+                    Palette pal = CreatePalette(newPalette, file,
+                        cachedFavoritePalettes?.Contains(newPalette.Title) ?? false);
+                    action(pal);
+
+                    return pal;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    private async Task<Palette> RefreshCacheUpdated(string file)
+    {
+        return await RefreshCacheItem(file, palette =>
+        {
+            Palette existingPalette = cachedPalettes.FirstOrDefault(x => x.FileName == palette.FileName);
+            if (existingPalette != null)
+            {
+                existingPalette.Colors = palette.Colors.ToList();
+                existingPalette.Name = palette.Name;
+                existingPalette.FileName = palette.FileName;
+            }
+        });
+    }
+
+    private async Task<Palette> RefreshCacheAdded(string file)
+    {
+        return await RefreshCacheItem(file, palette =>
+        {
+            string fileName = Path.GetFileName(file);
+            int index = cachedPalettes.FindIndex(p => p.FileName == fileName);
+            if (index != -1)
+            {
+                cachedPalettes.RemoveAt(index);
+            }
+            cachedPalettes.Add(palette);
+        });
+    }
+
+    private async Task<List<Palette>> ParseAll(string[] files)
+    {
+        List<Palette> result = new List<Palette>();
+
+        foreach (var file in files)
+        {
+            string extension = Path.GetExtension(file);
+            if (!File.Exists(file)) continue;
+            var foundParser = AvailableParsers.First(x => x.SupportedFileExtensions.Contains(extension));
+            {
+                PaletteFileData fileData = await foundParser.Parse(file);
+                if (fileData.IsCorrupted) continue;
+                var palette = CreatePalette(fileData, file, cachedFavoritePalettes?.Contains(fileData.Title) ?? false);
+
+                result.Add(palette);
+            }
+        }
+
+        return result;
+    }
+
+    private Palette CreatePalette(PaletteFileData fileData, string file, bool isFavourite)
+    {
+        var palette = new Palette(
+            fileData.Title,
+            new List<PaletteColor>(fileData.GetPaletteColors()),
+            Path.GetFileName(file), this)
+        {
+            IsFavourite = isFavourite
+        };
+
+        return palette;
+    }
+
+    private void RenamedFile(object sender, RenamedEventArgs e)
+    {
+        RefreshCacheRenamed(e.FullPath, e.OldFullPath);
+    }
+
+    private static void InitDir()
+    {
+        if (!Directory.Exists(Paths.PathToPalettesFolder))
+        {
+            Directory.CreateDirectory(Paths.PathToPalettesFolder);
+        }
+    }
+}

+ 48 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/Palette.cs

@@ -0,0 +1,48 @@
+#nullable enable
+using System.Collections.Generic;
+using System.IO;
+using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Helpers;
+namespace PixiEditor.Models.DataHolders.Palettes;
+
+internal class Palette : ObservableObject, IPalette
+{
+    private string _name = "";
+
+    public string Name
+    {
+        get => _name;
+        set => SetProperty(ref _name, value);
+    }
+    public List<PaletteColor> Colors { get; set; }
+
+    private string? fileName;
+
+    public string? FileName
+    {
+        get => fileName;
+        set
+        {
+            fileName = ReplaceInvalidChars(value);
+            OnPropertyChanged(nameof(FileName));
+        }
+    }
+
+    public bool IsFavourite { get; set; }
+
+    public PaletteListDataSource Source { get; }
+
+    public Palette(string name, List<PaletteColor> colors, string? fileName, PaletteListDataSource source)
+    {
+        Name = name;
+        Colors = colors;
+        FileName = fileName;
+        Source = source;
+    }
+
+    public static string? ReplaceInvalidChars(string? filename)
+    {
+        return filename == null ? null : string.Join("_", filename.Split(Path.GetInvalidFileNameChars()));
+    }
+}

+ 12 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/PaletteFileType.cs

@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PixiEditor.Models.DataHolders.Palettes;
+
+public enum PaletteFileType
+{
+    JascPal
+}

+ 10 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/PaletteList.cs

@@ -0,0 +1,10 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace PixiEditor.Models.DataHolders.Palettes;
+
+internal sealed class PaletteList : ObservableObject
+{
+    public bool FetchedCorrectly { get; set; } = false;
+    public ObservableCollection<Palette> Palettes { get; set; } = new ObservableCollection<Palette>();
+}

+ 26 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/PaletteObject.cs

@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.Extensions.Palettes;
+
+namespace PixiEditor.Models.DataHolders.Palettes;
+
+/// <summary>
+///     Class used to deserialize palette file from Lospec.
+/// </summary>
+internal class PaletteObject
+{
+    public string Name { get; set; }
+    public List<string> Colors { get; set; }
+
+    public Palette ToPalette()
+    {
+        List<PaletteColor> colors = new();
+        foreach (string color in Colors)
+        {
+            Color parsedColor = Color.Parse(color);
+            colors.Add(new PaletteColor(parsedColor.R, parsedColor.G, parsedColor.B));
+        }
+
+        return new(Name, colors, null, null);
+    }
+}

+ 10 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/RefreshType.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.Models.DataHolders.Palettes;
+
+public enum RefreshType
+{
+    All,
+    Created,
+    Updated,
+    Deleted,
+    Renamed
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Palettes/SortingType.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.DataHolders.Palettes;
+
+public enum SortingType
+{
+    Default,
+    Alphabetical,
+    ColorCount
+}

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

@@ -228,7 +228,7 @@ internal class MemberPreviewUpdater
             }
 
             //TODO: Make sure PreviewBitmap implementation raises PropertyChanged
-            //member.RaisePropertyChanged(nameof(member.PreviewBitmap));
+            //member.OnPropertyChanged(nameof(member.PreviewBitmap));
         }
 
         // update masks
@@ -249,7 +249,7 @@ internal class MemberPreviewUpdater
             }
 
             //TODO: Make sure MaskPreviewBitmap implementation raises PropertyChanged
-            //member.RaisePropertyChanged(nameof(member.MaskPreviewBitmap));
+            //member.OnPropertyChanged(nameof(member.MaskPreviewBitmap));
         }
     }
 

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

@@ -20,6 +20,7 @@
         <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
         <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)" />
         <PackageReference Include="ByteSize" Version="2.1.1" />
+        <PackageReference Include="CLSEncoderDecoder" Version="1.0.0" />
         <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
         <PackageReference Include="DiscordRichPresence" Version="1.1.3.18" />
         <PackageReference Include="Dock.Avalonia" Version="11.0.0" />

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Document/DocumentManagerViewModel.cs

@@ -145,8 +145,8 @@ internal class DocumentManagerViewModel : SubViewModel<ViewModelMain>, IDocument
     }
 
 
-    [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)]
+    [Command.Basic("PixiEditor.Document.ResizeDocument", false, "RESIZE_DOCUMENT", "RESIZE_DOCUMENT", CanExecute = "PixiEditor.HasDocument", Key = Key.I, Modifiers = KeyModifiers.Control | KeyModifiers.Shift)]
+    [Command.Basic("PixiEditor.Document.ResizeCanvas", true, "RESIZE_CANVAS", "RESIZE_CANVAS", CanExecute = "PixiEditor.HasDocument", Key = Key.C, Modifiers = KeyModifiers.Control | KeyModifiers.Shift)]
     public void OpenResizePopup(bool canvas)
     {
         DocumentViewModel? doc = Owner.DocumentManagerSubViewModel.ActiveDocument;

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

@@ -200,7 +200,7 @@ internal class DocumentTransformViewModel : ObservableObject
         undoStack.AddState((Corners, InternalState), TransformOverlayStateType.Initial);
     }
 
-    public void ModifierKeysInlet(bool isShiftDown, bool isCtrlDown, bool isAltDown)
+    public void KeyModifiersInlet(bool isShiftDown, bool isCtrlDown, bool isAltDown)
     {
         var requestedCornerFreedom = TransformCornerFreedom.Scale;
         var requestedSideFreedom = TransformSideFreedom.Stretch;

+ 23 - 15
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ClipboardViewModel.cs

@@ -1,10 +1,17 @@
 using System.Collections.Immutable;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Input;
 using Avalonia.Media;
+using PixiEditor.Avalonia.Helpers.Extensions;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Controllers;
+using PixiEditor.Models.IO;
 
 namespace PixiEditor.ViewModels.SubViewModels.Main;
 #nullable enable
@@ -16,18 +23,18 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
     {
     }
 
-    [Command.Basic("PixiEditor.Clipboard.Cut", "CUT", "CUT_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.X, Modifiers = ModifierKeys.Control)]
-    public void Cut()
+    [Command.Basic("PixiEditor.Clipboard.Cut", "CUT", "CUT_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.X, Modifiers = KeyModifiers.Control)]
+    public async Task Cut()
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         if (doc is null)
             return;
-        Copy();
+        await 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)]
+    [Command.Basic("PixiEditor.Clipboard.Paste", false, "PASTE", "PASTE_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Shift)]
+    [Command.Basic("PixiEditor.Clipboard.PasteAsNewLayer", true, "PASTE_AS_NEW_LAYER", "PASTE_AS_NEW_LAYER_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPaste", Key = Key.V, Modifiers = KeyModifiers.Control)]
     public void Paste(bool pasteAsNewLayer)
     {
         if (Owner.DocumentManagerSubViewModel.ActiveDocument is null) 
@@ -45,14 +52,16 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         
         var bitmap = surface.image.ToWriteableBitmap();
 
-        byte[] pixels = new byte[bitmap.PixelWidth * bitmap.PixelHeight * 4];
-        bitmap.CopyPixels(pixels, bitmap.PixelWidth * 4, 0);
+        byte[] pixels = bitmap.ExtractPixels();
 
         doc.Operations.ImportReferenceLayer(
             pixels.ToImmutableArray(),
             surface.image.Size);
 
-        Application.Current.MainWindow!.Activate();
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+        {
+            desktop.MainWindow!.Activate();
+        }
     }
     
     [Command.Internal("PixiEditor.Clipboard.PasteReferenceLayerFromPath")]
@@ -61,12 +70,11 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         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);
+        byte[] pixels = bitmap.ExtractPixels();
 
         doc.Operations.ImportReferenceLayer(
             pixels.ToImmutableArray(),
-            new VecI(bitmap.PixelWidth, bitmap.PixelHeight));
+            new VecI(bitmap.PixelSize.Width, bitmap.PixelSize.Height));
     }
 
     [Command.Basic("PixiEditor.Clipboard.PasteColor", false, "PASTE_COLOR", "PASTE_COLOR_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.CanPasteColor", IconEvaluator = "PixiEditor.Clipboard.PasteColorIcon")]
@@ -88,20 +96,20 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         }
     }
 
-    [Command.Basic("PixiEditor.Clipboard.Copy", "COPY", "COPY_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.C, Modifiers = ModifierKeys.Control)]
-    public void Copy()
+    [Command.Basic("PixiEditor.Clipboard.Copy", "COPY", "COPY_DESCRIPTIVE", CanExecute = "PixiEditor.Selection.IsNotEmpty", Key = Key.C, Modifiers = KeyModifiers.Control)]
+    public async Task Copy()
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         if (doc is null)
             return;
-        ClipboardController.CopyToClipboard(doc);
+        await 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)]
+    [Command.Filter("PixiEditor.Clipboard.CopyColorToClipboard", "COPY_COLOR_TO_CLIPBOARD", "COPY_COLOR", Key = Key.C, Modifiers = KeyModifiers.Shift)]
     public void CopyColorAsHex(CopyColor color)
     {
         var targetColor = color switch

+ 5 - 4
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ColorsViewModel.cs

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
 using System.Windows;
 using System.Windows.Input;
 using System.Windows.Media;
+using Avalonia;
 using Avalonia.Input;
 using Avalonia.Media;
 using CommunityToolkit.Mvvm.Input;
@@ -123,13 +124,13 @@ internal class ColorsViewModel : SubViewModel<ViewModelMain>
         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);
+        var oldDrawing = new GeometryDrawing { Brush = new SolidColorBrush(oldColor.ToOpaqueMediaColor()), Pen = new Pen(Brushes.Gray, .5) };
+        var oldGeometry = new EllipseGeometry(new Rect(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);
+        var newDrawing = new GeometryDrawing { Brush = new SolidColorBrush(newColor.ToOpaqueMediaColor()), Pen = new Pen(Brushes.White, 1) };
+        var newGeometry = new EllipseGeometry(new Rect(10, 10, 6, 6));
 
         newDrawing.Geometry = newGeometry;
         

+ 32 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ExtensionsViewModel.cs

@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Extensions;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Windowing;
+using PixiEditor.Models.AppExtensions;
+using PixiEditor.Models.AppExtensions.Services;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Views.Dialogs;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+internal class ExtensionsViewModel : SubViewModel<ViewModelMain>
+{
+    public ExtensionLoader ExtensionLoader { get; }
+    public ExtensionsViewModel(ViewModelMain owner, ExtensionLoader loader) : base(owner)
+    {
+        ExtensionLoader = loader;
+        ((WindowProvider)Owner.Services.GetService<IWindowProvider>()).RegisterHandler(PalettesBrowser.UniqueId, () =>
+        {
+            return PalettesBrowser.Open(
+                Owner.ColorsSubViewModel.PaletteProvider,
+                Owner.ColorsSubViewModel.ImportPaletteCommand,
+                Owner.DocumentManagerSubViewModel.ActiveDocument?.Palette);
+        });
+        Owner.OnStartupEvent += Owner_OnStartupEvent;
+    }
+
+    private void Owner_OnStartupEvent(object sender, EventArgs e)
+    {
+        ExtensionLoader.InitializeExtensions(new ExtensionServices(Owner.Services));
+    }
+}

+ 28 - 22
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/FileViewModel.cs

@@ -1,8 +1,12 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Input;
+using Avalonia.Platform.Storage;
 using ChunkyImageLib;
 using Newtonsoft.Json.Linq;
 using PixiEditor.Avalonia.Exceptions.Exceptions;
@@ -14,6 +18,7 @@ using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.IO;
 using PixiEditor.Parser;
 using PixiEditor.ViewModels.SubViewModels.Document;
 
@@ -122,20 +127,20 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
     }
 
     [Command.Basic("PixiEditor.File.Open", "OPEN", "OPEN_FILE", Key = Key.O, Modifiers = KeyModifiers.Control)]
-    public void OpenFromOpenFileDialog()
+    public async Task OpenFromOpenFileDialog()
     {
-        string filter = SupportedFilesHelper.BuildOpenFilter();
+        var filter = SupportedFilesHelper.BuildOpenFilter();
 
-        OpenFileDialog dialog = new OpenFileDialog
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
         {
-            Filter = filter,
-            FilterIndex = 0
-        };
+            var dialog = await desktop.MainWindow.StorageProvider.OpenFilePickerAsync(
+                new FilePickerOpenOptions { FileTypeFilter = filter });
 
-        if (!(bool)dialog.ShowDialog() || !Importer.IsSupportedFile(dialog.FileName))
-            return;
+            if (dialog.Count == 0 || !Importer.IsSupportedFile(dialog[0].Path.AbsolutePath))
+                return;
 
-        OpenFromPath(dialog.FileName);
+            OpenFromPath(dialog[0].Path.AbsolutePath);
+        }
     }
 
     [Command.Basic("PixiEditor.File.OpenFileFromClipboard", "OPEN_FILE_FROM_CLIPBOARD", "OPEN_FILE_FROM_CLIPBOARD_DESCRIPTIVE", CanExecute = "PixiEditor.Clipboard.HasImageInClipboard")]
@@ -262,7 +267,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         AddRecentlyOpened(path);
     }
 
-    [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N, Modifiers = ModifierKeys.Control)]
+    [Command.Basic("PixiEditor.File.New", "NEW_IMAGE", "CREATE_NEW_IMAGE", Key = Key.N, Modifiers = KeyModifiers.Control)]
     public void CreateFromNewFileDialog()
     {
         NewFileDialog newFile = new NewFileDialog();
@@ -290,31 +295,32 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         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)
+    [Command.Basic("PixiEditor.File.Save", false, "SAVE", "SAVE_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control, IconPath = "Save.png")]
+    [Command.Basic("PixiEditor.File.SaveAsNew", true, "SAVE_AS", "SAVE_IMAGE_AS", CanExecute = "PixiEditor.HasDocument", Key = Key.S, Modifiers = KeyModifiers.Control | KeyModifiers.Shift, IconPath = "Save.png")]
+    public async Task<bool> SaveActiveDocument(bool asNew)
     {
         DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         if (doc is null)
             return false;
-        return SaveDocument(doc, asNew);
+        return await SaveDocument(doc, asNew);
     }
 
-    public bool SaveDocument(DocumentViewModel document, bool asNew)
+    public async Task<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)
+            var result = await Exporter.TrySaveWithDialog(document);
+            if (result.Result == DialogSaveResult.Cancelled)
                 return false;
-            if (result != DialogSaveResult.Success)
+            if (result.Result != DialogSaveResult.Success)
             {
-                ShowSaveError(result);
+                ShowSaveError(result.Result);
                 return false;
             }
-            finalPath = path;
-            AddRecentlyOpened(path);
+
+            finalPath = result.Path;
+            AddRecentlyOpened(result.Path);
         }
         else
         {
@@ -336,7 +342,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
     ///     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)]
+    [Command.Basic("PixiEditor.File.Export", "EXPORT", "EXPORT_IMAGE", CanExecute = "PixiEditor.HasDocument", Key = Key.E, Modifiers = KeyModifiers.Control)]
     public void ExportFile()
     {
         DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;

+ 28 - 38
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/IoViewModel.cs

@@ -2,6 +2,8 @@
 using System.Windows;
 using System.Windows.Input;
 using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Input;
 using Avalonia.Interactivity;
 using CommunityToolkit.Mvvm.Input;
@@ -30,10 +32,10 @@ internal class IoViewModel : SubViewModel<ViewModelMain>
     private bool? drawingWithRight;
     private bool startedWithEraser;
 
-    public RelayCommand MouseMoveCommand { get; set; }
-    public RelayCommand MouseDownCommand { get; set; }
+    public RelayCommand<MouseOnCanvasEventArgs> MouseMoveCommand { get; set; }
+    public RelayCommand<MouseOnCanvasEventArgs> MouseDownCommand { get; set; }
     public RelayCommand PreviewMouseMiddleButtonCommand { get; set; }
-    public RelayCommand MouseUpCommand { get; set; }
+    public RelayCommand<MouseOnCanvasEventArgs> MouseUpCommand { get; set; }
 
     private MouseInputFilter mouseFilter = new();
     private KeyboardInputFilter keyboardFilter = new();
@@ -41,13 +43,21 @@ internal class IoViewModel : SubViewModel<ViewModelMain>
     public IoViewModel(ViewModelMain owner)
         : base(owner)
     {
-        MouseDownCommand = new RelayCommand(mouseFilter.MouseDownInlet);
-        MouseMoveCommand = new RelayCommand(mouseFilter.MouseMoveInlet);
-        MouseUpCommand = new RelayCommand(mouseFilter.MouseUpInlet);
+        MouseDownCommand = new RelayCommand<MouseOnCanvasEventArgs>(mouseFilter.MouseDownInlet);
+        MouseMoveCommand = new RelayCommand<MouseOnCanvasEventArgs>(mouseFilter.MouseMoveInlet);
+        MouseUpCommand = new RelayCommand<MouseOnCanvasEventArgs>(mouseFilter.MouseUpInlet);
         PreviewMouseMiddleButtonCommand = new RelayCommand(OnPreviewMiddleMouseButton);
-        GlobalMouseHook.Instance.OnMouseUp += mouseFilter.MouseUpInlet;
+        // TODO: Implement mouse capturing
+        //GlobalMouseHook.Instance.OnMouseUp += mouseFilter.MouseUpInlet;
 
-        InputManager.Current.PreProcessInput += Current_PreProcessInput;
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+        {
+            desktop.MainWindow.KeyDown += MainWindowKeyDown;
+            desktop.MainWindow.KeyUp += MainWindowKeyUp;
+
+            desktop.MainWindow.Deactivated += keyboardFilter.DeactivatedInlet;
+            desktop.MainWindow.Deactivated += mouseFilter.DeactivatedInlet;
+        }
 
         mouseFilter.OnMouseDown += OnMouseDown;
         mouseFilter.OnMouseMove += OnMouseMove;
@@ -58,9 +68,6 @@ internal class IoViewModel : SubViewModel<ViewModelMain>
 
         keyboardFilter.OnConvertedKeyDown += OnConvertedKeyDown;
         keyboardFilter.OnConvertedKeyUp += OnConvertedKeyDown;
-
-        Application.Current.Deactivated += keyboardFilter.DeactivatedInlet;
-        Application.Current.Deactivated += mouseFilter.DeactivatedInlet;
     }
 
     private void OnConvertedKeyDown(object? sender, FilteredKeyEventArgs args)
@@ -75,36 +82,19 @@ internal class IoViewModel : SubViewModel<ViewModelMain>
         Owner.ToolsSubViewModel.ConvertedKeyUpInlet(args);
     }
 
-    private void Current_PreProcessInput(object sender, PreProcessInputEventArgs e)
+    private void MainWindowKeyDown(object? sender, KeyEventArgs e)
     {
-        if (e is { StagingItem: { Input: { } } })
-        {
-            InputEventArgs inputEvent = e.StagingItem.Input;
+        keyboardFilter.KeyDownInlet(e);
+    }
 
-            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 MainWindowKeyUp(object? sender, KeyEventArgs e)
+    {
+        keyboardFilter.KeyUpInlet(e);
     }
 
     private void OnKeyDown(object? sender, FilteredKeyEventArgs args)
     {
-        ProcessShortcutDown(args.IsRepeat, args.Key);
+        ProcessShortcutDown(args.IsRepeat, args.Key, args.Modifiers);
         Owner.DocumentManagerSubViewModel.ActiveDocument?.EventInlet.OnKeyDown(args.Key);
     }
 
@@ -131,7 +121,7 @@ internal class IoViewModel : SubViewModel<ViewModelMain>
         return tool;
     }
 
-    private void ProcessShortcutDown(bool isRepeat, Key key)
+    private void ProcessShortcutDown(bool isRepeat, Key key, KeyModifiers argsModifiers)
     {
         HandleTransientKey(key);
         if (isRepeat && Owner.ShortcutController.LastCommands != null &&
@@ -140,7 +130,7 @@ internal class IoViewModel : SubViewModel<ViewModelMain>
             Owner.ToolsSubViewModel.HandleToolRepeatShortcutDown();
         }
 
-        Owner.ShortcutController.KeyPressed(key, Keyboard.Modifiers);
+        Owner.ShortcutController.KeyPressed(key, argsModifiers);
     }
 
     private void OnKeyUp(object? sender, FilteredKeyEventArgs args)
@@ -224,7 +214,7 @@ internal class IoViewModel : SubViewModel<ViewModelMain>
         tools.SetActiveTool<EraserToolViewModel>(true);
     }
     
-    private void OnPreviewMiddleMouseButton(object sender)
+    private void OnPreviewMiddleMouseButton()
     {
         Owner.ToolsSubViewModel.SetActiveTool<MoveViewportToolViewModel>(true);
     }

+ 27 - 15
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/LayersViewModel.cs

@@ -1,10 +1,16 @@
 using System.Collections.Generic;
 using System.Collections.Immutable;
 using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Input;
+using Avalonia.Media;
 using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
 using PixiEditor.Avalonia.Exceptions.Exceptions;
+using PixiEditor.Avalonia.Helpers.Extensions;
 using PixiEditor.ChangeableDocument.Enums;
 using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Extensions.Common.Localization;
@@ -328,13 +334,13 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
         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()
+    public async Task ImportReferenceLayer()
     {
         var doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
         if (doc is null)
             return;
 
-        string path = OpenReferenceLayerFilePicker();
+        string path = await OpenReferenceLayerFilePicker();
         if (path is null)
             return;
 
@@ -349,27 +355,33 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
             return;
         }
 
-        byte[] pixels = new byte[bitmap.PixelWidth * bitmap.PixelHeight * 4];
-        bitmap.CopyPixels(pixels, bitmap.PixelWidth * 4, 0);
+        byte[] pixels = bitmap.ExtractPixels();
 
-        VecI size = new VecI(bitmap.PixelWidth, bitmap.PixelHeight);
+        VecI size = new VecI(bitmap.PixelSize.Width, bitmap.PixelSize.Height);
 
         doc.Operations.ImportReferenceLayer(
             pixels.ToImmutableArray(), 
             size);
     }
 
-    private string OpenReferenceLayerFilePicker()
+    private async Task<string> OpenReferenceLayerFilePicker()
     {
         var imagesFilter = new FileTypeDialogDataSet(FileTypeDialogDataSet.SetKind.Images).GetFormattedTypes();
-        OpenFileDialog dialog = new OpenFileDialog
+        if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
         {
-            Title = new LocalizedString("REFERENCE_LAYER_PATH"),
-            CheckPathExists = true,
-            Filter = imagesFilter
-        };
+            var filePicker = await desktop.MainWindow.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions()
+            {
+                Title = new LocalizedString("REFERENCE_LAYER_PATH"),
+                FileTypeFilter = imagesFilter,
+            });
 
-        return (bool)dialog.ShowDialog() ? dialog.FileName : null;
+            if (filePicker is null)
+                return null;
+
+            return filePicker[0].Path.AbsolutePath;
+        }
+
+        return null;
     }
 
     [Command.Basic("PixiEditor.Layer.DeleteReferenceLayer", "DELETE_REFERENCE_LAYER", "DELETE_REFERENCE_LAYER", CanExecute = "PixiEditor.Layer.ReferenceLayerExists", IconPath = "Trash.png")]
@@ -413,12 +425,12 @@ internal class LayersViewModel : SubViewModel<ViewModelMain>
     }
 
     [Evaluator.Icon("PixiEditor.Layer.ToggleReferenceLayerTopMostIcon")]
-    public ImageSource GetAboveEverythingReferenceLayerIcon()
+    public IImage 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 Bitmap("pack://application:,,,/Images/ReferenceLayerBelow.png");
 
-        return new BitmapImage(new Uri("pack://application:,,,/Images/ReferenceLayerAbove.png"));
+        return new Bitmap("pack://application:,,,/Images/ReferenceLayerAbove.png");
     }
 }

+ 60 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/RegistryViewModel.cs

@@ -0,0 +1,60 @@
+using System.Diagnostics;
+using System.Security.AccessControl;
+using System.Windows;
+using Microsoft.Win32;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Dialogs;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+internal class RegistryViewModel : SubViewModel<ViewModelMain>
+{
+    public RegistryViewModel(ViewModelMain owner) : base(owner)
+    {
+
+    }
+
+    // TODO: PixiEditor shouldn't handle any registry stuff. It should be handled by the installers.
+
+    /*private void OwnerOnStartupEvent(object sender, EventArgs e)
+    {
+#if !STEAM // Something is wrong with Steam version of PixiEditor, so we disable this feature for now. Steam will have native association soon.
+        // Check if lospec-palette is associated in registry
+        if (!LospecPaletteIsAssociated())
+        {
+            // Associate lospec-palette URL protocol
+            RegistryHelpers.TryAssociate(AssociateLospecPaletteInRegistry, "FAILED_ASSOCIATE_LOSPEC");
+        }
+#endif
+    }
+
+    private void AssociateLospecPaletteInRegistry()
+    {
+        try
+        {
+            using RegistryKey key = Registry.ClassesRoot.CreateSubKey("lospec-palette");
+
+            key.SetValue("", "PixiEditor");
+            key.SetValue("URL Protocol", "");
+
+            // Create a new key
+            using RegistryKey shellKey = key.CreateSubKey("shell");
+            // Create a new key
+            using RegistryKey openKey = shellKey.CreateSubKey("open");
+            // Create a new key
+            using RegistryKey commandKey = openKey.CreateSubKey("command");
+            // Set the default value of the key
+            commandKey.SetValue("", $"\"{Process.GetCurrentProcess().MainModule?.FileName}\" \"%1\"");
+        }
+        catch
+        {
+            NoticeDialog.Show("Failed to associate lospec-palette protocol", "Error");
+        }
+    }
+
+    private bool LospecPaletteIsAssociated()
+    {
+        // Check if HKEY_CLASSES_ROOT\lospec-palette is present
+        return RegistryHelpers.IsKeyPresentInRoot("lospec-palette");
+    }*/
+}

+ 55 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/SearchViewModel.cs

@@ -0,0 +1,55 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.Commands.Attributes.Commands;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.Search", "SEARCH")]
+internal class SearchViewModel : SubViewModel<ViewModelMain>
+{
+    private bool searchWindowOpen;
+    private bool selectAll;
+    private string searchTerm;
+
+    public bool SearchWindowOpen
+    {
+        get => searchWindowOpen;
+        set => SetProperty(ref searchWindowOpen, value);
+    }
+
+    public string SearchTerm
+    {
+        get => searchTerm;
+        set => SetProperty(ref searchTerm, value);
+    }
+
+    public bool SelectAll
+    {
+        get => selectAll;
+        set => SetProperty(ref selectAll, value);
+    }
+
+    public SearchViewModel(ViewModelMain owner) : base(owner)
+    { }
+
+    [Evaluator.CanExecute("PixiEditor.Search.CanOpenSearchWindow")]
+    public bool CanToggleSeachWindow() => !ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument?.Busy ?? true;
+
+    [Command.Basic("PixiEditor.Search.Toggle", "", "COMMAND_SEARCH", "OPEN_COMMAND_SEARCH", Key = Key.K, Modifiers = KeyModifiers.Control, CanExecute = "PixiEditor.Search.CanOpenSearchWindow")]
+    public void ToggleSearchWindow(string searchTerm)
+    {
+        SelectAll = true;
+        SearchWindowOpen = !SearchWindowOpen;
+        if (SearchWindowOpen)
+        {
+            SearchTerm = searchTerm;
+        }
+    }
+
+    public void OpenSearchWindow(string searchTerm, bool selectAll = true)
+    {
+        SelectAll = selectAll;
+        SearchWindowOpen = true;
+        SearchTerm = searchTerm;
+    }
+}

+ 103 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/StylusViewModel.cs

@@ -0,0 +1,103 @@
+using System.Windows.Input;
+using PixiEditor.Extensions.Common.UserPreferences;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.ViewModels.SubViewModels.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+[Command.Group("PixiEditor.Stylus", "STYLUS")]
+internal class StylusViewModel : SubViewModel<ViewModelMain>
+{
+    private bool isPenModeEnabled;
+    private bool useTouchGestures;
+
+    public bool ToolSetByStylus { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether touch gestures are enabled even when the MoveViewportTool and ZoomTool are not selected.
+    /// </summary>
+    public bool IsPenModeEnabled
+    {
+        get => isPenModeEnabled;
+        set
+        {
+            if (SetProperty(ref isPenModeEnabled, value))
+            {
+                IPreferences.Current.UpdateLocalPreference(nameof(IsPenModeEnabled), value);
+                UpdateUseTouchGesture();
+            }
+        }
+    }
+
+    public bool UseTouchGestures
+    {
+        get => useTouchGestures;
+        set => SetProperty(ref useTouchGestures, value);
+    }
+
+    private ToolViewModel PreviousTool { get; set; }
+
+    public StylusViewModel(ViewModelMain owner)
+        : base(owner)
+    {
+        isPenModeEnabled = IPreferences.Current.GetLocalPreference<bool>(nameof(IsPenModeEnabled));
+        Owner.ToolsSubViewModel.AddPropertyChangedCallback(nameof(ToolsViewModel.ActiveTool), UpdateUseTouchGesture);
+
+        UpdateUseTouchGesture();
+    }
+
+    [Command.Basic("PixiEditor.Stylus.TogglePenMode", "TOGGLE_PEN_MODE", "TOGGLE_PEN_MODE", IconPath = "penMode.png")]
+    public void TogglePenMode()
+    {
+        IsPenModeEnabled = !IsPenModeEnabled;
+    }
+
+    private void UpdateUseTouchGesture()
+    {
+        UseTouchGestures = Owner.ToolsSubViewModel.ActiveTool is MoveViewportToolViewModel or ZoomToolViewModel || IsPenModeEnabled;
+    }
+
+    //TODO: Fix stylus support
+    /*[Command.Internal("PixiEditor.Stylus.StylusOutOfRange")]
+    public void StylusOutOfRange(StylusEventArgs e)
+    {
+        //Owner.BitmapManager.UpdateHighlightIfNecessary(true);
+    }*/
+
+    /*[Command.Internal("PixiEditor.Stylus.StylusSystemGesture")]
+    public void StylusSystemGesture(StylusSystemGestureEventArgs e)
+    {
+        if (e.SystemGesture is SystemGesture.Drag or SystemGesture.Tap)
+        {
+            return;
+        }
+
+        e.Handled = true;
+    }*/
+
+    /*[Command.Internal("PixiEditor.Stylus.StylusDown")]
+    public void StylusDown(StylusButtonEventArgs e)
+    {
+        e.Handled = true;
+
+        if (e.StylusButton.Guid == StylusPointProperties.TipButton.Id && e.Inverted)
+        {
+            PreviousTool = Owner.ToolsSubViewModel.ActiveTool;
+            Owner.ToolsSubViewModel.SetActiveTool<EraserToolViewModel>(true);
+            ToolSetByStylus = true;
+        }
+    }*/
+
+    /*[Command.Internal("PixiEditor.Stylus.StylusUp")]
+    public void StylusUp(StylusButtonEventArgs e)
+    {
+        e.Handled = true;
+
+        if (ToolSetByStylus && e.StylusButton.Guid == StylusPointProperties.TipButton.Id && e.Inverted)
+        {
+            Owner.ToolsSubViewModel.SetActiveTool(PreviousTool, false);
+        }
+    }*/
+}

+ 1 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ToolsViewModel.cs

@@ -7,6 +7,7 @@ using PixiEditor.DrawingApi.Core.Numerics;
 using PixiEditor.Extensions.Common.UserPreferences;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Containers;
+using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Enums;
 using PixiEditor.Models.Events;
 using PixiEditor.ViewModels.SubViewModels.Document;

+ 56 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/ViewportWindowViewModel.cs

@@ -0,0 +1,56 @@
+using ChunkyImageLib.DataHolders;
+using CommunityToolkit.Mvvm.Input;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
+using PixiEditor.ViewModels.SubViewModels;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Main;
+
+namespace PixiEditor.ViewModels;
+#nullable enable
+internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>
+{
+    public DocumentViewModel Document { get; }
+
+    public ExecutionTrigger<VecI> CenterViewportTrigger { get; } = new ExecutionTrigger<VecI>();
+    public ExecutionTrigger<double> ZoomViewportTrigger { get; } = new ExecutionTrigger<double>();
+
+    public string Index => Owner.CalculateViewportIndex(this) ?? "";
+
+    public RelayCommand RequestCloseCommand { get; }
+
+    private bool _flipX;
+
+    public bool FlipX
+    {
+        get => _flipX;
+        set
+        {
+            _flipX = value;
+            OnPropertyChanged(nameof(FlipX));
+        }
+    }
+    
+    private bool _flipY;
+
+    public bool FlipY
+    {
+        get => _flipY;
+        set
+        {
+            _flipY = value;
+            OnPropertyChanged(nameof(FlipY));
+        }
+    }
+
+    public void IndexChanged()
+    {
+        OnPropertyChanged(nameof(Index));
+    }
+
+    public ViewportWindowViewModel(WindowViewModel owner, DocumentViewModel document) : base(owner)
+    {
+        Document = document;
+        RequestCloseCommand = new RelayCommand(() => ViewModelMain.Current?.WindowSubViewModel.OnViewportWindowCloseButtonPressed(this));
+    }
+}

+ 190 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/WindowViewModel.cs

@@ -0,0 +1,190 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Avalonia.Input;
+using CommunityToolkit.Mvvm.Input;
+using PixiEditor.Avalonia.Views;
+using PixiEditor.Models.Commands;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using Command = PixiEditor.Models.Commands.Attributes.Commands.Command;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main;
+
+#nullable enable
+[Command.Group("PixiEditor.Window", "WINDOWS")]
+internal class WindowViewModel : SubViewModel<ViewModelMain>
+{
+    private CommandController commandController;
+    private ShortcutPopup? shortcutPopup;
+    private ShortcutPopup ShortcutPopup => shortcutPopup ??= new(commandController);
+    public RelayCommand<string> ShowAvalonDockWindowCommand { get; set; }
+    public ObservableCollection<ViewportWindowViewModel> Viewports { get; } = new();
+    public event EventHandler<ViewportWindowViewModel>? ActiveViewportChanged;
+
+    private object? activeWindow;
+    public object? ActiveWindow
+    {
+        get => activeWindow;
+        set
+        {
+            if (activeWindow == value)
+                return;
+            activeWindow = value;
+            OnPropertyChanged(nameof(ActiveWindow));
+            if (activeWindow is ViewportWindowViewModel viewport)
+                ActiveViewportChanged?.Invoke(this, viewport);
+        }
+    }
+
+    public WindowViewModel(ViewModelMain owner, CommandController commandController)
+        : base(owner)
+    {
+        ShowAvalonDockWindowCommand = new(ShowAvalonDockWindow);
+        this.commandController = commandController;
+    }
+
+    [Command.Basic("PixiEditor.Window.CreateNewViewport", "NEW_WINDOW_FOR_IMG", "NEW_WINDOW_FOR_IMG", IconPath = "@Images/Plus-square.png", CanExecute = "PixiEditor.HasDocument")]
+    public void CreateNewViewport()
+    {
+        var doc = ViewModelMain.Current?.DocumentManagerSubViewModel.ActiveDocument;
+        if (doc is null)
+            return;
+        CreateNewViewport(doc);
+    }
+    
+    [Command.Basic("PixiEditor.Window.CenterActiveViewport", "CENTER_ACTIVE_VIEWPORT", "CENTER_ACTIVE_VIEWPORT", CanExecute = "PixiEditor.HasDocument")]
+    public void CenterCurrentViewport()
+    {
+        if (ActiveWindow is ViewportWindowViewModel viewport)
+            viewport.CenterViewportTrigger.Execute(this, viewport.Document.SizeBindable);
+    }
+    
+    [Command.Basic("PixiEditor.Window.FlipHorizontally", "FLIP_VIEWPORT_HORIZONTALLY", "FLIP_VIEWPORT_HORIZONTALLY", CanExecute = "PixiEditor.HasDocument", IconPath = "FlipHorizontal.png")]
+    public void FlipViewportHorizontally()
+    {
+        if (ActiveWindow is ViewportWindowViewModel viewport)
+        {
+            viewport.FlipX = !viewport.FlipX;
+        }
+    }
+    
+    [Command.Basic("PixiEditor.Window.FlipVertically", "FLIP_VIEWPORT_VERTICALLY", "FLIP_VIEWPORT_VERTICALLY", CanExecute = "PixiEditor.HasDocument", IconPath = "FlipVertical.png")]
+    public void FlipViewportVertically()
+    {
+        if (ActiveWindow is ViewportWindowViewModel viewport)
+        {
+            viewport.FlipY = !viewport.FlipY;
+        }
+    }
+
+    public void CreateNewViewport(DocumentViewModel doc)
+    {
+        Viewports.Add(new ViewportWindowViewModel(this, doc));
+        foreach (var viewport in Viewports.Where(vp => vp.Document == doc))
+        {
+            viewport.IndexChanged();
+        }
+    }
+
+    public void MakeDocumentViewportActive(DocumentViewModel? doc)
+    {
+        if (doc is null)
+        {
+            ActiveWindow = null;
+            Owner.DocumentManagerSubViewModel.MakeActiveDocumentNull();
+            return;
+        }
+        ActiveWindow = Viewports.Where(viewport => viewport.Document == doc).FirstOrDefault();
+    }
+
+    public string CalculateViewportIndex(ViewportWindowViewModel viewport)
+    {
+        ViewportWindowViewModel[] viewports = Viewports.Where(a => a.Document == viewport.Document).ToArray();
+        if (viewports.Length < 2)
+            return "";
+        return $"[{Array.IndexOf(viewports, viewport) + 1}]";
+    }
+
+    public void OnViewportWindowCloseButtonPressed(ViewportWindowViewModel viewport)
+    {
+        var viewports = Viewports.Where(vp => vp.Document == viewport.Document).ToArray();
+        if (viewports.Length == 1)
+        {
+            Owner.DisposeDocumentWithSaveConfirmation(viewport.Document);
+        }
+        else
+        {
+            Viewports.Remove(viewport);
+            foreach (var sibling in viewports)
+            {
+                sibling.IndexChanged();
+            }
+        }
+    }
+
+    public void CloseViewportsForDocument(DocumentViewModel document)
+    {
+        var viewports = Viewports.Where(vp => vp.Document == document).ToArray();
+        foreach (ViewportWindowViewModel viewport in viewports)
+        {
+            Viewports.Remove(viewport);
+        }
+    }
+
+    [Command.Basic("PixiEditor.Window.OpenSettingsWindow", "OPEN_SETTINGS", "OPEN_SETTINGS_DESCRIPTIVE", Key = Key.OemComma, Modifiers = KeyModifiers.Control)]
+    public static void OpenSettingsWindow(int page)
+    {
+        if (page < 0)
+        {
+            page = 0;
+        }
+
+        var settings = new SettingsWindow(page);
+        settings.Show();
+    }
+
+    [Command.Basic("PixiEditor.Window.OpenStartupWindow", "OPEN_STARTUP_WINDOW", "OPEN_STARTUP_WINDOW")]
+    public void OpenHelloThereWindow()
+    {
+        new HelloTherePopup(Owner.FileSubViewModel).Show();
+    }
+
+    [Command.Basic("PixiEditor.Window.OpenShortcutWindow", "OPEN_SHORTCUT_WINDOW", "OPEN_SHORTCUT_WINDOW", Key = Key.F1)]
+    public void ShowShortcutWindow()
+    {
+        ShortcutPopup.Show();
+        ShortcutPopup.Activate();
+    }
+
+    [Command.Basic("PixiEditor.Window.OpenPalettesBrowserWindow", "OPEN_PALETTE_BROWSER", "OPEN_PALETTE_BROWSER",
+        IconPath = "Database.png")]
+    public void ShowPalettesBrowserWindow()
+    {
+        PalettesBrowser.Open(Owner.ColorsSubViewModel.PaletteProvider, Owner.ColorsSubViewModel.ImportPaletteCommand,
+            Owner.DocumentManagerSubViewModel.ActiveDocument?.Palette);
+    }
+        
+    [Command.Basic("PixiEditor.Window.OpenAboutWindow", "OPEN_ABOUT_WINDOW", "OPEN_ABOUT_WINDOW")]
+    public void OpenAboutWindow()
+    {
+        new AboutPopup().Show();
+    }
+
+    [Command.Basic("PixiEditor.Window.OpenNavigationWindow", "navigation", "OPEN_NAVIGATION_WINDOW", "OPEN_NAVIGATION_WINDOW")]
+    public static void ShowAvalonDockWindow(string id)
+    {
+        if (MainWindow.Current?.LayoutRoot?.Manager?.Layout == null) return;
+        var anchorables = new List<LayoutAnchorable>(MainWindow.Current.LayoutRoot.Manager.Layout
+            .Descendents()
+            .OfType<LayoutAnchorable>());
+
+        foreach (var la in anchorables)
+        {
+            if (la.ContentId == id)
+            {
+                la.Show();
+                la.IsActive = true;
+            }
+        }
+    }
+}

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/ColorPickerToolViewModel.cs

@@ -107,8 +107,8 @@ internal class ColorPickerToolViewModel : ToolViewModel
     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;
+        bool ctrlDown = (Keyboard.Modifiers & KeyModifiers.Control) != 0;
+        bool shiftDown = (Keyboard.Modifiers & KeyModifiers.Shift) != 0;
         
         UpdateActionDisplay(ctrlDown, shiftDown);
     }

+ 5 - 5
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/LassoToolViewModel.cs

@@ -20,25 +20,25 @@ internal class LassoToolViewModel : ToolViewModel
         ActionDisplay = defaultActionDisplay;
     }
 
-    private SelectionMode modifierKeySelectionMode = SelectionMode.New;
-    public SelectionMode ResultingSelectionMode => modifierKeySelectionMode != SelectionMode.New ? modifierKeySelectionMode : SelectMode;
+    private SelectionMode KeyModifierselectionMode = SelectionMode.New;
+    public SelectionMode ResultingSelectionMode => KeyModifierselectionMode != SelectionMode.New ? KeyModifierselectionMode : SelectMode;
 
     public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
     {
         if (shiftIsDown)
         {
             ActionDisplay = "LASSO_TOOL_ACTION_DISPLAY_SHIFT";
-            modifierKeySelectionMode = SelectionMode.Add;
+            KeyModifierselectionMode = SelectionMode.Add;
         }
         else if (ctrlIsDown)
         {
             ActionDisplay = "LASSO_TOOL_ACTION_DISPLAY_CTRL";
-            modifierKeySelectionMode = SelectionMode.Subtract;
+            KeyModifierselectionMode = SelectionMode.Subtract;
         }
         else
         {
             ActionDisplay = defaultActionDisplay;
-            modifierKeySelectionMode = SelectionMode.New;
+            KeyModifierselectionMode = SelectionMode.New;
         }
     }
 

+ 5 - 5
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/Tools/Tools/SelectToolViewModel.cs

@@ -26,25 +26,25 @@ internal class SelectToolViewModel : ToolViewModel
         Cursor = Cursors.Cross;
     }
 
-    private SelectionMode modifierKeySelectionMode = SelectionMode.New;
-    public SelectionMode ResultingSelectionMode => modifierKeySelectionMode != SelectionMode.New ? modifierKeySelectionMode : SelectMode;
+    private SelectionMode KeyModifierselectionMode = SelectionMode.New;
+    public SelectionMode ResultingSelectionMode => KeyModifierselectionMode != SelectionMode.New ? KeyModifierselectionMode : SelectMode;
 
     public override void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
     {
         if (shiftIsDown)
         {
             ActionDisplay = new LocalizedString("SELECT_TOOL_ACTION_DISPLAY_SHIFT");
-            modifierKeySelectionMode = SelectionMode.Add;
+            KeyModifierselectionMode = SelectionMode.Add;
         }
         else if (ctrlIsDown)
         {
             ActionDisplay = new LocalizedString("SELECT_TOOL_ACTION_DISPLAY_CTRL");
-            modifierKeySelectionMode = SelectionMode.Subtract;
+            KeyModifierselectionMode = SelectionMode.Subtract;
         }
         else
         {
             ActionDisplay = defaultActionDisplay;
-            modifierKeySelectionMode = SelectionMode.New;
+            KeyModifierselectionMode = SelectionMode.New;
         }
     }
 

+ 21 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/ViewModelBase.cs

@@ -4,4 +4,25 @@ namespace PixiEditor.Avalonia.ViewModels;
 
 public class ViewModelBase : ObservableObject
 {
+    public void AddPropertyChangedCallback(string propertyName, Action action)
+    {
+        if (action == null)
+        {
+            throw new ArgumentNullException(nameof(propertyName));
+        }
+
+        if (string.IsNullOrWhiteSpace(propertyName))
+        {
+            PropertyChanged += (_, _) => action();
+            return;
+        }
+
+        PropertyChanged += (sender, e) =>
+        {
+            if (e.PropertyName == propertyName)
+            {
+                action();
+            }
+        };
+    }
 }

+ 1 - 6
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/ViewModelMain.cs

@@ -1,22 +1,17 @@
 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;
@@ -103,7 +98,7 @@ internal partial class ViewModelMain : ViewModelBase
     public ViewModelMain(IServiceProvider serviceProvider)
     {
         Current = this;
-        ActionDisplays = new ActionDisplayList(() => RaisePropertyChanged(nameof(ActiveActionDisplay)));
+        ActionDisplays = new ActionDisplayList(() => OnPropertyChanged(nameof(ActiveActionDisplay)));
     }
 
     public void Setup(IServiceProvider services)

+ 1 - 0
src/PixiEditor/ViewModels/SubViewModels/Main/StylusViewModel.cs

@@ -43,6 +43,7 @@ internal class StylusViewModel : SubViewModel<ViewModelMain>
         : base(owner)
     {
         isPenModeEnabled = IPreferences.Current.GetLocalPreference<bool>(nameof(IsPenModeEnabled));
+        //TODO: It's very likely, that this won't work, because I implemented AddPropertyChangedCallback in ViewModelBase instead of ObservableObject
         Owner.ToolsSubViewModel.AddPropertyChangedCallback(nameof(ToolsViewModel.ActiveTool), UpdateUseTouchGesture);
 
         UpdateUseTouchGesture();