فهرست منبع

intergrate changes from master into avalonia

Equbuxu 1 سال پیش
والد
کامیت
ea8f9b8c87
34فایلهای تغییر یافته به همراه909 افزوده شده و 158 حذف شده
  1. 7 2
      src/PixiEditor.AvaloniaUI/Helpers/Converters/ReciprocalConverter.cs
  2. 15 3
      src/PixiEditor.AvaloniaUI/Helpers/CrashHelper.cs
  3. 1 0
      src/PixiEditor.AvaloniaUI/Helpers/ServiceCollectionHelpers.cs
  4. 29 7
      src/PixiEditor.AvaloniaUI/Initialization/ClassicDesktopEntry.cs
  5. 2 2
      src/PixiEditor.AvaloniaUI/Models/AppExtensions/ExtensionLoader.cs
  6. 3 0
      src/PixiEditor.AvaloniaUI/Models/Commands/CommandController.cs
  7. 44 0
      src/PixiEditor.AvaloniaUI/Models/Commands/CommandLog/CommandLog.cs
  8. 19 0
      src/PixiEditor.AvaloniaUI/Models/Commands/CommandLog/CommandLogEntry.cs
  9. 18 2
      src/PixiEditor.AvaloniaUI/Models/Commands/CommandMethods.cs
  10. 48 23
      src/PixiEditor.AvaloniaUI/Models/Controllers/InputDevice/MouseUpdateController.cs
  11. 119 0
      src/PixiEditor.AvaloniaUI/Models/Controllers/InputDevice/MouseUpdateControllerSession.cs
  12. 4 1
      src/PixiEditor.AvaloniaUI/Models/Dialogs/OptionsDialog.cs
  13. 4 1
      src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs
  14. 258 39
      src/PixiEditor.AvaloniaUI/Models/ExceptionHandling/CrashReport.cs
  15. 40 18
      src/PixiEditor.AvaloniaUI/Models/IO/Exporter.cs
  16. 14 3
      src/PixiEditor.AvaloniaUI/Models/IO/Importer.cs
  17. 128 0
      src/PixiEditor.AvaloniaUI/Models/IO/PaletteParsers/DeluxePaintParser.cs
  18. 2 2
      src/PixiEditor.AvaloniaUI/Models/Rendering/CanvasUpdater.cs
  19. 1 1
      src/PixiEditor.AvaloniaUI/Models/Rendering/MemberPreviewUpdater.cs
  20. 27 3
      src/PixiEditor.AvaloniaUI/ViewModels/CrashReportViewModel.cs
  21. 5 1
      src/PixiEditor.AvaloniaUI/ViewModels/Document/TransformOverlays/DocumentTransformViewModel.cs
  22. 21 15
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/FileViewModel.cs
  23. 2 2
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/MiscViewModel.cs
  24. 5 0
      src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/UpdateViewModel.cs
  25. 1 0
      src/PixiEditor.AvaloniaUI/Views/Dialogs/OptionPopup.axaml.cs
  26. 8 2
      src/PixiEditor.AvaloniaUI/Views/Layers/FolderControl.axaml.cs
  27. 7 1
      src/PixiEditor.AvaloniaUI/Views/Layers/LayerControl.axaml.cs
  28. 5 5
      src/PixiEditor.AvaloniaUI/Views/Main/Viewport.axaml.cs
  29. 43 8
      src/PixiEditor.AvaloniaUI/Views/MainWindow.axaml.cs
  30. 8 8
      src/PixiEditor.AvaloniaUI/Views/Overlays/BrushShapeOverlay/BrushShapeOverlay.cs
  31. 7 1
      src/PixiEditor.AvaloniaUI/Views/Overlays/LineToolOverlay/LineToolOverlay.cs
  32. 8 2
      src/PixiEditor.AvaloniaUI/Views/Overlays/SymmetryOverlay/SymmetryOverlay.cs
  33. 2 2
      src/PixiEditor.AvaloniaUI/Views/Windows/HelloTherePopup.axaml.cs
  34. 4 4
      src/PixiEditor.Extensions/Common/Localization/LocalizedString.cs

+ 7 - 2
src/PixiEditor.AvaloniaUI/Helpers/Converters/ReciprocalConverter.cs

@@ -8,8 +8,13 @@ internal class ReciprocalConverter : SingleInstanceConverter<ReciprocalConverter
     {
         if (value is not double num)
             return AvaloniaProperty.UnsetValue;
+
+        double result;
         if (parameter is not double mult)
-            return 1 / num;
-        return mult / num;
+            result = 1 / num;
+        else
+            result = mult / num;
+
+        return Math.Clamp(result, 1e-15, 1e15);
     }
 }

+ 15 - 3
src/PixiEditor.AvaloniaUI/Helpers/CrashHelper.cs

@@ -110,16 +110,28 @@ internal class CrashHelper
             }
         }
     }
+    
+    public static void SendExceptionInfoToWebhook(Exception e, bool wait = false,
+        [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
+    {
+        // TODO: quadruple check that this Task.Run is actually acceptable here
+        // I think it might not be because there is stuff about the main window in the crash report, so Avalonia is touched from a different thread (is it bad for avalonia?)
+        var task = Task.Run(() => SendExceptionInfoToWebhookAsync(e, filePath, memberName));
+        if (wait)
+        {
+            task.Wait();
+        }
+    }
 
-    public static async Task SendExceptionInfoToWebhook(Exception e, [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
+    public static async Task SendExceptionInfoToWebhookAsync(Exception e, [CallerFilePath] string filePath = "<unknown>", [CallerMemberName] string memberName = "<unknown>")
     {
         // TODO: Proper DebugBuild checking
         /*if (DebugViewModel.IsDebugBuild)
             return;*/
-        await SendReportTextToWebhook(CrashReport.Generate(e), $"{filePath}; Method {memberName}");
+        await SendReportTextToWebhookAsync(CrashReport.Generate(e), $"{filePath}; Method {memberName}");
     }
 
-    public static async Task SendReportTextToWebhook(CrashReport report, string catchLocation = null)
+    public static async Task SendReportTextToWebhookAsync(CrashReport report, string catchLocation = null)
     {
         string reportText = report.ReportText;
         if (catchLocation is not null)

+ 1 - 0
src/PixiEditor.AvaloniaUI/Helpers/ServiceCollectionHelpers.cs

@@ -96,6 +96,7 @@ internal static class ServiceCollectionHelpers
             // Palette Parsers
             .AddSingleton<PaletteFileParser, JascFileParser>()
             .AddSingleton<PaletteFileParser, ClsFileParser>()
+            .AddSingleton<PaletteFileParser, DeluxePaintParser>()
             .AddSingleton<PaletteFileParser, PngPaletteParser>()
             .AddSingleton<PaletteFileParser, PaintNetTxtParser>()
             .AddSingleton<PaletteFileParser, HexPaletteParser>()

+ 29 - 7
src/PixiEditor.AvaloniaUI/Initialization/ClassicDesktopEntry.cs

@@ -49,9 +49,24 @@ internal class ClassicDesktopEntry
 
         if (ParseArgument("--crash (\"?)([A-z0-9:\\/\\ -_.]+)\\1", arguments, out Group[] groups))
         {
-            CrashReport report = CrashReport.Parse(groups[2].Value);
-            desktop.MainWindow = new CrashReportDialog(report);
-            desktop.MainWindow.Show();
+            try
+            {
+                CrashReport report = CrashReport.Parse(groups[2].Value);
+                desktop.MainWindow = new CrashReportDialog(report);
+                desktop.MainWindow.Show();
+            }
+            catch (Exception exception)
+            {
+                try
+                {
+                    CrashHelper.SendExceptionInfoToWebhook(exception, true);
+                }
+                finally
+                {
+                    // TODO: find an avalonia replacement for messagebox 
+                    //MessageBox.Show("Fatal error", $"Fatal error while trying to open crash report in App.OnStartup()\n{exception}");
+                }
+            }
             return;
         }
 
@@ -65,10 +80,7 @@ internal class ClassicDesktopEntry
         #endif
 
         InitOperatingSystem();
-        InitPlatform();
-
-        ExtensionLoader extensionLoader = new ExtensionLoader();
-        extensionLoader.LoadExtensions();
+        var extensionLoader = InitApp();
 
         desktop.MainWindow = new MainWindow(extensionLoader);
         desktop.MainWindow.Show();
@@ -80,6 +92,16 @@ internal class ClassicDesktopEntry
         IPlatform.RegisterPlatform(platform);
         platform.PerformHandshake();
     }
+    
+    public ExtensionLoader InitApp()
+    {
+        InitPlatform();
+
+        ExtensionLoader extensionLoader = new ExtensionLoader();
+        extensionLoader.LoadExtensions();
+        
+        return extensionLoader;
+    }
 
     private IPlatform GetActivePlatform()
     {

+ 2 - 2
src/PixiEditor.AvaloniaUI/Models/AppExtensions/ExtensionLoader.cs

@@ -49,7 +49,7 @@ internal class ExtensionLoader
         }
         catch (Exception ex)
         {
-            Task.Run(async () => await CrashHelper.SendExceptionInfoToWebhook(ex));
+            CrashHelper.SendExceptionInfoToWebhook(ex);
         }
     }
 
@@ -86,7 +86,7 @@ internal class ExtensionLoader
         catch (Exception ex)
         {
             //MessageBox.Show(new LocalizedString("ERROR_LOADING_PACKAGE", packageJsonPath), "ERROR");
-            Task.Run(async () => await CrashHelper.SendExceptionInfoToWebhook(ex));
+            CrashHelper.SendExceptionInfoToWebhook(ex);
         }
     }
 

+ 3 - 0
src/PixiEditor.AvaloniaUI/Models/Commands/CommandController.cs

@@ -33,6 +33,8 @@ internal class CommandController
     public CommandCollection Commands { get; }
 
     public List<CommandGroup> CommandGroups { get; }
+    
+    public CommandLog.CommandLog Log { get; }
 
     public OneToManyDictionary<string, Command> FilterCommands { get; }
     
@@ -47,6 +49,7 @@ internal class CommandController
     public CommandController()
     {
         Current ??= this;
+        Log = new CommandLog.CommandLog();
 
         ShortcutsPath = Path.Join(
             Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),

+ 44 - 0
src/PixiEditor.AvaloniaUI/Models/Commands/CommandLog/CommandLog.cs

@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using PixiEditor.AvaloniaUI.Models.Commands.Commands;
+
+namespace PixiEditor.AvaloniaUI.Models.Commands.CommandLog;
+
+internal class CommandLog
+{
+    private readonly List<CommandLogEntry> list = new(MaxEntries);
+
+    private const int MaxEntries = 8;
+
+    public void Log(Command command, bool? canExecute)
+    {
+        if (canExecute.HasValue && !list[0].CanExecute.HasValue)
+        {
+            list[0].CanExecute = canExecute;
+            return;
+        }
+
+        if (list.Count >= MaxEntries)
+        {
+            list.RemoveRange(MaxEntries - 1, list.Count - MaxEntries + 1);
+        }
+
+        list.Insert(0, new CommandLogEntry(command, canExecute, DateTime.Now));
+    }
+
+    public string GetSummary(DateTime relativeTime)
+    {
+        var builder = new StringBuilder();
+
+        foreach (var entry in list)
+        {
+            var relativeSpan = entry.DateTime - relativeTime;
+            string canExecute = entry.CanExecute.HasValue ? entry.CanExecute.ToString()! : "not executed";
+
+            builder.AppendLine($"{entry.Command.InternalName} | CanExecute: {canExecute} | {relativeSpan.TotalSeconds.ToString("F3", CultureInfo.InvariantCulture)}s ago | {entry.DateTime.ToString("O", CultureInfo.InvariantCulture)}");
+        }
+
+        return builder.ToString();
+    }
+}

+ 19 - 0
src/PixiEditor.AvaloniaUI/Models/Commands/CommandLog/CommandLogEntry.cs

@@ -0,0 +1,19 @@
+using PixiEditor.AvaloniaUI.Models.Commands.Commands;
+
+namespace PixiEditor.AvaloniaUI.Models.Commands.CommandLog;
+
+internal class CommandLogEntry
+{
+    public Command Command { get; }
+
+    public bool? CanExecute { get; set; }
+
+    public DateTime DateTime { get; }
+
+    public CommandLogEntry(Command command, bool? commandMethod, DateTime dateTime)
+    {
+        Command = command;
+        CanExecute = commandMethod;
+        DateTime = dateTime;
+    }
+}

+ 18 - 2
src/PixiEditor.AvaloniaUI/Models/Commands/CommandMethods.cs

@@ -20,11 +20,27 @@ internal class CommandMethods
 
     public void Execute(object parameter)
     {
-        if (CanExecute(parameter))
+        var log = CommandController.Current?.Log;
+        ToLog(log, null);
+
+        if (!CanExecute(parameter))
         {
-            _execute(parameter);
+            ToLog(log, false);
+            return;
         }
+        ToLog(log, true);
+
+        _execute(parameter);
     }
 
     public bool CanExecute(object parameter) => _canExecute.CallEvaluate(_command, parameter);
+    
+    
+    private void ToLog(CommandLog.CommandLog? log, bool? canExecute)
+    {
+        if (log != null && _command != null)
+        {
+            log.Log(_command, canExecute);
+        }
+    }
 }

+ 48 - 23
src/PixiEditor.AvaloniaUI/Models/Controllers/InputDevice/MouseUpdateController.cs

@@ -1,46 +1,71 @@
 using System.Timers;
+using Avalonia.Controls;
 using Avalonia.Input;
+using Avalonia.Interactivity;
 
 namespace PixiEditor.AvaloniaUI.Models.Controllers.InputDevice;
 
+#nullable enable
 public class MouseUpdateController : IDisposable
 {
-    private const int MouseUpdateIntervalMs = 7;  // 7ms ~= 142 Hz
-    
-    private readonly System.Timers.Timer _timer;
-    
-    private InputElement element;
-    
-    private Action<PointerEventArgs> mouseMoveHandler;
-    
-    public MouseUpdateController(InputElement uiElement, Action<PointerEventArgs> onMouseMove)
+    private bool isDisposed = false;
+
+    private readonly Control element;
+    private readonly Action<PointerEventArgs> mouseMoveHandler;
+    private MouseUpdateControllerSession? session;
+
+    public MouseUpdateController(Control uiElement, Action<PointerEventArgs> onMouseMove)
     {
         mouseMoveHandler = onMouseMove;
         element = uiElement;
         
-        _timer = new System.Timers.Timer(MouseUpdateIntervalMs);
-        _timer.AutoReset = true;
-        _timer.Elapsed += TimerOnElapsed;
-        
-        element.PointerMoved += OnMouseMove;
+        element.Loaded += OnElementLoaded;
+        element.Unloaded += OnElementUnloaded;
+
+        session ??= new MouseUpdateControllerSession(StartListening, StopListening, mouseMoveHandler); 
+
+        element.PointerMoved += CallMouseMoveInput;
+    }
+
+    void OnElementLoaded(object? o, RoutedEventArgs routedEventArgs)
+    {
+        session ??= new MouseUpdateControllerSession(StartListening, StopListening, mouseMoveHandler);
+    }
+
+    private void OnElementUnloaded(object? o, RoutedEventArgs routedEventArgs)
+    {
+        session.Dispose();
+        session = null;
+    }
+
+    private void StartListening()
+    {
+        if (isDisposed)
+            return;
+        element.PointerMoved -= CallMouseMoveInput;
+        element.PointerMoved += CallMouseMoveInput;
     }
 
-    private void TimerOnElapsed(object sender, ElapsedEventArgs e)
+    private void CallMouseMoveInput(object? sender, PointerEventArgs e)
     {
-        _timer.Stop();
-        element.PointerMoved += OnMouseMove;
+        if (isDisposed)
+            return;
+        session?.MouseMoveInput(e);
     }
 
-    private void OnMouseMove(object sender, PointerEventArgs e)
+    private void StopListening()
     {
-        element.PointerMoved -= OnMouseMove;
-        _timer.Start();
-        mouseMoveHandler(e);
+        if (isDisposed)
+            return;
+        element.PointerMoved -= CallMouseMoveInput;
     }
 
     public void Dispose()
     {
-        _timer.Dispose();
-        element.RemoveHandler(InputElement.PointerMovedEvent, OnMouseMove);
+        element.RemoveHandler(InputElement.PointerMovedEvent, CallMouseMoveInput);
+        element.RemoveHandler(Control.LoadedEvent, OnElementLoaded);
+        element.RemoveHandler(Control.UnloadedEvent, OnElementUnloaded);
+        session?.Dispose();
+        isDisposed = true;
     }
 }

+ 119 - 0
src/PixiEditor.AvaloniaUI/Models/Controllers/InputDevice/MouseUpdateControllerSession.cs

@@ -0,0 +1,119 @@
+using System.Diagnostics;
+using System.Threading;
+using Avalonia;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+namespace PixiEditor.AvaloniaUI.Models.Controllers.InputDevice;
+
+#nullable enable
+internal class MouseUpdateControllerSession : IDisposable
+{
+    private const double IntervalMs = 1000 / 142.0; //142 Hz
+
+    private readonly Action onStartListening;
+    private readonly Action onStopListening;
+    private readonly Action<PointerEventArgs> onMouseMove;
+
+    private readonly AutoResetEvent resetEvent = new(false);
+    private readonly object lockObj = new();
+
+    /// <summary>
+    /// <see cref="MouseUpdateControllerSession"/> doesn't rely on attaching and detaching mouse move handler,
+    /// it just ignores mouse move events when not listening. <br/>
+    /// Yet it still calls <see cref="onStartListening"/> and <see cref="onStopListening"/> which can be used to attach and detach event handler elsewhere.
+    /// </summary>
+    private bool isListening = true;
+    private bool isDisposed = false;
+
+    public MouseUpdateControllerSession(Action onStartListening, Action onStopListening, Action<PointerEventArgs> onMouseMove)
+    {
+        this.onStartListening = onStartListening;
+        this.onStopListening = onStopListening;
+        this.onMouseMove = onMouseMove;
+
+        Thread timerThread = new(TimerLoop)
+        {
+            IsBackground = true, Name = "MouseUpdateController thread"
+        };
+
+        timerThread.Start();
+
+        onStartListening();
+    }
+
+    public void MouseMoveInput(PointerEventArgs e)
+    {
+        if (!isListening || isDisposed)
+            return;
+
+        bool lockWasTaken = false;
+        try
+        {
+            Monitor.TryEnter(lockObj, ref lockWasTaken);
+            if (lockWasTaken)
+            {
+                isListening = false;
+                onStopListening();
+                onMouseMove(e);
+                resetEvent.Set();
+            }
+        }
+        finally
+        {
+            if (lockWasTaken)
+                Monitor.Exit(lockObj);
+        }
+    }
+
+    public void Dispose()
+    {
+        isDisposed = true;
+        resetEvent.Dispose();
+    }
+
+    private void TimerLoop()
+    {
+        try
+        {
+            long lastThreadIter = Stopwatch.GetTimestamp();
+            while (!isDisposed)
+            {
+                // call waitOne periodically instead of waiting infinitely to make sure we crash or exit when resetEvent is disposed
+                if (!resetEvent.WaitOne(300))
+                {
+                    lastThreadIter = Stopwatch.GetTimestamp();
+                    continue;
+                }
+
+                lock (lockObj)
+                {
+                    double sleepDur = Math.Clamp(IntervalMs - Stopwatch.GetElapsedTime(lastThreadIter).TotalMilliseconds, 0, IntervalMs);
+                    lastThreadIter += (long)(IntervalMs * Stopwatch.Frequency / 1000);
+                    if (sleepDur > 0)
+                        Thread.Sleep((int)Math.Round(sleepDur));
+
+                    if (isDisposed)
+                        return;
+
+                    isListening = true;
+                    
+                    Dispatcher.UIThread.Invoke(() =>
+                    {
+                        if (!isDisposed)
+                            onStartListening();
+                    });
+                }
+            }
+        }
+        catch (ObjectDisposedException)
+        {
+            return;
+        }
+        catch (Exception e)
+        {
+            Dispatcher.UIThread.Post(() => throw new AggregateException("Input handling thread died", e), DispatcherPriority.SystemIdle);
+            throw;
+        }
+    }
+}

+ 4 - 1
src/PixiEditor.AvaloniaUI/Models/Dialogs/OptionsDialog.cs

@@ -57,9 +57,12 @@ internal class OptionsDialog<T> : CustomDialog, IEnumerable<T>
         set => _results.Add(name, value);
     }
 
-    public override async Task<bool> ShowDialog()
+    public override Task<bool> ShowDialog() => ShowDialog(false);
+
+    public async Task<bool> ShowDialog(bool topmost)
     {
         var popup = new OptionPopup(Title, Content, new(_results.Keys.Select(x => (object)x)));
+        popup.Topmost = topmost;
         await popup.ShowDialog(OwnerWindow);
 
         Result = (T?)popup.Result;

+ 4 - 1
src/PixiEditor.AvaloniaUI/Models/DocumentModels/DocumentUpdater.cs

@@ -191,12 +191,15 @@ internal class DocumentUpdater
 
     private void ProcessSetSelectedMember(SetSelectedMember_PassthroughAction info)
     {
+        IStructureMemberHandler? member = doc.StructureHelper.Find(info.GuidValue);
+        if (member is null || member.Selection == StructureMemberSelectionType.Hard)
+            return;
+        
         if (doc.SelectedStructureMember is { } oldMember)
         {
             oldMember.Selection = StructureMemberSelectionType.None;
             //oldMember.OnPropertyChanged(nameof(oldMember.Selection));
         }
-        IStructureMemberHandler? member = doc.StructureHelper.FindOrThrow(info.GuidValue);
         member.Selection = StructureMemberSelectionType.Hard;
         //member.OnPropertyChanged(nameof(member.Selection));
         doc.SetSelectedMember(member);

+ 258 - 39
src/PixiEditor.AvaloniaUI/Models/ExceptionHandling/CrashReport.cs

@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using System.Diagnostics;
+using System.Globalization;
 using System.IO;
 using System.IO.Compression;
 using System.Linq;
@@ -7,6 +8,12 @@ using System.Reflection;
 using System.Text;
 using Newtonsoft.Json;
 using PixiEditor.AvaloniaUI.Helpers;
+using PixiEditor.AvaloniaUI.Models.Commands;
+using PixiEditor.AvaloniaUI.Models.Preferences;
+using PixiEditor.AvaloniaUI.ViewModels;
+using PixiEditor.AvaloniaUI.Views;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Common.UserPreferences;
 
 namespace PixiEditor.AvaloniaUI.Models.ExceptionHandling;
 
@@ -20,6 +27,7 @@ internal class CrashReport : IDisposable
 
         builder
             .AppendLine($"PixiEditor {VersionHelpers.GetCurrentAssemblyVersionString(moreSpecific: true)} x{IntPtr.Size * 8} crashed on {currentTime:yyyy.MM.dd} at {currentTime:HH:mm:ss} {currentTime:zzz}")
+            .AppendLine($"Application started {GetFormatted(() => Process.GetCurrentProcess().StartTime, "yyyy.MM.dd HH:hh:ss")}, {GetFormatted(() => DateTime.Now - Process.GetCurrentProcess().StartTime, @"d\ hh\:mm\.ss")} ago")
             .AppendLine($"Report: {Guid.NewGuid()}\n")
             .AppendLine("-----System Information----")
             .AppendLine("General:")
@@ -28,6 +36,48 @@ internal class CrashReport : IDisposable
 
         CrashHelper helper = new();
 
+        AppendHardwareInfo(helper, builder);
+
+        builder.AppendLine("\n--------Command Log--------\n");
+
+        try
+        {
+            builder.Append(CommandController.Current.Log.GetSummary(currentTime.LocalDateTime));
+        }
+        catch (Exception cemLogException)
+        {
+            builder.AppendLine($"Error ({cemLogException.GetType().FullName}: {cemLogException.Message}) while gathering command log, skipping...");
+        }
+
+        builder.AppendLine("\n-----------State-----------");
+
+        try
+        {
+            AppendStateInfo(builder);
+        }
+        catch (Exception stateException)
+        {
+            builder.AppendLine($"Error ({stateException.GetType().FullName}: {stateException.Message}) while gathering state (Must be bug in GetPreferenceFormatted, GetFormatted or StringBuilder.AppendLine as these should not throw), skipping...");
+        }
+
+        CrashHelper.AddExceptionMessage(builder, exception);
+
+        string filename = $"crash-{currentTime:yyyy-MM-dd_HH-mm-ss_fff}.zip";
+        string path = Path.Combine(
+            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+            "PixiEditor",
+            "crash_logs");
+        Directory.CreateDirectory(path);
+
+        CrashReport report = new();
+        report.FilePath = Path.Combine(path, filename);
+        report.ReportText = builder.ToString();
+
+        return report;
+    }
+
+    private static void AppendHardwareInfo(CrashHelper helper, StringBuilder builder)
+    {
         try
         {
             helper.GetCPUInformation(builder);
@@ -46,6 +96,7 @@ internal class CrashReport : IDisposable
             builder.AppendLine($"Error ({gpuE.GetType().FullName}: {gpuE.Message}) while gathering GPU information, skipping...");
         }
 
+        
         try
         {
             helper.GetMemoryInformation(builder);
@@ -54,23 +105,146 @@ internal class CrashReport : IDisposable
         {
             builder.AppendLine($"Error ({memE.GetType().FullName}: {memE.Message}) while gathering memory information, skipping...");
         }
+}
 
-        CrashHelper.AddExceptionMessage(builder, exception);
+    private static void AppendStateInfo(StringBuilder builder)
+    {
+        builder
+            .AppendLine("Environment:")
+            .AppendLine($"  Thread Count: {GetFormatted(() => Process.GetCurrentProcess().Threads.Count)}")
+            .AppendLine("\nCulture:")
+            .AppendLine($"  Selected language: {GetPreferenceFormatted("LanguageCode", true, "system")}")
+            .AppendLine($"  Current Culture: {GetFormatted(() => CultureInfo.CurrentCulture)}")
+            .AppendLine($"  Current UI Culture: {GetFormatted(() => CultureInfo.CurrentUICulture)}")
+            .AppendLine("\nPreferences:")
+            .AppendLine($"  Has shared toolbar enabled: {GetPreferenceFormatted("EnableSharedToolbar", true, false)}")
+            .AppendLine($"  Right click mode: {GetPreferenceFormatted<RightClickMode>("RightClickMode", true)}")
+            .AppendLine($"  Has Rich presence enabled: {GetPreferenceFormatted("EnableRichPresence", true, true)}")
+            .AppendLine($"  Debug Mode enabled: {GetPreferenceFormatted("IsDebugModeEnabled", true, false)}")
+            .AppendLine("\nUI:")
+            .AppendLine($"  MainWindow not null: {GetFormatted(() => MainWindow.Current != null)}")
+            .AppendLine($"  MainWindow Size: {GetFormatted(() => MainWindow.Current?.Bounds)}")
+            .AppendLine($"  MainWindow State: {GetFormatted(() => MainWindow.Current?.WindowState)}")
+            .AppendLine("\nViewModels:")
+            .AppendLine($"  Has active updateable change: {GetFormatted(() => ViewModelMain.Current?.DocumentManagerSubViewModel?.ActiveDocument?.UpdateableChangeActive)}")
+            .AppendLine($"  Current Tool: {GetFormattedFromViewModelMain(x => x.ToolsSubViewModel?.ActiveTool?.ToolName)}")
+            .AppendLine($"  Primary Color: {GetFormattedFromViewModelMain(x => x.ColorsSubViewModel?.PrimaryColor)}")
+            .AppendLine($"  Secondary Color: {GetFormattedFromViewModelMain(x => x.ColorsSubViewModel?.SecondaryColor)}")
+            .Append("\nActive Document: ");
 
-        string filename = $"crash-{currentTime:yyyy-MM-dd_HH-mm-ss_fff}.zip";
-        string path = Path.Combine(
-            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
-            "PixiEditor",
-            "crash_logs");
-        Directory.CreateDirectory(path);
+        try
+        {
+            AppendActiveDocumentInfo(builder);
+        }
+        catch (Exception e)
+        {
+            builder.AppendLine($"Could not get active document info:\n{e}");
+        }
+    }
 
-        CrashReport report = new();
-        report.FilePath = Path.Combine(path, filename);
-        report.ReportText = builder.ToString();
+    private static void AppendActiveDocumentInfo(StringBuilder builder)
+    {
+        var main = ViewModelMain.Current;
 
-        return report;
+        if (main == null)
+        {
+            builder.AppendLine("{ ViewModelMain.Current is null }");
+            return;
+        }
+
+        var manager = main.DocumentManagerSubViewModel;
+
+        if (manager == null)
+        {
+            builder.AppendLine("{ DocumentManagerSubViewModel is null }");
+            return;
+        }
+
+        var document = manager.ActiveDocument;
+
+        if (document == null)
+        {
+            builder.AppendLine("null");
+            return;
+        }
+
+        builder
+            .AppendLine()
+            .AppendLine($"  Size: {document.SizeBindable}")
+            .AppendLine($"  Layer Count: {FormatObject(document.StructureHelper.GetAllLayers().Count)}")
+            .AppendLine($"  Has all changes saved: {document.AllChangesSaved}")
+            .AppendLine($"  Horizontal Symmetry Enabled: {document.HorizontalSymmetryAxisEnabledBindable}")
+            .AppendLine($"  Horizontal Symmetry Value: {FormatObject(document.HorizontalSymmetryAxisYBindable)}")
+            .AppendLine($"  Vertical Symmetry Enabled: {document.VerticalSymmetryAxisEnabledBindable}")
+            .AppendLine($"  Vertical Symmetry Value: {FormatObject(document.VerticalSymmetryAxisXBindable)}")
+            .AppendLine($"  Updateable Change Active: {FormatObject(document.UpdateableChangeActive)}")
+            .AppendLine($"  Transform: {FormatObject(document.TransformViewModel)}");
+    }
+
+    private static string GetPreferenceFormatted<T>(string name, bool roaming, T defaultValue = default, string? format = null)
+    {
+        try
+        {
+            var preferences = IPreferences.Current;
+
+            if (preferences == null)
+                return "{ Preferences are null }";
+
+            var value = roaming
+                ? preferences.GetPreference(name, defaultValue)
+                : preferences.GetLocalPreference(name, defaultValue);
+
+            return FormatObject(value, format);
+        }
+        catch (Exception e)
+        {
+            return $$"""{ Failed getting preference: {{e.Message}} }""";
+        }
     }
 
+    private static string GetFormattedFromViewModelMain<T>(Func<ViewModelMain, T?> getter, string? format = null)
+    {
+        var main = ViewModelMain.Current;
+
+        if (main == null)
+            return "{ ViewModelMain.Current is null }";
+
+        return GetFormatted(() => getter(main), format);
+    }
+
+    private static string GetFormatted<T>(Func<T?> getter, string? format = null)
+    {
+        try
+        {
+            var value = getter();
+
+            return FormatObject(value, format);
+        }
+        catch (Exception e)
+        {
+            return $$"""{ Failed retrieving: {{e.Message}} }""";
+        }
+    }
+
+    private static string FormatObject<T>(T? value, string? format = null)
+    {
+        return value switch
+        {
+            null => "null",
+            IFormattable formattable => formattable.ToString(format, CultureInfo.InvariantCulture),
+            LocalizedString localizedS => FormatLocalizedString(localizedS),
+            string s => $"\"{s}\"",
+            _ => value.ToString()!
+        };
+
+        string FormatLocalizedString(LocalizedString localizedS)
+        {
+            return localizedS.Parameters != null
+                ? $"{localizedS.Key} @({string.Join(", ", localizedS.Parameters.Select(x => FormatObject(x, format)))})" 
+                : localizedS.Key;
+        }
+    }
+    
     public static CrashReport Parse(string path)
     {
         CrashReport report = new();
@@ -90,36 +264,63 @@ internal class CrashReport : IDisposable
 
     public int GetDocumentCount() => ZipFile.Entries.Where(x => x.FullName.EndsWith(".pixi")).Count();
 
-    public List<(string? originalPath, byte[] dotPixiBytes)> RecoverDocuments()
+    public bool TryRecoverDocuments(out List<RecoveredPixi> list)
     {
-        // Load .pixi files
-        Dictionary<string, byte[]> recoveredDocuments = new();
-        foreach (ZipArchiveEntry entry in ZipFile.Entries.Where(x => x.FullName.EndsWith(".pixi")))
+        try
+        {
+            list = RecoverDocuments();
+        }
+        catch (Exception e)
         {
-            using Stream stream = entry.Open();
-            using MemoryStream memStream = new();
-            stream.CopyTo(memStream);
-            recoveredDocuments.Add(entry.Name, memStream.ToArray());
+            list = null;
+            CrashHelper.SendExceptionInfoToWebhook(e);
+            return false;
         }
 
-        ZipArchiveEntry? originalPathsEntry = ZipFile.Entries.Where(entry => entry.FullName == "DocumentInfo.json").FirstOrDefault();
-        if (originalPathsEntry is null)
-            return recoveredDocuments.Select<KeyValuePair<string, byte[]>, (string?, byte[])>(keyValue => (null, keyValue.Value)).ToList();
+        return true;
+    }
+
+    public List<RecoveredPixi> RecoverDocuments()
+    {
+        List<RecoveredPixi> recoveredDocuments = new();
 
-        // Load original paths
-        Dictionary<string, string?> originalPaths;
+        var paths = TryGetOriginalPaths();
+        if (paths == null)
         {
-            using Stream stream = originalPathsEntry.Open();
-            using StreamReader reader = new(stream);
-            string json = reader.ReadToEnd();
-            originalPaths = JsonConvert.DeserializeObject<Dictionary<string, string?>>(json);
+            recoveredDocuments.AddRange(
+                ZipFile.Entries
+                    .Where(x => 
+                        x.FullName.StartsWith("Documents") && 
+                        x.FullName.EndsWith(".pixi"))
+                    .Select(entry => new RecoveredPixi(null, entry)));
+
+            return recoveredDocuments;
         }
 
-        return (
-            from docKeyValue in recoveredDocuments
-            join pathKeyValue in originalPaths on docKeyValue.Key equals pathKeyValue.Key
-            select (pathKeyValue.Value, docKeyValue.Value)
-            ).ToList();
+        recoveredDocuments.AddRange(paths.Select(path => new RecoveredPixi(path.Value, ZipFile.GetEntry($"Documents/{path.Key}"))));
+
+        return recoveredDocuments;
+
+        Dictionary<string, string>? TryGetOriginalPaths()
+        {
+            var originalPathsEntry = ZipFile.Entries.FirstOrDefault(entry => entry.FullName == "DocumentInfo.json");
+
+            if (originalPathsEntry == null)
+                return null;
+
+            try
+            {
+                using var stream = originalPathsEntry.Open();
+                using var reader = new StreamReader(stream);
+                string json = reader.ReadToEnd();
+
+                return JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
+            }
+            catch
+            {
+                return null;
+            }
+        }
     }
 
     public void Dispose()
@@ -170,14 +371,16 @@ internal class CrashReport : IDisposable
 
         // Write the documents into zip
         int counter = 0;
-        Dictionary<string, string?> originalPaths = new();
+        var originalPaths = new Dictionary<string, string>();
         //TODO: Implement
-        /*foreach (DocumentViewModel document in vm.DocumentManagerSubViewModel.Documents)
+        /*foreach (var document in vm.DocumentManagerSubViewModel.Documents)
         {
             try
             {
                 string fileName = string.IsNullOrWhiteSpace(document.FullFilePath) ? "Unsaved" : Path.GetFileNameWithoutExtension(document.FullFilePath);
-                string nameInZip = $"{fileName}-{document.OpenedUTC}-{counter}.pixi".Replace(':', '_');
+                string nameInZip = $"{fileName}-{document.OpenedUTC.ToString(CultureInfo.InvariantCulture)}-{counter.ToString(CultureInfo.InvariantCulture)}.pixi"
+                    .Replace(':', '_')
+                    .Replace('/', '_');
 
                 byte[] serialized = PixiParser.Serialize(document.ToSerializable());
 
@@ -211,10 +414,26 @@ internal class CrashReport : IDisposable
         ReportText = Encoding.UTF8.GetString(encodedReport);
     }
 
-    internal class CrashReportUserMessage
+    public class RecoveredPixi
     {
-        public string Message { get; set; }
+        public string? Path { get; }
+
+        public ZipArchiveEntry RecoveredEntry { get; }
 
-        public string Mail { get; set; }
+        public byte[] GetRecoveredBytes()
+        {
+            var buffer = new byte[RecoveredEntry.Length];
+            using var stream = RecoveredEntry.Open();
+
+            stream.ReadExactly(buffer);
+
+            return buffer;
+        }
+
+        public RecoveredPixi(string? path, ZipArchiveEntry recoveredEntry)
+        {
+            Path = path;
+            RecoveredEntry = recoveredEntry;
+        }
     }
 }

+ 40 - 18
src/PixiEditor.AvaloniaUI/Models/IO/Exporter.cs

@@ -112,30 +112,26 @@ internal class Exporter
 
         var typeFromPath = SupportedFilesHelper.ParseImageFormat(Path.GetExtension(pathWithExtension));
 
-        if (typeFromPath != FileType.Pixi)
+        if (typeFromPath == FileType.Pixi)
         {
-            var maybeBitmap = document.MaybeRenderWholeImage();
-            if (maybeBitmap.IsT0)
-                return SaveResult.ConcurrencyError;
-            var bitmap = maybeBitmap.AsT1;
-
-            EncodedImageFormat mappedFormat = typeFromPath.ToEncodedImageFormat();
-
-            if (mappedFormat == EncodedImageFormat.Unknown)
-            {
-                return SaveResult.UnknownError;
-            }
+            return TrySaveAsPixi(document, pathWithExtension);
+        }
+        
+        var maybeBitmap = document.MaybeRenderWholeImage();
+        if (maybeBitmap.IsT0)
+            return SaveResult.ConcurrencyError;
+        var bitmap = maybeBitmap.AsT1;
 
-            UniversalFileEncoder encoder = new(mappedFormat);
+        EncodedImageFormat mappedFormat = typeFromPath.ToEncodedImageFormat();
 
-            return TrySaveAs(encoder, pathWithExtension, bitmap, exportSize);
-        }
-        else
+        if (mappedFormat == EncodedImageFormat.Unknown)
         {
-            Parser.PixiParser.Serialize(document.ToSerializable(), pathWithExtension);
+            return SaveResult.UnknownError;
         }
 
-        return SaveResult.Success;
+        UniversalFileEncoder encoder = new(mappedFormat);
+
+        return TrySaveAs(encoder, pathWithExtension, bitmap, exportSize);
     }
 
     static Exporter()
@@ -190,6 +186,32 @@ internal class Exporter
         {
             return SaveResult.SecurityError;
         }
+        catch (UnauthorizedAccessException)
+        {
+            return SaveResult.SecurityError;
+        }
+        catch (IOException)
+        {
+            return SaveResult.IoError;
+        }
+        catch
+        {
+            return SaveResult.UnknownError;
+        }
+
+        return SaveResult.Success;
+    }
+    
+    private static SaveResult TrySaveAsPixi(DocumentViewModel document, string pathWithExtension)
+    {
+        try
+        {
+            Parser.PixiParser.Serialize(document.ToSerializable(), pathWithExtension);
+        }
+        catch (UnauthorizedAccessException e)
+        {
+            return SaveResult.SecurityError;
+        }
         catch (IOException)
         {
             return SaveResult.IoError;

+ 14 - 3
src/PixiEditor.AvaloniaUI/Models/IO/Importer.cs

@@ -31,8 +31,19 @@ internal class Importer : ObservableObject
     /// <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 (!Path.Exists(path)) 
+            throw new MissingFileException();
+        
+        Surface original;
+        try
+        {
+            original = Surface.Load(path);
+        }
+        catch (Exception e) when (e is ArgumentException or FileNotFoundException)
+        {
+            throw new CorruptedFileException(e);
+        }
+        
         if (original.Size == size || size == VecI.NegativeOne)
         {
             return original;
@@ -94,7 +105,7 @@ internal class Importer : ObservableObject
 
                 return doc;
             }
-            catch (InvalidFileException e)
+            catch (Exception e)
             {
                 throw new CorruptedFileException("FAILED_TO_OPEN_FILE", e);
             }

+ 128 - 0
src/PixiEditor.AvaloniaUI/Models/IO/PaletteParsers/DeluxePaintParser.cs

@@ -0,0 +1,128 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Extensions.Palettes.Parsers;
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.AvaloniaUI.Models.IO.PaletteParsers;
+
+/// <summary>
+/// Reads 8-bit color palette data from Interleaved Bitmap format (LBM/BBM)
+/// most commonly used by DeluxePaint on Amiga and MS DOS.
+///
+/// https://en.wikipedia.org/wiki/ILBM
+///
+/// Note: A BBM file is essentially a LBM without a full image.
+/// 
+/// Code adapted from https://devblog.cyotek.com/post/loading-the-color-palette-from-a-bbm-lbm-image-file-using-csharp
+/// </summary>
+internal class DeluxePaintParser : PaletteFileParser
+{
+    public override string FileName { get; } = "DeluxePaint Interleaved Bitmap Palette";
+    public override string[] SupportedFileExtensions { get; } = new string[] { ".bbm", ".lbm" };
+    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)
+    {
+        List<PaletteColor> colorPalette = new();
+        string name = Path.GetFileNameWithoutExtension(path);
+
+        await using (Stream stream = File.OpenRead(path))
+        {
+            byte[] buffer;
+            string header;
+
+            // read the FORM header that identifies the document as an IFF file
+            buffer = new byte[4];
+            stream.Read(buffer, 0, buffer.Length);
+            if (Encoding.ASCII.GetString(buffer) != "FORM")
+                return PaletteFileData.Corrupted; // Form header not found
+
+            // the next value is the size of all the data in the FORM chunk
+            // We don't actually need this value, but we have to read it
+            // regardless to advance the stream
+            ReadInt(stream);
+
+            stream.Read(buffer, 0, buffer.Length);
+            header = Encoding.ASCII.GetString(buffer);
+            if (header != "PBM " && header != "ILBM")
+                return PaletteFileData.Corrupted; // Bitmap header not found
+
+            while (stream.Read(buffer, 0, buffer.Length) == buffer.Length)
+            {
+                int chunkLength;
+
+                chunkLength = ReadInt(stream);
+
+                if (Encoding.ASCII.GetString(buffer) != "CMAP")
+                {
+                    // some other LBM chunk, skip it
+                    if (stream.CanSeek)
+                    {
+                        stream.Seek(chunkLength, SeekOrigin.Current);
+                    }
+                    else
+                    {
+                        for (int i = 0; i < chunkLength; i++)
+                            stream.ReadByte();
+                    }
+                }
+                else
+                {
+                    // color map chunk
+                    for (int i = 0; i < chunkLength / 3; i++)
+                    {
+                        int[] rgb = new int[3];
+
+                        rgb[0] = stream.ReadByte();
+                        rgb[1] = stream.ReadByte();
+                        rgb[2] = stream.ReadByte();
+
+                        colorPalette.Add(new PaletteColor((byte)rgb[0], (byte)rgb[1], (byte)rgb[2]));
+                    }
+
+                    // all done so stop reading the rest of the file
+                    break;
+                }
+
+                // chunks always contain an even number of bytes even if the recorded length is odd
+                // if the length is odd, then there's a padding byte in the file - just read and discard
+                if (chunkLength % 2 != 0)
+                    stream.ReadByte();
+            }
+        }
+
+        return new PaletteFileData(name, colorPalette.ToArray());
+    }
+
+    public override bool CanSave => false;
+
+    public override async Task<bool> Save(string path, PaletteFileData data)
+    {
+        throw new SavingNotSupportedException("Saving palette as .bbm or .lbm is not supported.");
+    }
+
+    private static int ReadInt(Stream stream)
+    {
+        byte[] buffer;
+
+        // big endian conversion: http://stackoverflow.com/a/14401341/148962
+
+        buffer = new byte[4];
+        stream.Read(buffer, 0, buffer.Length);
+
+        return (buffer[0] << 24) | (buffer[1] << 16) | (buffer[2] << 8) | buffer[3];
+    }
+}

+ 2 - 2
src/PixiEditor.AvaloniaUI/Models/Rendering/CanvasUpdater.cs

@@ -144,6 +144,8 @@ internal class CanvasUpdater
                 break;
             }
         }
+        
+        UpdateAffectedNonRerenderedChunks(chunksToRerender, chunkGatherer.MainImageArea);
 
         bool anythingToUpdate = false;
         foreach (var (_, chunks) in chunksToRerender)
@@ -152,8 +154,6 @@ internal class CanvasUpdater
         }
         if (!anythingToUpdate)
             return new();
-
-        UpdateAffectedNonRerenderedChunks(chunksToRerender, chunkGatherer.MainImageArea);
         
         List<IRenderInfo> infos = new();
         UpdateMainImage(chunksToRerender, updatingStoredChunks ? null : chunkGatherer.MainImageArea.GlobalArea.Value, infos);

+ 1 - 1
src/PixiEditor.AvaloniaUI/Models/Rendering/MemberPreviewUpdater.cs

@@ -563,7 +563,7 @@ internal class MemberPreviewUpdater
                 continue;
 
             if (tightBounds is null)
-                tightBounds = lastMainPreviewTightBounds[guid];
+                tightBounds = lastMaskPreviewTightBounds[guid];
 
             var previewSize = StructureHelpers.CalculatePreviewSize(tightBounds.Value.Size);
             float scaling = (float)previewSize.X / tightBounds.Value.Width;

+ 27 - 3
src/PixiEditor.AvaloniaUI/ViewModels/CrashReportViewModel.cs

@@ -1,10 +1,13 @@
 using System.Diagnostics;
+using System.Threading.Tasks;
 using Avalonia;
 using Avalonia.Controls;
 using CommunityToolkit.Mvvm.Input;
 using PixiEditor.AvaloniaUI.Helpers;
+using PixiEditor.AvaloniaUI.Models.Dialogs;
 using PixiEditor.AvaloniaUI.Models.ExceptionHandling;
 using PixiEditor.AvaloniaUI.Views;
+using PixiEditor.Extensions.Common.Localization;
 
 namespace PixiEditor.AvaloniaUI.ViewModels;
 
@@ -31,17 +34,38 @@ internal partial class CrashReportViewModel : ViewModelBase
         //OpenSendCrashReportCommand = ReactiveCommand.Create(() => new SendCrashReportWindow(CrashReport).Show());
 
         if (!IsDebugBuild)
-            _ = CrashHelper.SendReportTextToWebhook(report);
+            _ = CrashHelper.SendReportTextToWebhookAsync(report);
     }
 
     [RelayCommand(CanExecute = nameof(CanRecoverDocuments))]
-    public void RecoverDocuments()
+    public async Task RecoverDocuments()
     {
-        MainWindow window = MainWindow.CreateWithDocuments(CrashReport.RecoverDocuments());
+        MainWindow window = MainWindow.CreateWithRecoveredDocuments(CrashReport, out var showMissingFilesDialog);
 
         Application.Current.Run(window);
         window.Show();
         hasRecoveredDocuments = false;
+        
+        if (showMissingFilesDialog)
+        {
+            var dialog = new OptionsDialog<LocalizedString>(
+                "CRASH_NOT_ALL_DOCUMENTS_RECOVERED_TITLE",
+                new LocalizedString("CRASH_NOT_ALL_DOCUMENTS_RECOVERED"), 
+                MainWindow.Current!)
+            {
+                {
+                    "SEND", _ =>
+                    {
+                        // TODO
+                        //var sendReportDialog = new SendCrashReportWindow(CrashReport);
+                        //sendReportDialog.ShowDialog();
+                    }
+                },
+                "CLOSE"
+            };
+
+            await dialog.ShowDialog(true);
+        }
     }
 
     public bool CanRecoverDocuments()

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

@@ -1,4 +1,5 @@
-using System.Windows.Input;
+using System.Diagnostics;
+using System.Windows.Input;
 using ChunkyImageLib.DataHolders;
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
@@ -11,6 +12,7 @@ using PixiEditor.Extensions.Helpers;
 
 namespace PixiEditor.AvaloniaUI.ViewModels.Document.TransformOverlays;
 #nullable enable
+[DebuggerDisplay("{ToString(),nq}")]
 internal class DocumentTransformViewModel : ObservableObject, ITransformHandler
 {
     private DocumentViewModel document;
@@ -247,4 +249,6 @@ internal class DocumentTransformViewModel : ObservableObject, ITransformHandler
                 break;
         }
     }
+    
+    public override string ToString() => !TransformActive ? "Not active" : $"Transform Mode: {activeTransformMode}; Corner Freedom: {CornerFreedom}; Side Freedom: {SideFreedom}";
 }

+ 21 - 15
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/FileViewModel.cs

@@ -104,8 +104,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
         {
             OpenFromPath(file);
         }
-        else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0
-                  || !args.Contains("--crash")) && !args.Contains("--openedInExisting"))
+        else if ((Owner.DocumentManagerSubViewModel.Documents.Count == 0 && !args.Contains("--crash")) && !args.Contains("--openedInExisting"))
         {
             if (IPreferences.Current.GetPreference("ShowStartupWindow", true))
             {
@@ -159,7 +158,7 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
                 continue;
             }
 
-            OpenFromPath(dataImage.name, false);
+            OpenRegularImage(dataImage.image, null);
         }
     }
 
@@ -349,20 +348,27 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
     [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;
-        if (doc is null)
-            return;
+        try
+        {
+            DocumentViewModel doc = Owner.DocumentManagerSubViewModel.ActiveDocument;
+            if (doc is null)
+                return;
 
-        //TODO: Implement ExportFileDialog
-        /*ExportFileDialog info = new ExportFileDialog(doc.SizeBindable);
-        if (info.ShowDialog())
+            //TODO: Implement ExportFileDialog
+            /*ExportFileDialog info = new ExportFileDialog(doc.SizeBindable);
+            if (info.ShowDialog())
+            {
+                SaveResult result = Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat, out string finalPath, new(info.FileWidth, info.FileHeight));
+                if (result == SaveResult.Success)
+                    ProcessHelper.OpenInExplorer(finalPath);
+                else
+                    ShowSaveError((DialogSaveResult)result);
+            }*/
+        }
+        catch (RecoverableException e)
         {
-            SaveResult result = Exporter.TrySaveUsingDataFromDialog(doc, info.FilePath, info.ChosenFormat, out string finalPath, new(info.FileWidth, info.FileHeight));
-            if (result == SaveResult.Success)
-                ProcessHelper.OpenInExplorer(finalPath);
-            else
-                ShowSaveError((DialogSaveResult)result);
-        }*/
+            NoticeDialog.Show(e.DisplayMessage, "ERROR");
+        }
     }
 
     private void ShowSaveError(DialogSaveResult result)

+ 2 - 2
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/MiscViewModel.cs

@@ -20,7 +20,7 @@ internal class MiscViewModel : SubViewModel<ViewModelMain>
     [Command.Basic("PixiEditor.Links.OpenRepository", "https://github.com/PixiEditor/PixiEditor", "REPOSITORY", "OPEN_REPOSITORY", IconPath = "Globe.png")]
     [Command.Basic("PixiEditor.Links.OpenLicense", "https://github.com/PixiEditor/PixiEditor/blob/master/LICENSE", "LICENSE", "OPEN_LICENSE", IconPath = "Globe.png")]
     [Command.Basic("PixiEditor.Links.OpenOtherLicenses", "https://pixieditor.net/docs/Third-party-licenses", "THIRD_PARTY_LICENSES", "OPEN_THIRD_PARTY_LICENSES", IconPath = "Globe.png")]
-    public static async Task OpenHyperlink(string url)
+    public static void OpenHyperlink(string url)
     {
         try
         {
@@ -28,8 +28,8 @@ internal class MiscViewModel : SubViewModel<ViewModelMain>
         }
         catch (Exception e)
         {
+            CrashHelper.SendExceptionInfoToWebhook(e);
             NoticeDialog.Show(title: "Error", message: $"Couldn't open the address {url} in your default browser");
-            await CrashHelper.SendExceptionInfoToWebhook(e);
         }
     }
 }

+ 5 - 0
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/UpdateViewModel.cs

@@ -227,6 +227,11 @@ internal class UpdateViewModel : SubViewModel<ViewModelMain>
             {
                 NoticeDialog.Show("COULD_NOT_CHECK_FOR_UPDATES", "UPDATE_CHECK_FAILED");
             }
+            catch (Exception e)
+            {
+                CrashHelper.SendExceptionInfoToWebhookAsync(e);
+                NoticeDialog.Show("COULD_NOT_CHECK_FOR_UPDATES", "UPDATE_CHECK_FAILED");
+            }
 
             AskToInstall();
         }

+ 1 - 0
src/PixiEditor.AvaloniaUI/Views/Dialogs/OptionPopup.axaml.cs

@@ -41,6 +41,7 @@ public partial class OptionPopup : Window
 
     public OptionPopup(string title, object content, ObservableCollection<object> options)
     {
+        Title = title;
         PopupContent = content;
         Options = options;
         CancelCommand = new RelayCommand(Cancel);

+ 8 - 2
src/PixiEditor.AvaloniaUI/Views/Layers/FolderControl.axaml.cs

@@ -35,7 +35,7 @@ internal partial class FolderControl : UserControl
     private readonly IBrush? highlightColor;
 
     
-    private MouseUpdateController mouseUpdateController;
+    private MouseUpdateController? mouseUpdateController;
 
     public FolderControl()
     {
@@ -46,9 +46,15 @@ internal partial class FolderControl : UserControl
         }
 
         Loaded += OnLoaded;
+        Unloaded += OnUnloaded;
     }
 
-    private void OnLoaded(object sender, RoutedEventArgs e)
+    private void OnUnloaded(object? sender, RoutedEventArgs e)
+    {
+        mouseUpdateController?.Dispose();
+    }
+
+    private void OnLoaded(object? sender, RoutedEventArgs e)
     {
         mouseUpdateController = new MouseUpdateController(this, Manager.FolderControl_MouseMove);
     }

+ 7 - 1
src/PixiEditor.AvaloniaUI/Views/Layers/LayerControl.axaml.cs

@@ -77,14 +77,20 @@ internal partial class LayerControl : UserControl
     {
         InitializeComponent();
         Loaded += LayerControl_Loaded;
+        Unloaded += LayerControl_Unloaded;
 
         if (App.Current.TryGetResource("SoftSelectedLayerBrush", App.Current.ActualThemeVariant, out var value))
         {
             highlightColor = value as IBrush;
         }
     }
+    
+    private void LayerControl_Unloaded(object? sender, RoutedEventArgs e)
+    { 
+        mouseUpdateController?.Dispose();
+    }
 
-    private void LayerControl_Loaded(object sender, RoutedEventArgs e)
+    private void LayerControl_Loaded(object? sender, RoutedEventArgs e)
     {
         mouseUpdateController = new MouseUpdateController(this, Manager.LayerControl_MouseMove);
     }

+ 5 - 5
src/PixiEditor.AvaloniaUI/Views/Main/Viewport.axaml.cs

@@ -288,7 +288,7 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
     public Guid GuidValue { get; } = Guid.NewGuid();
 
-    private MouseUpdateController mouseUpdateController;
+    private MouseUpdateController? mouseUpdateController;
 
     static Viewport()
     {
@@ -312,8 +312,6 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
         //TODO: It's weird that I had to do it this way, right click didn't raise Image_MouseUp otherwise.
         viewportGrid.AddHandler(PointerReleasedEvent, Image_MouseUp, RoutingStrategies.Tunnel);
         viewportGrid.AddHandler(PointerPressedEvent, Image_MouseDown, RoutingStrategies.Bubble);
-
-        mouseUpdateController = new MouseUpdateController(this, Image_MouseMove);
     }
 
     public Image? MainImage => (Image?)((Grid?)((Border?)zoombox.AdditionalContent)?.Child)?.Children[1];
@@ -324,14 +322,16 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
         MainImage?.InvalidateVisual();
     }
 
-    private void OnUnload(object sender, RoutedEventArgs e)
+    private void OnUnload(object? sender, RoutedEventArgs e)
     {
         Document?.Operations.RemoveViewport(GuidValue);
+        mouseUpdateController?.Dispose();
     }
 
-    private void OnLoad(object sender, RoutedEventArgs e)
+    private void OnLoad(object? sender, RoutedEventArgs e)
     {
         Document?.Operations.AddOrUpdateViewport(GetLocation());
+        mouseUpdateController = new MouseUpdateController(this, Image_MouseMove);
     }
 
     private static void OnDocumentChange(AvaloniaPropertyChangedEventArgs<DocumentViewModel> e)

+ 43 - 8
src/PixiEditor.AvaloniaUI/Views/MainWindow.axaml.cs

@@ -5,7 +5,9 @@ using Avalonia.Controls.ApplicationLifetimes;
 using Microsoft.Extensions.DependencyInjection;
 using PixiEditor.AvaloniaUI.Helpers;
 using PixiEditor.AvaloniaUI.Models.AppExtensions;
+using PixiEditor.AvaloniaUI.Models.ExceptionHandling;
 using PixiEditor.AvaloniaUI.Models.Services;
+using PixiEditor.AvaloniaUI.ViewModels.SubViewModels;
 using PixiEditor.DrawingApi.Core.Bridge;
 using PixiEditor.DrawingApi.Skia;
 using PixiEditor.Extensions.Common.UserPreferences;
@@ -56,19 +58,52 @@ internal partial class MainWindow : Window
         InitializeComponent();
     }
 
-    public static MainWindow CreateWithDocuments(IEnumerable<(string? originalPath, byte[] dotPixiBytes)> documents)
+    public static MainWindow CreateWithRecoveredDocuments(CrashReport report, out bool showMissingFilesDialog)
     {
-        //TODO: Implement this
-        /*MainWindow window = new(extLoader);
-        FileViewModel fileVM = window.services.GetRequiredService<FileViewModel>();
+        showMissingFilesDialog = false;
+        return new MainWindow(new ExtensionLoader());
+        // TODO implement this
+        /*
+        var window = GetMainWindow();
+        var fileVM = window.services.GetRequiredService<FileViewModel>();
 
-        foreach (var (path, bytes) in documents)
+        if (!report.TryRecoverDocuments(out var documents))
         {
-            fileVM.OpenRecoveredDotPixi(path, bytes);
+            showMissingFilesDialog = true;
+            return window;
         }
 
-        return window;*/
+        var i = 0;
 
-        return new MainWindow(null);
+        foreach (var document in documents)
+        {
+            try
+            {
+                fileVM.OpenRecoveredDotPixi(document.Path, document.GetRecoveredBytes());
+                i++;
+            }
+            catch (Exception e)
+            {
+                CrashHelper.SendExceptionInfoToWebhook(e);
+            }
+        }
+
+        showMissingFilesDialog = documents.Count != i;
+
+        return window;
+
+        MainWindow GetMainWindow()
+        {
+            try
+            {
+                var app = (App)Application.Current;
+                return new MainWindow(app.InitApp());
+            }
+            catch (Exception e)
+            {
+                CrashHelper.SendExceptionInfoToWebhook(e, true);
+                throw;
+            }
+        }*/
     }
 }

+ 8 - 8
src/PixiEditor.AvaloniaUI/Views/Overlays/BrushShapeOverlay/BrushShapeOverlay.cs

@@ -19,8 +19,8 @@ internal class BrushShapeOverlay : Control
     public static readonly StyledProperty<int> BrushSizeProperty =
         AvaloniaProperty.Register<BrushShapeOverlay, int>(nameof(BrushSize), defaultValue: 1);
 
-    public static readonly StyledProperty<InputElement?> MouseEventSourceProperty =
-        AvaloniaProperty.Register<BrushShapeOverlay, InputElement?>(nameof(MouseEventSource), defaultValue: null);
+    public static readonly StyledProperty<Control?> MouseEventSourceProperty =
+        AvaloniaProperty.Register<BrushShapeOverlay, Control?>(nameof(MouseEventSource), defaultValue: null);
 
     public static readonly StyledProperty<InputElement?> MouseReferenceProperty =
         AvaloniaProperty.Register<BrushShapeOverlay, InputElement?>(nameof(MouseReference), defaultValue: null);
@@ -40,9 +40,9 @@ internal class BrushShapeOverlay : Control
         set => SetValue(MouseReferenceProperty, value);
     }
 
-    public InputElement? MouseEventSource
+    public Control? MouseEventSource
     {
-        get => (InputElement?)GetValue(MouseEventSourceProperty);
+        get => (Control?)GetValue(MouseEventSourceProperty);
         set => SetValue(MouseEventSourceProperty, value);
     }
 
@@ -61,7 +61,7 @@ internal class BrushShapeOverlay : Control
     private Pen whitePen = new Pen(Brushes.LightGray, 1);
     private Point lastMousePos = new();
 
-    private MouseUpdateController mouseUpdateController;
+    private MouseUpdateController? mouseUpdateController;
 
     static BrushShapeOverlay()
     {
@@ -77,15 +77,15 @@ internal class BrushShapeOverlay : Control
         Unloaded += ControlUnloaded;
     }
 
-    private void ControlUnloaded(object sender, RoutedEventArgs e)
+    private void ControlUnloaded(object? sender, RoutedEventArgs e)
     {
         if (MouseEventSource is null)
             return;
         
-        mouseUpdateController.Dispose();
+        mouseUpdateController?.Dispose();
     }
 
-    private void ControlLoaded(object sender, RoutedEventArgs e)
+    private void ControlLoaded(object? sender, RoutedEventArgs e)
     {
         if (MouseEventSource is null)
             return;

+ 7 - 1
src/PixiEditor.AvaloniaUI/Views/Overlays/LineToolOverlay/LineToolOverlay.cs

@@ -82,13 +82,19 @@ internal class LineToolOverlay : Overlay
         AddHandle(moveHandle);
 
         Loaded += OnLoaded;
+        Unloaded += OnUnloaded;
     }
 
-    private void OnLoaded(object sender, RoutedEventArgs e)
+    private void OnLoaded(object? sender, RoutedEventArgs e)
     {
         //TODO: Ensure this bug doesn't happen in Avalonia, currently Handle classes are taking care of dragging events
         //mouseUpdateController = new MouseUpdateController(this, MouseMoved);
     }
+    
+    private void OnUnloaded(object? sender, RoutedEventArgs e)
+    {
+        //mouseUpdateController?.Dispose();
+    }
 
     private static void OnZoomboxScaleChanged(AvaloniaPropertyChangedEventArgs<double> args)
     {

+ 8 - 2
src/PixiEditor.AvaloniaUI/Views/Overlays/SymmetryOverlay/SymmetryOverlay.cs

@@ -118,14 +118,20 @@ internal class SymmetryOverlay : Overlay
     private double verticalAxisX;
     private Point pointerPosition;
 
-    private MouseUpdateController mouseUpdateController;
+    private MouseUpdateController? mouseUpdateController;
 
     public SymmetryOverlay()
     {
         Loaded += OnLoaded;
+        Unloaded += OnUnloaded;
     }
 
-    private void OnLoaded(object sender, RoutedEventArgs e)
+    private void OnUnloaded(object? sender, RoutedEventArgs e)
+    {
+        mouseUpdateController?.Dispose();
+    }
+
+    private void OnLoaded(object? sender, RoutedEventArgs e)
     {
         mouseUpdateController = new MouseUpdateController(this, MouseMoved);
         PointerEntered += OnPointerEntered;

+ 2 - 2
src/PixiEditor.AvaloniaUI/Views/Windows/HelloTherePopup.axaml.cs

@@ -196,7 +196,7 @@ internal partial class HelloTherePopup : Window
 
     private async void HelloTherePopup_OnLoaded(object sender, RoutedEventArgs e)
     {
-        return;
+        return; // TODO
         if(_newsDisabled) return;
 
         try
@@ -223,7 +223,7 @@ internal partial class HelloTherePopup : Window
         {
             IsFetchingNews = false;
             FailedFetchingNews = true;
-            await CrashHelper.SendExceptionInfoToWebhook(ex);
+            await CrashHelper.SendExceptionInfoToWebhookAsync(ex);
         }
     }
 }

+ 4 - 4
src/PixiEditor.Extensions/Common/Localization/LocalizedString.cs

@@ -26,14 +26,14 @@ public struct LocalizedString
     }
     public string Value { get; private set; }
 
-    public object[] Parameters { get; set; }
+    public object[]? Parameters { get; set; }
 
     public LocalizedString(string key)
     {
         Key = key;
     }
 
-    public LocalizedString(string key, params object[] parameters)
+    public LocalizedString(string key, params object[]? parameters)
     {
         Parameters = parameters;
         Key = key;
@@ -51,7 +51,7 @@ public struct LocalizedString
             return localizationKey;
         }
         
-        ILocalizationProvider localizationProvider = ILocalizationProvider.Current;
+        ILocalizationProvider? localizationProvider = ILocalizationProvider.Current;
         if (localizationProvider?.LocalizationData == null)
         {
             return localizationKey;
@@ -70,7 +70,7 @@ public struct LocalizedString
         }
 
 
-        return ApplyParameters(ILocalizationProvider.Current.CurrentLanguage.Locale[localizationKey]);
+        return ApplyParameters(ILocalizationProvider.Current!.CurrentLanguage.Locale[localizationKey]);
     }
 
     private string GetLongString(int length) => string.Join(' ', Enumerable.Repeat("LaLaLaLaLa", length));