Procházet zdrojové kódy

Porting Commands wip

Krzysztof Krysiński před 2 roky
rodič
revize
3c99862aea
67 změnil soubory, kde provedl 3088 přidání a 14 odebrání
  1. 11 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/GlobalUsings.cs
  2. 37 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/DesignCommandHelpers.cs
  3. 45 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/MarkupExtensions/LocalizationExtension.cs
  4. 36 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/BasicAttribute.cs
  5. 51 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/CommandAttribute.cs
  6. 16 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/DebugAttribute.cs
  7. 19 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/FilterAttribute.cs
  8. 24 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/GroupAttribute.cs
  9. 26 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/InternalAttribute.cs
  10. 17 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/ToolAttribute.cs
  11. 20 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Evaluators/CanExecuteAttribute.cs
  12. 15 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Evaluators/EvaluatorAttribute.cs
  13. 12 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Evaluators/IconAttribute.cs
  14. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/InternalNameAttribute.cs
  15. 86 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandCollection.cs
  16. 493 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandController.cs
  17. 69 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandGroup.cs
  18. 28 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandMethods.cs
  19. 28 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandNameList.cs
  20. 15 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/BasicCommand.cs
  21. 75 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/Command.cs
  22. 20 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/ToolCommand.cs
  23. 22 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Evaluators/CanExecuteEvaluator.cs
  24. 17 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Evaluators/Evaluator.cs
  25. 94 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Evaluators/IconEvaluator.cs
  26. 15 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Exceptions/CommandNotFoundException.cs
  27. 76 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/ColorSearchResult.cs
  28. 22 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/CommandSearchResult.cs
  29. 51 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/FileSearchResult.cs
  30. 77 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/SearchResult.cs
  31. 16 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutChangedEventArgs.cs
  32. 56 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutFile.cs
  33. 58 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutsTemplate.cs
  34. 9 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/ICustomShortcutFormat.cs
  35. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/IShortcutDefaults.cs
  36. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/IShortcutFile.cs
  37. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/IShortcutInstallation.cs
  38. 37 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/AsepriteProvider.cs
  39. 40 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/DebugProvider.cs
  40. 213 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/AsepriteKeysParser.cs
  41. 85 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/KeyDefinition.cs
  42. 110 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/KeyParser.cs
  43. 61 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/KeysParser.cs
  44. 27 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/ShortcutCollection.cs
  45. 41 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/ShortcutProvider.cs
  46. 81 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/Command.cs
  47. 47 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/ContextMenu.cs
  48. 66 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/Menu.cs
  49. 42 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/ShortcutBinding.cs
  50. 6 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Descriptors/ISearchHandler.cs
  51. 44 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Descriptors/IToolHandler.cs
  52. 6 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Descriptors/IToolsHandler.cs
  53. 24 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Dialogs/NoticeDialog.cs
  54. 53 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Input/KeyCombination.cs
  55. 159 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Structures/OneToManyDictionary.cs
  56. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/PixiEditor.Avalonia.csproj
  57. 15 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/AdditionalContent/AdditionalContentViewModel.cs
  58. 14 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/SubViewModel.cs
  59. 42 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Dialogs/NoticePopup.axaml
  60. 37 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Dialogs/NoticePopup.axaml.cs
  61. 6 0
      src/PixiEditor.OperatingSystem/IOperatingSystem.cs
  62. 9 0
      src/PixiEditor.OperatingSystem/PixiEditor.OperatingSystem.csproj
  63. 13 0
      src/PixiEditor.Windows/PixiEditor.Windows.csproj
  64. 8 0
      src/PixiEditor.Windows/WindowsOperatingSystem.cs
  65. 92 0
      src/PixiEditor.sln
  66. 1 0
      src/PixiEditor/ViewModels/CrashReportViewModel.cs
  67. 14 14
      src/PixiEditor/ViewModels/SubViewModels/Tools/ToolViewModel.cs

+ 11 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/GlobalUsings.cs

@@ -0,0 +1,11 @@
+global using OneOf;
+global using System;
+global using OneOf.Types;
+//global using PixiEditor.ChangeableDocument.Actions.Generated;
+//global using PixiEditor.Helpers.Extensions;
+global using PixiEditor.Models.Commands.Attributes.Evaluators;
+global using PixiEditor.Models.Commands.Search;
+global using PixiEditor.ViewModels;
+//global using PixiEditor.ViewModels.SubViewModels.Main;
+global using SkiaSharp;
+

+ 37 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/DesignCommandHelpers.cs

@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Exceptions;
+using CommandAttribute = PixiEditor.Models.Commands.Attributes.Commands.Command;
+
+namespace PixiEditor.Helpers;
+
+/// <summary>
+/// Helps with debugging when using XAML
+/// </summary>
+internal static class DesignCommandHelpers
+{
+    private static IEnumerable<CommandAttribute.CommandAttribute> _commands;
+
+    public static CommandAttribute.CommandAttribute GetCommandAttribute(string name)
+    {
+        if (_commands == null)
+        {
+            _commands = Assembly
+                .GetAssembly(typeof(CommandController))
+                .GetTypes()
+                .SelectMany(x => x.GetMethods())
+                .SelectMany(x => x.GetCustomAttributes<CommandAttribute.CommandAttribute>());
+        }
+
+        var command = _commands.SingleOrDefault(x => x.InternalName == name || x.InternalName == $"#DEBUG#{name}");
+
+        if (command == null)
+        {
+            throw new CommandNotFoundException(name);
+        }
+
+        return command;
+    }
+}

+ 45 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/MarkupExtensions/LocalizationExtension.cs

@@ -0,0 +1,45 @@
+using Avalonia.Data;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.Helpers;
+
+public class LocalizationExtension : MarkupExtension
+{
+    private LocalizationExtensionToProvide toProvide;
+    private static Binding flowDirectionBinding;
+
+    public LocalizationExtension(LocalizationExtensionToProvide toProvide)
+    {
+        
+    }
+    
+    public override object ProvideValue(IServiceProvider serviceProvider)
+    {
+        switch (toProvide)
+        {
+            case LocalizationExtensionToProvide.FlowDirection:
+                return GetFlowDirectionBinding(serviceProvider);
+        }
+
+        throw new NotImplementedException();
+    }
+
+    private object GetFlowDirectionBinding(IServiceProvider serviceProvider)
+    {
+        /*flowDirectionBinding = new Binding("CurrentLanguage.FlowDirection");
+        flowDirectionBinding.Source = ViewModelMain.Current.LocalizationProvider;
+        flowDirectionBinding.Mode = BindingMode.OneWay;
+
+        var expression = (BindingExpression)flowDirectionBinding.ProvideValue(serviceProvider);
+
+        ViewModelMain.Current.LocalizationProvider.OnLanguageChanged += _ => expression.UpdateTarget();
+
+        return expression;*/
+        return null;
+    }
+}
+
+public enum LocalizationExtensionToProvide
+{
+    FlowDirection
+}

+ 36 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/BasicAttribute.cs

@@ -0,0 +1,36 @@
+namespace PixiEditor.Models.Commands.Attributes.Commands;
+
+internal partial class Command
+{
+    internal class BasicAttribute : CommandAttribute
+    {
+        /// <summary>
+        /// Gets or sets the parameter that will be passed to the first argument of the method
+        /// </summary>
+        public object Parameter { get; set; }
+
+        /// <summary>
+        /// Create's a basic command which uses null as a paramter
+        /// </summary>
+        /// <param name="internalName">The internal name of the command</param>
+        /// <param name="displayName">A short description which is displayed in the the top menu, e.g. "Save as...". Accepts localized key</param>
+        /// <param name="descriptiveName">A description which is displayed in the search bar, e.g. "Save image as new". Leave empty to hide it from the search bar. Accepts localized key</param>
+        public BasicAttribute([InternalName] string internalName, string displayName, string descriptiveName)
+            : this(internalName, null, displayName, descriptiveName)
+        {
+        }
+
+        /// <summary>
+        /// Create's a basic command which uses <paramref name="parameter"/> as the parameter
+        /// </summary>
+        /// <param name="internalName">The internal name of the command</param>
+        /// <param name="parameter">The parameter that will be passed to the first argument of the method</param>
+        /// <param name="displayName">A short description which is displayed in the the top menu, e.g. "Save as...". Accepts localized key</param>
+        /// <param name="description">A description which is displayed in the search bar, e.g. "Save image as new". Leave empty to hide it from the search bar. Accepts localized key</param>
+        public BasicAttribute([InternalName] string internalName, object parameter, string displayName, string description)
+            : base(internalName, displayName, description)
+        {
+            Parameter = parameter;
+        }
+    }
+}

+ 51 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/CommandAttribute.cs

@@ -0,0 +1,51 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.Models.Commands.Attributes.Commands;
+
+internal partial class Command
+{
+    [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
+    internal abstract class CommandAttribute : Attribute
+    {
+        public string InternalName { get; }
+
+        public LocalizedString DisplayName { get; }
+
+        public LocalizedString Description { get; }
+
+        public string CanExecute { get; set; }
+
+        /// <summary>
+        /// Gets or sets the default shortcut key for this command
+        /// </summary>
+        public Key Key { get; set; }
+
+        /// <summary>
+        /// Gets or sets the default shortcut modfiers keys for this command
+        /// </summary>
+        public KeyModifiers Modifiers { get; set; }
+
+        /// <summary>
+        /// Gets or sets the name of the icon evaluator for this command
+        /// </summary>
+        public string IconEvaluator { get; set; }
+
+        /// <summary>
+        /// Gets or sets path to the icon. Must be bitmap image
+        /// </summary>
+        public string IconPath { get; set; }
+
+        protected CommandAttribute([InternalName] string internalName, string displayName, string description)
+        {
+            InternalName = internalName;
+            DisplayName = displayName;
+            Description = description;
+        }
+
+        public KeyCombination GetShortcut() => new() { Key = Key, Modifiers = Modifiers };
+    }
+}

+ 16 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/DebugAttribute.cs

@@ -0,0 +1,16 @@
+namespace PixiEditor.Models.Commands.Attributes.Commands;
+
+internal partial class Command
+{
+    internal class DebugAttribute : BasicAttribute
+    {
+        public DebugAttribute([InternalName] string internalName, string displayName, string descriptiveName) : base($"#DEBUG#{internalName}", displayName, descriptiveName)
+        {
+        }
+
+        public DebugAttribute([InternalName] string internalName, object parameter, string displayName, string description)
+            : base($"#DEBUG#{internalName}", parameter, displayName, description)
+        {
+        }
+    }
+}

+ 19 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/FilterAttribute.cs

@@ -0,0 +1,19 @@
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.Models.Commands.Attributes.Commands;
+
+internal partial class Command
+{
+    public class FilterAttribute : CommandAttribute
+    {
+        public LocalizedString SearchTerm { get; }
+        
+        public FilterAttribute([InternalName] string internalName, string displayName, string searchTerm) : base(internalName, displayName, string.Empty)
+        {
+            SearchTerm = searchTerm;
+        }
+        
+        public FilterAttribute([InternalName] string internalName) : base(internalName, null, null) { }
+    }
+}

+ 24 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/GroupAttribute.cs

@@ -0,0 +1,24 @@
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.Models.Commands.Attributes.Commands;
+
+internal partial class Command
+{
+    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
+    internal class GroupAttribute : Attribute
+    {
+        public string InternalName { get; }
+
+        public LocalizedString DisplayName { get; }
+
+        /// <summary>
+        /// Groups all commands that start with the name <paramref name="internalName"/>
+        /// </summary>
+        public GroupAttribute([InternalName] string internalName, string displayName)
+        {
+            InternalName = internalName;
+            DisplayName = displayName;
+        }
+    }
+}

+ 26 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/InternalAttribute.cs

@@ -0,0 +1,26 @@
+namespace PixiEditor.Models.Commands.Attributes.Commands;
+
+internal partial class Command
+{
+    /// <summary>
+    /// A command that is not shown in the UI
+    /// </summary>
+    internal class InternalAttribute : BasicAttribute
+    {
+        /// <summary>
+        /// A command that is not shown in the UI
+        /// </summary>
+        public InternalAttribute([InternalName] string name)
+            : base(name, string.Empty, string.Empty)
+        {
+        }
+
+        /// <summary>
+        /// A command that is not shown in the UI
+        /// </summary>
+        public InternalAttribute([InternalName] string name, object parameter)
+            : base(name, parameter, string.Empty, string.Empty)
+        {
+        }
+    }
+}

+ 17 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Commands/ToolAttribute.cs

@@ -0,0 +1,17 @@
+using System.Windows.Input;
+using Avalonia.Input;
+
+namespace PixiEditor.Models.Commands.Attributes.Commands;
+
+internal partial class Command
+{
+    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
+    internal class ToolAttribute : CommandAttribute
+    {
+        public Key Transient { get; set; }
+
+        public ToolAttribute() : base(null, null, null)
+        {
+        }
+    }
+}

+ 20 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Evaluators/CanExecuteAttribute.cs

@@ -0,0 +1,20 @@
+namespace PixiEditor.Models.Commands.Attributes.Evaluators;
+
+internal partial class Evaluator
+{
+    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = true)]
+    internal class CanExecuteAttribute : EvaluatorAttribute
+    {
+        public string[] NamesOfRequiredCanExecuteEvaluators { get; }
+
+        public CanExecuteAttribute([InternalName] string name) : base(name)
+        {
+            NamesOfRequiredCanExecuteEvaluators = Array.Empty<string>();
+        }
+
+        public CanExecuteAttribute([InternalName] string name, params string[] requires) : base(name)
+        {
+            NamesOfRequiredCanExecuteEvaluators = requires;
+        }
+    }
+}

+ 15 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Evaluators/EvaluatorAttribute.cs

@@ -0,0 +1,15 @@
+namespace PixiEditor.Models.Commands.Attributes.Evaluators;
+
+internal static partial class Evaluator
+{
+    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = true)]
+    internal abstract class EvaluatorAttribute : Attribute
+    {
+        public string Name { get; }
+
+        public EvaluatorAttribute([InternalName] string name)
+        {
+            Name = name;
+        }
+    }
+}

+ 12 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/Evaluators/IconAttribute.cs

@@ -0,0 +1,12 @@
+namespace PixiEditor.Models.Commands.Attributes.Evaluators;
+
+internal partial class Evaluator
+{
+    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = true)]
+    internal class IconAttribute : EvaluatorAttribute
+    {
+        public IconAttribute([InternalName] string name)
+            : base(name)
+        { }
+    }
+}

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Attributes/InternalNameAttribute.cs

@@ -0,0 +1,7 @@
+using System;
+
+namespace PixiEditor.Models.Commands.Attributes;
+
+[AttributeUsage(AttributeTargets.Parameter)]
+public class InternalNameAttribute : Attribute
+{ }

+ 86 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandCollection.cs

@@ -0,0 +1,86 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.DataHolders;
+using Command = PixiEditor.Models.Commands.Commands.Command;
+
+namespace PixiEditor.Models.Commands;
+
+[DebuggerDisplay("Count = {Count}")]
+internal class CommandCollection : ICollection<Command>
+{
+    private readonly Dictionary<string, Command> _commandInternalNames;
+    private readonly OneToManyDictionary<KeyCombination, Command> _commandShortcuts;
+
+    public int Count => _commandInternalNames.Count;
+
+    public bool IsReadOnly => false;
+
+    public Command this[string name] => _commandInternalNames[name];
+    public bool ContainsKey(string key) => _commandInternalNames.ContainsKey(key);
+
+    public List<Command> this[KeyCombination shortcut] => _commandShortcuts[shortcut];
+
+    public CommandCollection()
+    {
+        _commandInternalNames = new();
+        _commandShortcuts = new();
+    }
+
+    public void Add(Command item)
+    {
+        _commandInternalNames.Add(item.InternalName, item);
+        _commandShortcuts.Add(item.Shortcut, item);
+    }
+
+    public void Clear()
+    {
+        _commandInternalNames.Clear();
+        _commandShortcuts.Clear();
+    }
+
+    public void ClearShortcuts() => _commandShortcuts.Clear();
+
+    public bool Contains(Command item) => _commandInternalNames.ContainsKey(item.InternalName);
+
+    public void CopyTo(Command[] array, int arrayIndex) => _commandInternalNames.Values.CopyTo(array, arrayIndex);
+
+    public IEnumerator<Command> GetEnumerator() => _commandInternalNames.Values.GetEnumerator();
+
+    public bool Remove(Command item)
+    {
+        bool anyRemoved = false;
+
+        anyRemoved |= _commandInternalNames.Remove(item.InternalName);
+        anyRemoved |= _commandShortcuts.Remove(item);
+
+        return anyRemoved;
+    }
+
+    public void AddShortcut(Command command, KeyCombination shortcut)
+    {
+        _commandShortcuts.Remove(KeyCombination.None, command);
+        _commandShortcuts.Add(shortcut, command);
+    }
+
+    public void RemoveShortcut(Command command, KeyCombination shortcut)
+    {
+        _commandShortcuts.Remove(shortcut, command);
+        _commandShortcuts.Add(KeyCombination.None, command);
+    }
+
+    public void ClearShortcut(KeyCombination shortcut)
+    {
+        if (shortcut is { Key: Key.None, Modifiers: KeyModifiers.None })
+            return;
+        _commandShortcuts.AddRange(KeyCombination.None, _commandShortcuts[shortcut]);
+        _commandShortcuts.Clear(shortcut);
+    }
+
+    public IEnumerable<KeyValuePair<KeyCombination, IEnumerable<Command>>> GetShortcuts() =>
+        _commandShortcuts;
+
+    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}

+ 493 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandController.cs

@@ -0,0 +1,493 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Avalonia.Media.Imaging;
+using Microsoft.Extensions.DependencyInjection;
+using Newtonsoft.Json;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Commands;
+using PixiEditor.Models.Commands.Evaluators;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Dialogs;
+using CommandAttribute = PixiEditor.Models.Commands.Attributes.Commands.Command;
+
+namespace PixiEditor.Models.Commands;
+
+internal class CommandController
+{
+    private ShortcutFile shortcutFile;
+
+    public static CommandController Current { get; private set; }
+
+    public static string ShortcutsPath { get; private set; }
+
+    public CommandCollection Commands { get; }
+
+    public List<CommandGroup> CommandGroups { get; }
+
+    public OneToManyDictionary<string, Command> FilterCommands { get; }
+    
+    public Dictionary<string, string> FilterSearchTerm { get; }
+
+    public Dictionary<string, CanExecuteEvaluator> CanExecuteEvaluators { get; }
+
+    public Dictionary<string, IconEvaluator> IconEvaluators { get; }
+
+    public CommandController()
+    {
+        Current ??= this;
+
+        ShortcutsPath = Path.Join(
+            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+            "PixiEditor",
+            "shortcuts.json");
+
+        shortcutFile = new(ShortcutsPath, this);
+
+        FilterCommands = new();
+        FilterSearchTerm = new();
+        Commands = new();
+        CommandGroups = new();
+        CanExecuteEvaluators = new();
+        IconEvaluators = new();
+    }
+
+    public void Import(List<Shortcut> shortcuts, bool save = true)
+    {
+        foreach (var shortcut in shortcuts)
+        {
+            foreach (var command in shortcut.Commands)
+            {
+                if (Commands.ContainsKey(command))
+                {
+                    ReplaceShortcut(Commands[command], shortcut.KeyCombination);
+                }
+            }
+        }
+
+        if (save)
+        {
+            shortcutFile.SaveShortcuts();
+        }
+    }
+
+    private static List<(string internalName, LocalizedString displayName)> FindCommandGroups(IEnumerable<Type> typesToSearchForAttributes)
+    {
+        List<(string internalName, LocalizedString displayName)> result = new();
+
+        foreach (var type in typesToSearchForAttributes)
+        {
+            foreach (var group in type.GetCustomAttributes<CommandAttribute.GroupAttribute>())
+            {
+                result.Add((group.InternalName, group.DisplayName));
+            }
+        }
+
+        return result;
+    }
+
+    private static void ForEachMethod
+        (Type[] typesToSearchForMethods, IServiceProvider serviceProvider, Action<MethodInfo, object> action)
+    {
+        foreach (var type in typesToSearchForMethods)
+        {
+            object serviceInstance = serviceProvider.GetService(type);
+            var methods = type.GetMethods();
+            foreach (var method in methods)
+            {
+                action(method, serviceInstance);
+            }
+        }
+    }
+
+    public void Init(IServiceProvider serviceProvider)
+    {
+        ShortcutsTemplate template = new();
+        try
+        {
+            template = shortcutFile.LoadTemplate();
+        }
+        catch (JsonException)
+        {
+            File.Move(shortcutFile.Path, $"{shortcutFile.Path}.corrupted", true);
+            shortcutFile = new ShortcutFile(ShortcutsPath, this);
+            template = shortcutFile.LoadTemplate();
+            NoticeDialog.Show("SHORTCUTS_CORRUPTED", "SHORTCUTS_CORRUPTED_TITLE");
+        }
+        var compiledCommandList = new CommandNameList();
+        List<(string internalName, LocalizedString displayName)> commandGroupsData = FindCommandGroups(compiledCommandList.Groups);
+        OneToManyDictionary<string, Command> commands = new(); // internal name of the corr. group -> command in that group
+
+        LoadEvaluators(serviceProvider, compiledCommandList);
+        LoadCommands(serviceProvider, compiledCommandList, commandGroupsData, commands, template);
+        LoadTools(serviceProvider, commandGroupsData, commands, template);
+
+        var miscList = new List<Command>();
+
+        foreach (var (groupInternalName, storedCommands) in commands)
+        {
+            var groupData = commandGroupsData.FirstOrDefault(group => group.internalName == groupInternalName);
+            if (groupData == default || groupData.internalName == "PixiEditor.Links")
+            {
+                miscList.AddRange(storedCommands);
+                continue;
+            }
+
+            LocalizedString groupDisplayName = groupData.displayName;
+            CommandGroups.Add(new CommandGroup(groupDisplayName, storedCommands));
+        }
+        
+        CommandGroups.Add(new CommandGroup("MISC", miscList));
+    }
+
+    private void LoadTools(IServiceProvider serviceProvider, List<(string internalName, LocalizedString displayName)> commandGroupsData, OneToManyDictionary<string, Command> commands,
+        ShortcutsTemplate template)
+    {
+        foreach (var toolInstance in serviceProvider.GetServices<IToolHandler>())
+        {
+            var type = toolInstance.GetType();
+
+            if (!type.IsAssignableTo(typeof(IToolHandler)))
+                continue;
+
+            var toolAttr = type.GetCustomAttribute<CommandAttribute.ToolAttribute>();
+            if (toolAttr is null)
+                continue;
+
+            string internalName = $"PixiEditor.Tools.Select.{type.Name}";
+
+            LocalizedString displayName = new("SELECT_TOOL", toolInstance.DisplayName);
+
+            var command = new Command.ToolCommand()
+            {
+                InternalName = internalName,
+                DisplayName = displayName,
+                Description = displayName,
+                IconPath = $"@{toolInstance.ImagePath}",
+                IconEvaluator = IconEvaluator.Default,
+                TransientKey = toolAttr.Transient,
+                DefaultShortcut = toolAttr.GetShortcut(),
+                Shortcut = GetShortcut(internalName, toolAttr.GetShortcut(), template),
+                ToolType = type,
+            };
+
+            Commands.Add(command);
+            AddCommandToCommandsCollection(command, commandGroupsData, commands);
+        }
+    }
+
+    private KeyCombination GetShortcut(string internalName, KeyCombination defaultShortcut, ShortcutsTemplate template) =>
+        template.Shortcuts
+            .FirstOrDefault(x => x.Commands.Contains(internalName), new Shortcut(defaultShortcut, (List<string>)null))
+            .KeyCombination;
+
+    private void AddCommandToCommandsCollection(Command command, List<(string internalName, LocalizedString displayName)> commandGroupsData, OneToManyDictionary<string, Command> commands)
+    {
+        (string internalName, string displayName) group = commandGroupsData.FirstOrDefault(x => command.InternalName.StartsWith(x.internalName));
+        if (group == default)
+            commands.Add("", command);
+        else
+            commands.Add(group.internalName, command);
+    }
+
+    private void LoadCommands(IServiceProvider serviceProvider, CommandNameList compiledCommandList, List<(string internalName, LocalizedString displayName)> commandGroupsData, OneToManyDictionary<string, Command> commands, ShortcutsTemplate template)
+    {
+        foreach (var type in compiledCommandList.Commands)
+        {
+            foreach (var methodNames in type.Value)
+            {
+                var name = methodNames.Item1;
+
+                var methodInfo = type.Key.GetMethod(name, methodNames.Item2.ToArray());
+
+                var commandAttrs = methodInfo.GetCustomAttributes<CommandAttribute.CommandAttribute>();
+
+                foreach (var attribute in commandAttrs)
+                {
+                    if (attribute is CommandAttribute.BasicAttribute basic)
+                    {
+                        AddCommand(methodInfo, serviceProvider.GetService(type.Key), attribute,
+                            (isDebug, name, x, xCan, xIcon) => new Command.BasicCommand(x, xCan)
+                            {
+                                InternalName = name,
+                                IsDebug = isDebug,
+                                DisplayName = attribute.DisplayName,
+                                Description = attribute.Description,
+                                IconPath = attribute.IconPath,
+                                IconEvaluator = xIcon,
+                                DefaultShortcut = attribute.GetShortcut(),
+                                Shortcut = GetShortcut(name, attribute.GetShortcut(), template),
+                                Parameter = basic.Parameter,
+                            });
+                    }
+                    else if (attribute is CommandAttribute.FilterAttribute menu)
+                    {
+                        string searchTerm = menu.SearchTerm;
+                        
+                        if (searchTerm == null)
+                        {
+                            searchTerm = FilterSearchTerm[menu.InternalName];
+                        }
+                        else
+                        {
+                            FilterSearchTerm.Add(menu.InternalName, menu.SearchTerm);
+                        }
+
+                        bool hasFilter = FilterCommands.ContainsKey(searchTerm);
+                        
+                        foreach (var menuCommand in commandAttrs.Where(x => x is not CommandAttribute.FilterAttribute))
+                        {
+                            FilterCommands.Add(searchTerm, Commands[menuCommand.InternalName]);
+                        }
+
+                        if (hasFilter)
+                            continue;
+
+                        ISearchHandler searchHandler = serviceProvider.GetRequiredService<ISearchHandler>();
+
+                        if (searchHandler is null)
+                            continue;
+
+                        var command =
+                            new Command.BasicCommand(
+                                _ => searchHandler.OpenSearchWindow($":{searchTerm}:"),
+                                CanExecuteEvaluator.AlwaysTrue)
+                            {
+                                InternalName = menu.InternalName,
+                                DisplayName = menu.DisplayName,
+                                Description = menu.DisplayName,
+                                IconEvaluator = IconEvaluator.Default,
+                                DefaultShortcut = menu.GetShortcut(),
+                                Shortcut = GetShortcut(name, attribute.GetShortcut(), template)
+                            };
+                        
+                        Commands.Add(command);
+
+                        AddCommandToCommandsCollection(command, commandGroupsData, commands);
+                    }
+                }
+            }
+        }
+        
+        TCommand AddCommand<TAttr, TCommand>(MethodInfo method, object instance, TAttr attribute,
+            Func<bool, string, Action<object>, CanExecuteEvaluator, IconEvaluator, TCommand> commandFactory)
+            where TAttr : CommandAttribute.CommandAttribute
+            where TCommand : Command
+        {
+            if (method != null)
+            {
+                if (method.GetParameters().Length > 1)
+                {
+                    throw new Exception(
+                        $"Too many parameters for the CanExecute evaluator '{attribute.InternalName}' at {method.ReflectedType.FullName}.{method.Name}");
+                }
+                else if (!method.IsStatic && instance is null)
+                {
+                    throw new Exception(
+                        $"No type instance for the CanExecute evaluator '{attribute.InternalName}' at {method.ReflectedType.FullName}.{method.Name} found");
+                }
+            }
+
+            var parameters = method?.GetParameters();
+
+            async void ActionOnException(Task faultedTask)
+            {
+                // since this method is "async void" and not "async Task", the runtime will propagate exceptions out if it
+                // (instead of putting them into the returned task and forgetting about them)
+                await faultedTask; // this instantly throws the exception from the already faulted task
+            }
+
+            Action<object> action;
+            if (parameters is not { Length: 1 })
+            {
+                action = x =>
+                {
+                    object result = method.Invoke(instance, null);
+                    if (result is Task task)
+                        task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+                };
+            }
+            else
+            {
+                action = x =>
+                {
+                    object result = method.Invoke(instance, new[] { x });
+                    if (result is Task task)
+                        task.ContinueWith(ActionOnException, TaskContinuationOptions.OnlyOnFaulted);
+                };
+            }
+
+            string name = attribute.InternalName;
+            bool isDebug = attribute.InternalName.StartsWith("#DEBUG#");
+
+            if (attribute.InternalName.StartsWith("#DEBUG#"))
+            {
+                name = name["#DEBUG#".Length..];
+            }
+
+            var command = commandFactory(
+                isDebug,
+                name,
+                action,
+                attribute.CanExecute != null ? CanExecuteEvaluators[attribute.CanExecute] : CanExecuteEvaluator.AlwaysTrue,
+                attribute.IconEvaluator != null ? IconEvaluators[attribute.IconEvaluator] : IconEvaluator.Default);
+
+            Commands.Add(command);
+            AddCommandToCommandsCollection(command, commandGroupsData, commands);
+
+            return command;
+        }
+    }
+
+    private void LoadEvaluators(IServiceProvider serviceProvider, CommandNameList compiledCommandList)
+    {
+        object CastParameter(object input, Type target)
+        {
+            if (target == typeof(object) || target == input?.GetType())
+                return input;
+            return Convert.ChangeType(input, target);
+        }
+
+        void AddEvaluatorFactory<TAttr, T, TParameter>(MethodInfo method, object serviceInstance, TAttr attribute,
+            IDictionary<string, T> evaluators, Func<Func<object, TParameter>, T> factory)
+            where T : Evaluator<TParameter>, new()
+            where TAttr : Evaluator.EvaluatorAttribute
+        {
+            if (method.ReturnType != typeof(TParameter))
+            {
+                throw new Exception(
+                    $"Invalid return type for the CanExecute evaluator '{attribute.Name}' at {method.ReflectedType.FullName}.{method.Name}\nExpected '{typeof(TParameter).FullName}'");
+            }
+            else if (method.GetParameters().Length > 1)
+            {
+                throw new Exception(
+                    $"Too many parameters for the CanExecute evaluator '{attribute.Name}' at {method.ReflectedType.FullName}.{method.Name}");
+            }
+            else if (!method.IsStatic && serviceInstance is null)
+            {
+                throw new Exception(
+                    $"No type instance for the CanExecute evaluator '{attribute.Name}' at {method.ReflectedType.FullName}.{method.Name} found");
+            }
+
+            var parameters = method.GetParameters();
+
+            Func<object, TParameter> func;
+
+            if (parameters.Length == 1)
+            {
+                func = x => (TParameter)method.Invoke(serviceInstance,
+                    new[] { CastParameter(x, parameters[0].ParameterType) });
+            }
+            else
+            {
+                func = x => (TParameter)method.Invoke(serviceInstance, null);
+            }
+
+            T evaluator = factory(func);
+
+            evaluators.Add(evaluator.Name, evaluator);
+        }
+
+        void AddEvaluator<TAttr, T, TParameter>(MethodInfo method, object instance, TAttr attribute,
+            IDictionary<string, T> evaluators)
+            where T : Evaluator<TParameter>, new()
+            where TAttr : Evaluator.EvaluatorAttribute
+            => AddEvaluatorFactory<TAttr, T, TParameter>(method, instance, attribute, evaluators,
+                x => new T() { Name = attribute.Name, Evaluate = x });
+
+        {
+            foreach (var type in compiledCommandList.Evaluators)
+            {
+                foreach (var methodNames in type.Value)
+                {
+                    var name = methodNames.Item1;
+
+                    var methodInfo = type.Key.GetMethod(name, methodNames.Item2.ToArray());
+
+                    var commandAttrs = methodInfo.GetCustomAttributes<Evaluator.EvaluatorAttribute>();
+
+                    foreach (var attribute in commandAttrs)
+                    {
+                        switch (attribute)
+                        {
+                            case Evaluator.CanExecuteAttribute canExecuteAttribute:
+                            {
+                                var getRequiredEvaluatorsObjectsOfCurrentEvaluator =
+                                    (CommandController controller) =>
+                                        canExecuteAttribute.NamesOfRequiredCanExecuteEvaluators.Select(x =>
+                                            controller.CanExecuteEvaluators[x]);
+
+                                AddEvaluatorFactory<Evaluator.CanExecuteAttribute, CanExecuteEvaluator, bool>(
+                                    methodInfo,
+                                    serviceProvider.GetService(type.Key),
+                                    canExecuteAttribute,
+                                    CanExecuteEvaluators,
+                                    evaluateFunction => new CanExecuteEvaluator()
+                                    {
+                                        Name = attribute.Name,
+                                        Evaluate = evaluateFunctionArgument =>
+                                            evaluateFunction.Invoke(evaluateFunctionArgument) &&
+                                            getRequiredEvaluatorsObjectsOfCurrentEvaluator.Invoke(this).All(
+                                                requiredEvaluator =>
+                                                    requiredEvaluator.CallEvaluate(null, evaluateFunctionArgument))
+                                    });
+                                break;
+                            }
+                            case Evaluator.IconAttribute icon:
+                                AddEvaluator<Evaluator.IconAttribute, IconEvaluator, Bitmap>(methodInfo,
+                                    serviceProvider.GetService(type.Key), icon, IconEvaluators);
+                                break;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /// <summary>
+    /// Removes the old shortcut to this command and adds the new one
+    /// </summary>
+    public void UpdateShortcut(Command command, KeyCombination newShortcut)
+    {
+        Commands.RemoveShortcut(command, command.Shortcut);
+        Commands.AddShortcut(command, newShortcut);
+        command.Shortcut = newShortcut;
+        shortcutFile.SaveShortcuts();
+    }
+
+    /// <summary>
+    /// Deletes all shortcuts of <paramref name="newShortcut"/> and adds <paramref name="command"/>
+    /// </summary>
+    public void ReplaceShortcut(Command command, KeyCombination newShortcut)
+    {
+        foreach (Command other in Commands[newShortcut])
+        {
+            other.Shortcut = KeyCombination.None;
+        }
+
+        Commands.ClearShortcut(newShortcut);
+        Commands.RemoveShortcut(command, command.Shortcut);
+        Commands.AddShortcut(command, newShortcut);
+        command.Shortcut = newShortcut;
+        shortcutFile.SaveShortcuts();
+    }
+
+    public void ResetShortcuts()
+    {
+        File.Copy(ShortcutsPath, Path.ChangeExtension(ShortcutsPath, ".json.bak"), true);
+
+        Commands.ClearShortcuts();
+
+        foreach (var command in Commands)
+        {
+            Commands.RemoveShortcut(command, command.Shortcut);
+            Commands.AddShortcut(command, command.DefaultShortcut);
+            command.Shortcut = command.DefaultShortcut;
+        }
+
+        shortcutFile.SaveShortcuts();
+    }
+}

+ 69 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandGroup.cs

@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Input;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Commands;
+using PixiEditor.Models.DataHolders;
+using ReactiveUI;
+
+namespace PixiEditor.Models.Commands;
+
+internal class CommandGroup : ReactiveObject
+{
+    private readonly Command[] commands;
+    private readonly Command[] visibleCommands;
+
+    private LocalizedString displayName;
+
+    public LocalizedString DisplayName
+    {
+        get => displayName;
+        set => this.RaiseAndSetIfChanged(ref displayName, value);
+    }
+
+    public bool HasAssignedShortcuts { get; set; }
+
+    public IEnumerable<Command> Commands => commands;
+
+    public IEnumerable<Command> VisibleCommands => visibleCommands;
+
+    public CommandGroup(LocalizedString displayName, IEnumerable<Command> commands)
+    {
+        DisplayName = displayName;
+        this.commands = commands.ToArray();
+        visibleCommands = commands.Where(x => !string.IsNullOrEmpty(x.DisplayName.Value)).ToArray();
+
+        foreach (var command in commands)
+        {
+            HasAssignedShortcuts |= command.Shortcut.Key != Key.None;
+            command.ShortcutChanged += Command_ShortcutChanged;
+        }
+
+        ILocalizationProvider.Current.OnLanguageChanged += OnLanguageChanged;
+    }
+
+    private void OnLanguageChanged(Language obj)
+    {
+        DisplayName = new LocalizedString(DisplayName.Key);
+    }
+
+    private void Command_ShortcutChanged(Command cmd, ShortcutChangedEventArgs args)
+    {
+        if ((args.NewShortcut != KeyCombination.None && HasAssignedShortcuts) ||
+            (args.NewShortcut == KeyCombination.None && !HasAssignedShortcuts))
+        {
+            // If a shortcut is already assigned and the new shortcut is not none nothing can change
+            // If no shortcut is already assigned and the new shortcut is none nothing can change
+            return;
+        }
+
+        HasAssignedShortcuts = false;
+
+        foreach (var command in commands)
+        {
+            HasAssignedShortcuts |= command.Shortcut.Key != Key.None;
+        }
+    }
+
+    public IEnumerator<Command> GetEnumerator() => Commands.GetEnumerator();
+}

+ 28 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandMethods.cs

@@ -0,0 +1,28 @@
+using PixiEditor.Models.Commands.Commands;
+using PixiEditor.Models.Commands.Evaluators;
+
+namespace PixiEditor.Models.Commands;
+
+internal class CommandMethods
+{
+    private readonly Command _command;
+    private readonly Action<object> _execute;
+    private readonly CanExecuteEvaluator _canExecute;
+
+    public CommandMethods(Command command, Action<object> execute, CanExecuteEvaluator canExecute)
+    {
+        _execute = execute;
+        _canExecute = canExecute;
+        _command = command;
+    }
+
+    public void Execute(object parameter)
+    {
+        if (CanExecute(parameter))
+        {
+            _execute(parameter);
+        }
+    }
+
+    public bool CanExecute(object parameter) => _canExecute.CallEvaluate(_command, parameter);
+}

+ 28 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/CommandNameList.cs

@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+
+namespace PixiEditor.Models.Commands;
+
+internal partial class CommandNameList
+{
+    partial void AddCommands();
+
+    partial void AddEvaluators();
+
+    partial void AddGroups();
+    
+    public Dictionary<Type, List<(string, Type[])>> Commands { get; }
+    
+    public Dictionary<Type, List<(string, Type[])>> Evaluators { get; }
+    
+    public List<Type> Groups { get; }
+
+    public CommandNameList()
+    {
+        Commands = new();
+        Evaluators = new();
+        Groups = new();
+        AddCommands();
+        AddEvaluators();
+        AddGroups();
+    }
+}

+ 15 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/BasicCommand.cs

@@ -0,0 +1,15 @@
+using PixiEditor.Models.Commands.Evaluators;
+
+namespace PixiEditor.Models.Commands.Commands;
+
+internal partial class Command
+{
+    internal class BasicCommand : Command
+    {
+        public object Parameter { get; init; }
+
+        public override object GetParameter() => Parameter;
+
+        public BasicCommand(Action<object> onExecute, CanExecuteEvaluator canExecute) : base(onExecute, canExecute) { }
+    }
+}

+ 75 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/Command.cs

@@ -0,0 +1,75 @@
+using System.Diagnostics;
+using System.Windows.Input;
+using System.Windows.Media;
+using Avalonia.Media;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Commands.Evaluators;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Localization;
+using ReactiveUI;
+
+namespace PixiEditor.Models.Commands.Commands;
+
+[DebuggerDisplay("{InternalName,nq} ('{DisplayName,nq}')")]
+internal abstract partial class Command : ReactiveObject
+{
+    private KeyCombination _shortcut;
+
+    public bool IsDebug { get; init; }
+
+    public string InternalName { get; init; }
+
+    public string IconPath { get; init; }
+
+    public IconEvaluator IconEvaluator { get; init; }
+
+    public LocalizedString DisplayName { get; set; }
+
+    public LocalizedString Description { get; set; }
+
+    public CommandMethods Methods { get; init; }
+
+    public KeyCombination DefaultShortcut { get; init; }
+
+    public KeyCombination Shortcut
+    {
+        get => _shortcut;
+        set
+        {
+            var oldValue = _shortcut;
+            var combination = this.RaiseAndSetIfChanged(ref _shortcut, value);
+            if (combination != oldValue)
+            {
+                ShortcutChanged?.Invoke(this, new(oldValue, value));
+            }
+        }
+    }
+
+    public event ShortcutChangedEventHandler ShortcutChanged;
+
+    public abstract object GetParameter();
+
+    protected Command(Action<object> onExecute, CanExecuteEvaluator canExecute)
+    {
+        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*/
+    }
+
+    private void OnLanguageChanged(Language obj)
+    {
+        DisplayName = new LocalizedString(DisplayName.Key, DisplayName.Parameters);
+        Description = new LocalizedString(Description.Key, Description.Parameters);
+
+        this.RaisePropertyChanged(nameof(DisplayName));
+        this.RaisePropertyChanged(nameof(Description));
+    }
+
+    public void Execute() => Methods.Execute(GetParameter());
+
+    public bool CanExecute() => Methods.CanExecute(GetParameter());
+
+    public IImage GetIcon() => IconEvaluator.CallEvaluate(this, GetParameter());
+
+    public delegate void ShortcutChangedEventHandler(Command command, ShortcutChangedEventArgs args);
+}

+ 20 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Commands/ToolCommand.cs

@@ -0,0 +1,20 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.Containers;
+using PixiEditor.ViewModels;
+
+namespace PixiEditor.Models.Commands.Commands;
+
+internal partial class Command
+{
+    internal class ToolCommand : Command
+    {
+        public Type ToolType { get; init; }
+
+        public Key TransientKey { get; init; }
+
+        public override object GetParameter() => ToolType;
+
+        public ToolCommand(IToolsHandler handler) : base(handler.SetTool, CommandController.Current.CanExecuteEvaluators["PixiEditor.HasDocument"]) { }
+    }
+}

+ 22 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Evaluators/CanExecuteEvaluator.cs

@@ -0,0 +1,22 @@
+using PixiEditor.Models.Commands.Commands;
+
+namespace PixiEditor.Models.Commands.Evaluators;
+
+internal class CanExecuteEvaluator : Evaluator<bool>
+{
+    public static CanExecuteEvaluator AlwaysTrue { get; } = new StaticValueEvaluator(true);
+
+    public static CanExecuteEvaluator AlwaysFalse { get; } = new StaticValueEvaluator(false);
+
+    private class StaticValueEvaluator : CanExecuteEvaluator
+    {
+        private readonly bool value;
+
+        public StaticValueEvaluator(bool value)
+        {
+            this.value = value;
+        }
+
+        public override bool CallEvaluate(Command command, object parameter) => value;
+    }
+}

+ 17 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Evaluators/Evaluator.cs

@@ -0,0 +1,17 @@
+using System.Diagnostics;
+using PixiEditor.Models.Commands.Commands;
+
+namespace PixiEditor.Models.Commands.Evaluators;
+
+[DebuggerDisplay("{Name,nq}")]
+internal abstract class Evaluator<T>
+{
+    public string Name { get; init; }
+
+    public Func<object, T> Evaluate { private get; init; }
+
+    /// <param name="command">The command this evaluator corresponds to</param>
+    /// <param name="parameter">The parameter to pass to the Evaluate function</param>
+    /// <returns>The value returned by the Evaluate function</returns>
+    public virtual T CallEvaluate(Command command, object parameter) => Evaluate(parameter);
+}

+ 94 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Evaluators/IconEvaluator.cs

@@ -0,0 +1,94 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using Avalonia.Media.Imaging;
+using PixiEditor.Models.Commands.Commands;
+
+namespace PixiEditor.Models.Commands.Evaluators;
+
+internal class IconEvaluator : Evaluator<Bitmap>
+{
+    public static IconEvaluator Default { get; } = new CommandNameEvaluator();
+
+    public override Bitmap CallEvaluate(Command command, object parameter) =>
+        base.CallEvaluate(command, parameter ?? command);
+
+    public static string GetDefaultPath(Command command)
+    {
+        string path;
+
+        if (command.IconPath != null)
+        {
+            if (command.IconPath.StartsWith('@'))
+            {
+                path = command.IconPath[1..];
+            }
+            else if (command.IconPath.StartsWith('$'))
+            {
+                path = $"Images/Commands/{command.IconPath[1..].Replace('.', '/')}.png";
+            }
+            else
+            {
+                path = $"Images/{command.IconPath}";
+            }
+        }
+        else
+        {
+            path = $"Images/Commands/{command.InternalName.Replace('.', '/')}.png";
+        }
+
+        path = path.ToLower();
+
+        if (path.StartsWith("/"))
+        {
+            path = path[1..];
+        }
+
+        return path;
+    }
+
+    [DebuggerDisplay("IconEvaluator.Default")]
+    private class CommandNameEvaluator : IconEvaluator
+    {
+        public static string[] resources = GetResourceNames();
+
+        public static Dictionary<string, Bitmap> images = new();
+
+        public override Bitmap CallEvaluate(Command command, object parameter)
+        {
+            string path = GetDefaultPath(command);
+
+            if (resources.Contains(path))
+            {
+                var image = images.GetValueOrDefault(path);
+
+                if (image == null)
+                {
+                    using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream($"pack://application:,,,/{path}")!;
+                    image = new Bitmap(stream);
+                    images.Add(path, image);
+                }
+
+                return image;
+            }
+
+            return null;
+        }
+
+        private static string[] GetResourceNames()
+        {
+            var assembly = Assembly.GetExecutingAssembly();
+            string resName = assembly.GetName().Name + ".g.resources";
+            using var stream = assembly.GetManifestResourceStream(resName);
+            using var reader = new System.Resources.ResourceReader(stream);
+
+            return reader.Cast<DictionaryEntry>().Select(entry =>
+                (string)entry.Key).ToArray();
+        }
+    }
+}

+ 15 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Exceptions/CommandNotFoundException.cs

@@ -0,0 +1,15 @@
+namespace PixiEditor.Models.Commands.Exceptions;
+
+[Serializable]
+internal class CommandNotFoundException : Exception
+{
+    public string CommandName { get; set; }
+
+    public CommandNotFoundException(string name) : this(name, null) { }
+
+    public CommandNotFoundException(string name, Exception inner) : base($"No command with the name '{name}' found", inner) { }
+
+    protected CommandNotFoundException(
+      System.Runtime.Serialization.SerializationInfo info,
+      System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+}

+ 76 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/ColorSearchResult.cs

@@ -0,0 +1,76 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using PixiEditor.Extensions.Palettes;
+
+namespace PixiEditor.Models.Commands.Search;
+
+internal class ColorSearchResult : SearchResult
+{
+    private readonly DrawingImage icon;
+    private readonly DrawingApi.Core.ColorsImpl.Color color;
+    private string text;
+    private bool requiresDocument;
+    private bool isPalettePaste;
+    private readonly Action<DrawingApi.Core.ColorsImpl.Color> target;
+
+    public override string Text => text;
+
+    public override AvaloniaObject Description => new TextBlock(
+    {
+        Text = $"{color} rgba({color.R}, {color.G}, {color.B}, {color.A})",
+        FontSize = 16,
+        TextDecorations = GetDecoration(new Pen(new SolidColorBrush(color.ToOpaqueMediaColor()), 1))
+    };
+
+    //public override bool CanExecute => !requiresDocument || (requiresDocument && ViewModelMain.Current.BitmapManager.ActiveDocument != null);
+    public override bool CanExecute => !isPalettePaste || ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument != null;
+
+    public override IImage Icon => icon;
+
+    public override Task Execute()
+    {
+        target(color);
+        return Task.CompletedTask;
+    }
+
+    private ColorSearchResult(DrawingApi.Core.ColorsImpl.Color color, Action<DrawingApi.Core.ColorsImpl.Color> target)
+    {
+        this.color = color;
+        icon = GetIcon(color);
+        this.target = target;
+    }
+
+    public ColorSearchResult(DrawingApi.Core.ColorsImpl.Color color) : this(color, x => ViewModelMain.Current.ColorsSubViewModel.PrimaryColor = x)
+    {
+        text = $"Set color to {color}";
+    }
+
+    public static ColorSearchResult PastePalette(DrawingApi.Core.ColorsImpl.Color color, string searchTerm = null)
+    {
+        var result = new ColorSearchResult(color, x =>
+            ViewModelMain.Current.DocumentManagerSubViewModel.ActiveDocument!.Palette.Add(new PaletteColor(x.R, x.G, x.B)))
+        {
+            SearchTerm = searchTerm,
+            isPalettePaste = true
+        };
+        result.text = $"Add color {color} to palette";
+        result.requiresDocument = true;
+
+        return result;
+    }
+
+    public static DrawingImage GetIcon(DrawingApi.Core.ColorsImpl.Color color)
+    {
+        var drawing = new GeometryDrawing() { Brush = new SolidColorBrush(color.ToOpaqueMediaColor()), Pen = new(Brushes.White, 1) };
+        var geometry = new EllipseGeometry(new(5, 5), 5, 5) { };
+        drawing.Geometry = geometry;
+        return new DrawingImage(drawing);
+    }
+    
+    private static TextDecorationCollection GetDecoration(Pen pen) => new()
+    {
+        new TextDecoration(TextDecorationLocation.Underline, pen, 0, TextDecorationUnit.Pixel, TextDecorationUnit.Pixel)
+    };
+}

+ 22 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/CommandSearchResult.cs

@@ -0,0 +1,22 @@
+using System.Windows.Media;
+using PixiEditor.Models.Commands.Commands;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands.Search;
+
+internal class CommandSearchResult : SearchResult
+{
+    public Command Command { get; }
+
+    public override string Text => Command.Description;
+
+    public override bool CanExecute => Command.CanExecute();
+
+    public override ImageSource Icon => Command.IconEvaluator.CallEvaluate(Command, this);
+
+    public override KeyCombination Shortcut => Command.Shortcut;
+
+    public CommandSearchResult(Command command) => Command = command;
+
+    public override void Execute() => Command.Execute();
+}

+ 51 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/FileSearchResult.cs

@@ -0,0 +1,51 @@
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using PixiEditor.Helpers.Converters;
+
+namespace PixiEditor.Models.Commands.Search;
+
+internal class FileSearchResult : SearchResult
+{
+    private readonly DrawingImage icon;
+    private readonly bool asReferenceLayer;
+
+    public string FilePath { get; }
+
+    public override string Text => asReferenceLayer ? $"As reference: ...\\{Path.GetFileName(FilePath)}" : $"...\\{Path.GetFileName(FilePath)}";
+
+    public override FrameworkElement Description => new TextBlock { Text = FilePath, FontSize = 16 };
+
+    public override bool CanExecute => !asReferenceLayer ||
+                CommandController.Current.Commands["PixiEditor.Clipboard.PasteReferenceLayerFromPath"].Methods.CanExecute(FilePath);
+
+    public override ImageSource Icon => icon;
+
+    public FileSearchResult(string path, bool asReferenceLayer = false)
+    {
+        FilePath = path;
+        var drawing = new GeometryDrawing() { Brush = FileExtensionToColorConverter.GetBrush(FilePath) };
+        var geometry = new RectangleGeometry(new(0, 0, 10, 10), 3, 3) { };
+        drawing.Geometry = geometry;
+        icon = new DrawingImage(drawing);
+        this.asReferenceLayer = asReferenceLayer;
+    }
+
+    public override void Execute()
+    {
+        if (!asReferenceLayer)
+        {
+            CommandController.Current.Commands["PixiEditor.File.OpenRecent"].Methods.Execute(FilePath);
+        }
+        else
+        {
+            var command = CommandController.Current.Commands["PixiEditor.Clipboard.PasteReferenceLayerFromPath"];
+            if (command.Methods.CanExecute(FilePath))
+            {
+                CommandController.Current.Commands["PixiEditor.Clipboard.PasteReferenceLayerFromPath"].Methods
+                    .Execute(FilePath);
+            }
+        }
+    }
+}

+ 77 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/SearchResult.cs

@@ -0,0 +1,77 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls.Documents;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using PixiEditor.Models.DataHolders;
+using ReactiveUI;
+
+namespace PixiEditor.Models.Commands.Search;
+
+internal abstract class SearchResult : ReactiveObject
+{
+    private bool isSelected;
+    private bool isMouseSelected;
+
+    public string SearchTerm { get; init; }
+
+    public virtual Inline[] TextBlockContent => GetInlines().ToArray();
+
+    public Match Match { get; init; }
+
+    public abstract string Text { get; }
+
+    public virtual AvaloniaObject Description { get; }
+
+    public abstract bool CanExecute { get; }
+
+    public abstract IImage Icon { get; }
+
+    public bool IsSelected
+    {
+        get => isSelected;
+        set => this.RaiseAndSetIfChanged(ref isSelected, value);
+    }
+
+    public bool IsMouseSelected
+    {
+        get => isMouseSelected;
+        set => this.RaiseAndSetIfChanged(ref isMouseSelected, value);
+    }
+
+
+    public abstract Task Execute();
+
+    public virtual KeyCombination Shortcut { get; }
+
+    public IReactiveCommand ExecuteCommand { get; }
+
+    public SearchResult()
+    {
+        ExecuteCommand = ReactiveCommand.CreateFromTask(_ => Execute(), this.WhenAnyValue(x => CanExecute));
+    }
+
+    private IEnumerable<Inline> GetInlines()
+    {
+        if (Match is not { Success: true })
+        {
+            yield return new Run(Text);
+            yield break;
+        }
+
+        foreach (Group group in Match.Groups.Values.Skip(1))
+        {
+            var run = new Run(group.Value);
+
+            if (group.Value.Equals(SearchTerm, StringComparison.OrdinalIgnoreCase))
+            {
+                run.FontWeight = FontWeight.Bold;
+            }
+
+            yield return run;
+        }
+    }
+}

+ 16 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutChangedEventArgs.cs

@@ -0,0 +1,16 @@
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands;
+
+internal class ShortcutChangedEventArgs : EventArgs
+{
+    public KeyCombination OldShortcut { get; }
+
+    public KeyCombination NewShortcut { get; }
+
+    public ShortcutChangedEventArgs(KeyCombination oldShortcut, KeyCombination newShortcut)
+    {
+        OldShortcut = oldShortcut;
+        NewShortcut = newShortcut;
+    }
+}

+ 56 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutFile.cs

@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Newtonsoft.Json;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands;
+
+internal class ShortcutFile
+{
+    private readonly CommandController _commands;
+    public string Path { get; }
+
+    public ShortcutFile(string path, CommandController controller)
+    {
+        _commands = controller;
+        Path = path;
+
+        if (!File.Exists(path))
+        {
+            Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path));
+            File.Create(Path).Dispose();
+        }
+    }
+
+    public void SaveShortcuts()
+    {
+        List<Shortcut> shortcuts = new();
+
+        foreach (var shortcut in _commands.Commands.GetShortcuts())
+        {
+            foreach (var command in shortcut.Value.Where(x => x.Shortcut != x.DefaultShortcut))
+            {
+                Shortcut shortcutToAdd = new Shortcut(shortcut.Key, new List<string> { command.InternalName });
+                shortcuts.Add(shortcutToAdd);
+            }
+        }
+        
+        ShortcutsTemplate template = new()
+        {
+            Shortcuts = shortcuts.ToList(),
+        };
+
+        File.WriteAllText(Path, JsonConvert.SerializeObject(template));
+    }
+
+    public ShortcutsTemplate LoadTemplate() => LoadTemplate(Path);
+
+    public static ShortcutsTemplate LoadTemplate(string path)
+    {
+        var template = JsonConvert.DeserializeObject<ShortcutsTemplate>(File.ReadAllText(path));
+        if (template == null) return new ShortcutsTemplate();
+
+        return template;
+    }
+}

+ 58 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/ShortcutsTemplate.cs

@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.IO;
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.Commands.Templates.Parsers;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands;
+
+[Serializable]
+public sealed class ShortcutsTemplate
+{
+    public List<Shortcut> Shortcuts { get; set; }
+
+    public ShortcutsTemplate()
+    {
+        Shortcuts = new List<Shortcut>();
+    }
+
+    public static ShortcutsTemplate FromKeyDefinitions(List<KeyDefinition> keyDefinitions)
+    {
+        ShortcutsTemplate template = new ShortcutsTemplate();
+        foreach (KeyDefinition keyDefinition in keyDefinitions)
+        {
+            template.Shortcuts.Add(new Shortcut(keyDefinition.DefaultShortcut.ToKeyCombination(), keyDefinition.Command));
+        }
+
+        return template;
+    }
+}
+
+[Serializable]
+public sealed class Shortcut
+{
+    public KeyCombination KeyCombination { get; set; }
+    public List<string> Commands { get; set; }
+    
+    public Shortcut() { }
+
+    public Shortcut(KeyCombination keyCombination, List<string> commands)
+    {
+        KeyCombination = keyCombination;
+        Commands = commands;
+    }
+    
+    public Shortcut(KeyCombination keyCombination, string command)
+    {
+        KeyCombination = keyCombination;
+        Commands = new List<string> { command };
+    }
+    
+    public Shortcut(Key key, KeyModifiers modifierKeys, string command)
+    {
+        KeyCombination = new KeyCombination(key, modifierKeys);
+        Commands = new List<string> { command };
+    }
+}

+ 9 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/ICustomShortcutFormat.cs

@@ -0,0 +1,9 @@
+using PixiEditor.Models.Commands.Templates.Parsers;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+public interface ICustomShortcutFormat
+{
+    public KeysParser KeysParser { get; }
+    public string[] CustomShortcutExtensions { get; }
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/IShortcutDefaults.cs

@@ -0,0 +1,8 @@
+using System.Collections.Generic;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+internal interface IShortcutDefaults
+{
+    List<Shortcut> DefaultShortcuts { get; }
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/IShortcutFile.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.Commands.Templates;
+
+internal interface IShortcutFile
+{
+    string Filter { get; }
+
+    ShortcutsTemplate GetShortcutsTemplate(string path);
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/IShortcutInstallation.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.Commands.Templates;
+
+internal interface IShortcutInstallation
+{
+    bool InstallationPresent { get; }
+
+    ShortcutsTemplate GetInstalledShortcuts();
+}

+ 37 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/AsepriteProvider.cs

@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Windows.Input;
+using PixiEditor.Models.Commands.Templates.Parsers;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+internal partial class ShortcutProvider
+{
+    public static AsepriteProvider Aseprite { get; } = new();
+
+    internal class AsepriteProvider : ShortcutProvider, IShortcutDefaults, IShortcutInstallation, ICustomShortcutFormat
+    {
+        private static string InstallationPath { get; } = Path.Combine(
+            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Aseprite", "user.aseprite-keys");
+
+        private AsepriteKeysParser _parser;
+        
+        public AsepriteProvider() : base("Aseprite")
+        {
+            _parser = new AsepriteKeysParser("AsepriteShortcutMap.json");
+            LogoPath = "/Images/TemplateLogos/Aseprite.png";
+            HoverLogoPath = "/Images/TemplateLogos/Aseprite-Hover.png";
+        }
+
+        public bool InstallationPresent => File.Exists(InstallationPath);
+        
+        public ShortcutsTemplate GetInstalledShortcuts()
+        {
+            return _parser.Parse(InstallationPath, true);
+        }
+
+        public List<Shortcut> DefaultShortcuts => _parser.Defaults;
+        public KeysParser KeysParser => _parser;
+        public string[] CustomShortcutExtensions => new[] { ".aseprite-keys" };
+    }
+}

+ 40 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/DebugProvider.cs

@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Windows.Input;
+using Avalonia.Input;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+internal partial class ShortcutProvider
+{
+    private static DebugProvider Debug { get; } = new();
+
+    internal class DebugProvider : ShortcutProvider, IShortcutDefaults, IShortcutFile, IShortcutInstallation
+    {
+        private static string InstallationPath { get; } = Path.Combine(
+            Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+            "shortcut-provider.json");
+
+        public override string Description => "A provider for testing providers";
+
+        public DebugProvider() : base("Debug")
+        {
+        }
+
+        public List<Shortcut> DefaultShortcuts { get; } = new()
+        {
+            // Add shortcuts for undo and redo
+            new Shortcut(Key.Z, KeyModifiers.Control, "PixiEditor.Undo.Undo"),
+            new Shortcut(Key.Y, KeyModifiers.Control, "PixiEditor.Undo.Redo"),
+            new Shortcut(Key.X, KeyModifiers.None, "PixiEditor.Colors.Swap")
+        };
+
+        public string Filter => "json (*.json)|*.json";
+
+        public ShortcutsTemplate GetShortcutsTemplate(string path) => ShortcutFile.LoadTemplate(path);
+
+        public bool InstallationPresent => File.Exists(InstallationPath);
+
+        public ShortcutsTemplate GetInstalledShortcuts() => ShortcutFile.LoadTemplate(InstallationPath);
+    }
+}

+ 213 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/AsepriteKeysParser.cs

@@ -0,0 +1,213 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Xml;
+using PixiEditor.Avalonia.Exceptions.Exceptions;
+
+namespace PixiEditor.Models.Commands.Templates.Parsers;
+
+/// <summary>
+///     Aseprite uses XML (under .aseprite-keys file) to store keybindings.
+/// This class is used to load and parse this file into <see cref="ShortcutsTemplate"/> class.
+///
+/// Aseprite keys consists of 5 sections:
+/// <list type="">
+///     <item>Commands</item>
+///     <item>Tools</item>
+///     <item>QuickTools</item>
+///     <item>Actions</item>
+///     <item>Actions</item>
+/// </list>
+///  
+/// We are only interested in Commands and Tools sections, because actions (like binding Left Mouse Button to some shortcut)
+/// are not yet supported by us.
+/// </summary>
+public class AsepriteKeysParser : KeysParser
+{
+    public AsepriteKeysParser(string mapFileName) : base(mapFileName)
+    {
+    }
+
+    public override ShortcutsTemplate Parse(string path, bool applyDefaults)
+    {
+        if (!File.Exists(path))
+        {
+            throw new MissingFileException("FILE_NOT_FOUND", $"File {path} not found");
+        }
+
+        if (Path.GetExtension(path) != ".aseprite-keys")
+        {
+            throw new InvalidFileTypeException("FILE_FORMAT_NOT_ASEPRITE_KEYS", $"File {path} is not an aseprite-keys file");
+        }
+        
+        return LoadAndParse(path, applyDefaults);
+    }
+
+    private ShortcutsTemplate LoadAndParse(string path, bool applyDefaults)
+    {
+        XmlDocument doc = new XmlDocument();
+
+        try
+        {
+            doc.Load(path);
+        }
+        catch (Exception e) when (e is DirectoryNotFoundException or FileNotFoundException or PathTooLongException)
+        {
+            throw new MissingFileException("FILE_NOT_FOUND", e);
+        }
+        catch (Exception e)
+        {
+            throw new RecoverableException("FAILED_TO_OPEN_FILE", e);
+        }
+        
+        List<KeyDefinition> keyDefinitions = new List<KeyDefinition>(); // DefaultShortcut is actually mapped shortcut.
+
+        LoadCommands(doc, keyDefinitions, applyDefaults);
+        LoadTools(doc, keyDefinitions, applyDefaults);
+
+        try
+        {
+            return ShortcutsTemplate.FromKeyDefinitions(keyDefinitions);
+        }
+        catch (RecoverableException) { throw; }
+        catch (Exception e)
+        {
+            throw new CorruptedFileException("FILE_HAS_INVALID_SHORTCUT", e);
+        }
+    }
+
+    private void LoadCommands(XmlDocument document, List<KeyDefinition> keyDefinitions, bool applyDefaults)
+    {
+        XmlNodeList commands = document.SelectNodes("keyboard/commands/key");
+        if (applyDefaults)
+        {
+            ApplyDefaults(keyDefinitions, "PixiEditor");
+        }
+
+        foreach (XmlNode commandNode in commands)
+        {
+            if(commandNode.Attributes == null) continue;
+            
+            XmlAttribute command = commandNode.Attributes["command"];
+            XmlAttribute shortcut = commandNode.Attributes["shortcut"];
+            XmlNodeList paramNodes = commandNode.SelectNodes("param");
+
+            if(command == null || shortcut == null) continue;
+
+            string commandName = $"{command.Value}{GetParamString(paramNodes)}";
+            string shortcutValue = shortcut.Value;
+            
+            if (!Map.ContainsKey(commandName))
+            {
+                continue;
+            }
+
+            var mappedEntry = Map[commandName];
+            commandName = mappedEntry.Command;
+            
+            HumanReadableKeyCombination combination;
+            
+            XmlAttribute removed = commandNode.Attributes["removed"];
+            if (removed is { Value: "true" })
+            {
+                combination = new HumanReadableKeyCombination("None");
+            }
+            else
+            {
+                combination = HumanReadableKeyCombination.FromStringCombination(shortcutValue);
+            }
+
+            // We should override existing entry, because aseprite-keys file can contain multiple entries for the same command.
+            // Last one is the one that should be used.
+            keyDefinitions.RemoveAll(x => x.Command == commandName);
+            
+            keyDefinitions.Add(new KeyDefinition(commandName, combination));
+        }
+    }
+
+    private string GetParamString(XmlNodeList paramNodes)
+    {
+        if(paramNodes == null || paramNodes.Count == 0) return string.Empty;
+        
+        StringBuilder builder = new StringBuilder();
+        foreach (XmlNode paramNode in paramNodes)
+        {
+            if(paramNode.Attributes == null) continue;
+            
+            XmlAttribute paramName = paramNode.Attributes["name"];
+            XmlAttribute paramValue = paramNode.Attributes["value"];
+            if(paramName == null || paramValue == null) continue;
+
+            builder.Append('.');
+            builder.Append(paramName.Value);
+            builder.Append('=');
+            builder.Append(paramValue.Value);
+        }
+
+        return builder.ToString();
+    }
+
+    /// <summary>
+    ///     Tools are stored in keyboard > tools section of aseprite-keys file.
+    /// Each tool entry is a key XML node with command attribute under 'tool' parameter and shortcut under 'shortcut' parameter.
+    /// </summary>
+    /// <param name="keyDefinitions">Definitions to write to</param>
+    private void LoadTools(XmlDocument document, List<KeyDefinition> keyDefinitions, bool applyDefaults)
+    {
+        XmlNodeList tools = document.SelectNodes("keyboard/tools/key");
+        if (applyDefaults)
+        {
+            ApplyDefaults(keyDefinitions, "PixiEditor.Tools");
+        }
+
+        foreach (XmlNode tool in tools)
+        {
+            if(tool.Attributes == null) continue;
+            
+            XmlAttribute command = tool.Attributes["tool"];
+            XmlAttribute shortcut = tool.Attributes["shortcut"];
+
+            if(command == null || shortcut == null) continue;
+
+            string commandName = command.Value;
+            string shortcutValue = shortcut.Value;
+
+            if (!Map.ContainsKey(commandName))
+            {
+                continue;
+            }
+
+            var mappedEntry = Map[commandName];
+            commandName = mappedEntry.Command;
+
+            HumanReadableKeyCombination combination;
+            
+            XmlAttribute removed = tool.Attributes["removed"];
+            if (removed is { Value: "true" })
+            {
+                combination = new HumanReadableKeyCombination("None");
+            }
+            else
+            {
+                combination = HumanReadableKeyCombination.FromStringCombination(shortcutValue);
+            }
+
+            // We should override existing entry, because aseprite-keys file can contain multiple entries for the same tool.
+            // Last one is the one that should be used.
+            keyDefinitions.RemoveAll(x => x.Command == commandName);
+            
+            keyDefinitions.Add(new KeyDefinition(commandName, combination));
+        }
+    }
+
+    private void ApplyDefaults(List<KeyDefinition> keyDefinitions, string commandGroup)
+    {
+        foreach (var mapEntry in Map)
+        {
+            if (mapEntry.Value.Command.StartsWith(commandGroup))
+            {
+                keyDefinitions.Add(mapEntry.Value);
+            }
+        }
+    }
+}

+ 85 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/KeyDefinition.cs

@@ -0,0 +1,85 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands.Templates.Parsers;
+
+[Serializable]
+public class KeyDefinition
+{
+    public string Command { get; set; }
+    public HumanReadableKeyCombination DefaultShortcut { get; set; }
+    public string[] Parameters { get; set; }
+    
+    public KeyDefinition() { }
+    public KeyDefinition(string command, HumanReadableKeyCombination defaultShortcut, params string[] parameters)
+    {
+        Command = command;
+        DefaultShortcut = defaultShortcut;
+        Parameters = parameters;
+    }
+}
+
+public record HumanReadableKeyCombination(string key, string[] modifiers = null)
+{
+    public KeyCombination ToKeyCombination()
+    {
+        Key parsedKey = Key.None;
+        KeyModifiers parsedModifiers = KeyModifiers.None;
+
+        if (KeyParser.TryParseSpecial(key, out parsedKey) || Enum.TryParse(key, true, out parsedKey))
+        {
+            parsedModifiers = ParseModifiers(modifiers);
+        }
+        else
+        {
+            throw new ArgumentException($"Invalid key: {key}");
+        }
+        
+        return new KeyCombination(parsedKey, parsedModifiers);
+    }
+
+    private KeyModifiers ParseModifiers(string[] strings)
+    {
+        if(strings == null || strings.Length == 0)
+        {
+            return KeyModifiers.None;
+        }
+        
+        KeyModifiers modifiers = KeyModifiers.None;
+
+        for (int i = 0; i < strings.Length; i++)
+        {
+            switch (strings[i].ToLower())
+            {
+                case "ctrl": 
+                    modifiers |= KeyModifiers.Control;
+                    break;
+                case "alt":
+                    modifiers |= KeyModifiers.Alt;
+                    break;
+                case "shift":
+                    modifiers |= KeyModifiers.Shift;
+                    break;
+                case "win":
+                    modifiers |= KeyModifiers.Meta;
+                    break;
+            }
+        }
+        
+        return modifiers;
+    }
+
+    public static HumanReadableKeyCombination FromStringCombination(string shortcutCombination)
+    {
+        if (!shortcutCombination.Contains('+'))
+        {
+            return new HumanReadableKeyCombination(shortcutCombination, null);
+        }
+        
+        string[] split = shortcutCombination.Split('+');
+        string key = split[^1];
+        string[] modifiers = split[..^1];
+        return new HumanReadableKeyCombination(key, modifiers);
+    }
+}

+ 110 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/KeyParser.cs

@@ -0,0 +1,110 @@
+using System.Windows.Input;
+using Avalonia.Input;
+
+namespace PixiEditor.Models.Commands.Templates.Parsers;
+
+public static class KeyParser
+{
+    public static bool TryParseSpecial(string key, out Key parsed)
+    {
+        switch (key.ToLower())
+        {
+            case "shift":
+                parsed = Key.LeftShift;
+                return true;
+            case "ctrl":
+                parsed = Key.LeftCtrl;
+                return true;
+            case "alt":
+                parsed = Key.LeftAlt;
+                return true;
+            case "win":
+                parsed = Key.LWin;
+                return true;
+            case ".":
+                parsed = Key.OemPeriod;
+                return true;
+            case ",":
+                parsed = Key.OemComma;
+                return true;
+            case "+":
+                parsed = Key.OemPlus;
+                return true;
+            case "-":
+                parsed = Key.OemMinus;
+                return true;
+            case "/":
+                parsed = Key.OemQuestion;
+                return true;
+            case "*":
+                parsed = Key.Multiply;
+                return true;
+            case "\\":
+                parsed = Key.Oem5;
+                return true;
+            case "'":
+                parsed = Key.OemQuotes;
+                return true;
+            case "\"":
+                parsed = Key.Oem7;
+                return true;
+            case ";":
+                parsed = Key.OemSemicolon;
+                return true;
+            case ":":
+                parsed = Key.Oem1;
+                return true;
+            case "<":
+                parsed = Key.Oem102;
+                return true;
+            case ">":
+                parsed = Key.Oem102;
+                return true;
+            case "~":
+                parsed = Key.Oem3;
+                return true;
+            case "!":
+                parsed = Key.D1;
+                return true;
+            case "@":
+                parsed = Key.D2;
+                return true;
+            case "#":
+                parsed = Key.D3;
+                return true;
+            case "$":
+                parsed = Key.D4;
+                return true;
+            case "%":
+                parsed = Key.D5;
+                return true;
+            case "^":
+                parsed = Key.D6;
+                return true;
+            case "&":
+                parsed = Key.D7;
+                return true;
+            case "[" or "{":
+                parsed = Key.OemOpenBrackets;
+                return true;
+            case "]" or "}":
+                parsed = Key.OemCloseBrackets;
+                return true;
+            case "_":
+                parsed = Key.OemMinus;
+                return true;
+            case "=":
+                parsed = Key.OemPlus;
+                return true;
+            case "(":
+                parsed = Key.D9;
+                return true;
+            case ")":
+                parsed = Key.D0;
+                return true;
+            default:
+                parsed = Key.None;
+                return false;
+        }
+    }
+}

+ 61 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/Providers/Parsers/KeysParser.cs

@@ -0,0 +1,61 @@
+using System.Collections.Generic;
+using System.IO;
+using Newtonsoft.Json;
+using PixiEditor.Models.IO;
+
+namespace PixiEditor.Models.Commands.Templates.Parsers;
+
+public abstract class KeysParser
+{
+    public string MapFileName { get; }
+
+    private static string _fullMapFilePath;
+
+    public Dictionary<string, KeyDefinition> Map => _cachedMap ??= LoadKeysMap();
+    private Dictionary<string, KeyDefinition> _cachedMap;
+    
+    public List<Shortcut> Defaults => _cachedDefaults ??= ParseDefaults();
+    private List<Shortcut> _cachedDefaults;
+    
+    public KeysParser(string mapFileName)
+    {
+        _fullMapFilePath = Path.Combine(Paths.DataFullPath, "ShortcutActionMaps", mapFileName);
+        if (!File.Exists(_fullMapFilePath))
+        {
+            throw new FileNotFoundException($"Keys map file '{_fullMapFilePath}' not found.");
+        }
+        
+        MapFileName = mapFileName;
+    }
+    
+    /// <summary>
+    ///     Parses custom shortcuts file into ShortcutTemplate.
+    /// </summary>
+    /// <param name="filePath">Path to the file.</param>
+    /// <param name="applyTemplateDefaults">If true, all shortcuts available in the key map will be loaded, and then overwritten by entries in the file. If false, only entries from the file will be applied.</param>
+    /// <returns>Parsed ShortcutTemplate.</returns>
+    public abstract ShortcutsTemplate Parse(string filePath, bool applyTemplateDefaults);
+    
+    private Dictionary<string, KeyDefinition> LoadKeysMap()
+    {
+        string text = File.ReadAllText(_fullMapFilePath);
+        var dict = JsonConvert.DeserializeObject<Dictionary<string, KeyDefinition>>(text);
+        if(dict == null) throw new Exception("Keys map file is empty.");
+        if(dict.ContainsKey("")) dict.Remove("");
+        return dict;
+    }
+    
+    private List<Shortcut> ParseDefaults()
+    {
+        var defaults = new List<Shortcut>();
+        foreach (var (key, value) in Map)
+        {
+            if (value.DefaultShortcut != null)
+            {
+                defaults.Add(new Shortcut(value.DefaultShortcut.ToKeyCombination(), Map[key].Command));
+            }
+        }
+        
+        return defaults;
+    }
+}

+ 27 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/ShortcutCollection.cs

@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+internal class ShortcutCollection : List<Shortcut>
+{
+    public ShortcutCollection() { }
+
+    public ShortcutCollection(List<Shortcut> enumerable) : base(enumerable)
+    { }
+
+    public void Add(string commandName, Key key, KeyModifiers modifiers)
+    {
+        Add(new Shortcut(new KeyCombination(key, modifiers), new List<string>() { commandName }));
+    }
+
+    /// <summary>
+    /// Unassigns a shortcut.
+    /// </summary>
+    public void Add(string commandName)
+    {
+        Add(new Shortcut(KeyCombination.None, new List<string> { commandName }));
+    }
+}

+ 41 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Templates/ShortcutProvider.cs

@@ -0,0 +1,41 @@
+namespace PixiEditor.Models.Commands.Templates;
+
+internal partial class ShortcutProvider
+{
+    public string Name { get; set; }
+    
+    public string LogoPath { get; set; }
+    public string HoverLogoPath { get; set; }
+
+    /// <summary>
+    /// Set this to true if this provider has default shortcuts
+    /// </summary>
+    public bool HasDefaultShortcuts => this is IShortcutDefaults;
+
+    /// <summary>
+    /// Set this to true if this provider can provide from a file
+    /// </summary>
+    public bool ProvidesImport => this is IShortcutFile;
+
+    /// <summary>
+    /// Set this to true if this provider can provide from installation
+    /// </summary>
+    public bool ProvidesFromInstallation => this is IShortcutInstallation;
+
+    public bool HasInstallationPresent => (this as IShortcutInstallation)?.InstallationPresent ?? false;
+
+    public virtual string Description { get; } = string.Empty;
+
+    public ShortcutProvider(string name)
+    {
+        Name = name;
+    }
+
+    public static ShortcutProvider[] GetProviders() => new ShortcutProvider[]
+    {
+        #if DEBUG
+        Debug,
+        #endif
+        Aseprite
+    };
+}

+ 81 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/Command.cs

@@ -0,0 +1,81 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Helpers;
+using ReactiveUI;
+
+namespace PixiEditor.Models.Commands.XAML;
+
+internal class Command : MarkupExtension
+{
+    private static CommandController commandController;
+
+    public string Name { get; set; }
+
+    public bool UseProvided { get; set; }
+
+    public bool GetPixiCommand { get; set; }
+
+    public Command() { }
+
+    public Command(string name) => Name = name;
+
+    public override object ProvideValue(IServiceProvider serviceProvider)
+    {
+        if (Design.IsDesignMode)
+        {
+            var attribute = DesignCommandHelpers.GetCommandAttribute(Name);
+            return GetICommand(
+                new Commands.Command.BasicCommand(null, null)
+                {
+                    InternalName = Name,
+                    DisplayName = attribute.DisplayName,
+                    Description = attribute.Description,
+                    DefaultShortcut = attribute.GetShortcut(),
+                    Shortcut = attribute.GetShortcut()
+                }, false);
+        }
+
+        if (commandController is null)
+        {
+            commandController = serviceProvider.GetRequiredService<CommandController>();
+        }
+
+        var command = commandController.Commands[Name];
+        return GetPixiCommand ? command : GetICommand(command, UseProvided);
+    }
+
+    public static IReactiveCommand GetICommand(Commands.Command command, bool useProvidedParameter) => new ProvidedICommand()
+    {
+        Command = command,
+        UseProvidedParameter = useProvidedParameter,
+    };
+
+    class ProvidedICommand : IReactiveCommand
+    {
+        // TODO: Implement with ReactiveUI
+        /*public event EventHandler CanExecuteChanged
+        {
+            add => CommandManager.RequerySuggested += value;
+            remove => CommandManager.RequerySuggested -= value;
+        }
+
+        public Commands.Command Command { get; init; }
+
+        public bool UseProvidedParameter { get; init; }
+
+        public bool CanExecute(object parameter) => UseProvidedParameter ? Command.Methods.CanExecute(parameter) : Command.CanExecute();
+
+        public void Execute(object parameter)
+        {
+            if (UseProvidedParameter)
+            {
+                Command.Methods.Execute(parameter);
+            }
+            else
+            {
+                Command.Execute();
+            }
+        }*/
+    }
+}

+ 47 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/ContextMenu.cs

@@ -0,0 +1,47 @@
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using PixiEditor.Helpers;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands.XAML;
+
+internal class ContextMenu : ContextMenu
+{
+    public static readonly DependencyProperty CommandNameProperty =
+        DependencyProperty.RegisterAttached(
+            "Command",
+            typeof(string),
+            typeof(ContextMenu),
+            new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, CommandChanged)
+        );
+
+    public static string GetCommand(UIElement target) => (string)target.GetValue(CommandNameProperty);
+
+    public static void SetCommand(UIElement target, string value) => target.SetValue(CommandNameProperty, value);
+
+    public static void CommandChanged(object sender, DependencyPropertyChangedEventArgs e)
+    {
+        if (e.NewValue is not string value || sender is not MenuItem item)
+        {
+            throw new InvalidOperationException($"{nameof(ContextMenu)}.Command only works for MenuItem's");
+        }
+
+        if (DesignerProperties.GetIsInDesignMode(sender as DependencyObject))
+        {
+            HandleDesignMode(item, value);
+            return;
+        }
+
+        var command = CommandController.Current.Commands[value];
+
+        item.Command = Command.GetICommand(command, false);
+        item.SetBinding(MenuItem.InputGestureTextProperty, ShortcutBinding.GetBinding(command, null));
+    }
+
+    private static void HandleDesignMode(MenuItem item, string name)
+    {
+        var command = DesignCommandHelpers.GetCommandAttribute(name);
+        item.InputGestureText = new KeyCombination(command.Key, command.Modifiers).ToString();
+    }
+}

+ 66 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/Menu.cs

@@ -0,0 +1,66 @@
+using Avalonia;
+using Avalonia.Controls;
+using PixiEditor.Helpers;
+using PixiEditor.Models.DataHolders;
+using ReactiveUI;
+
+namespace PixiEditor.Models.Commands.XAML;
+
+internal class Menu : global::Avalonia.Controls.Menu
+{
+    public static readonly DirectProperty<Menu, string> CommandNameProperty;
+
+    static Menu()
+    {
+        CommandNameProperty = AvaloniaProperty.RegisterDirect<Menu, string>(
+            nameof(Command),
+            GetCommand,
+            SetCommand);
+        CommandNameProperty.Changed.Subscribe(CommandChanged);
+    }
+
+    public const double IconDimensions = 21;
+    
+    public static string GetCommand(Menu menu) => (string)menu.GetValue(CommandNameProperty);
+
+    public static void SetCommand(Menu menu, string value) => menu.SetValue(CommandNameProperty, value);
+
+    public static void CommandChanged(AvaloniaPropertyChangedEventArgs e)
+    {
+        if (e.NewValue is not string value || e.Sender is not MenuItem item)
+        {
+            throw new InvalidOperationException($"{nameof(Menu)}.Command only works for MenuItem's");
+        }
+
+        if (Design.IsDesignMode)
+        {
+            HandleDesignMode(item, value);
+            return;
+        }
+
+        var command = CommandController.Current.Commands[value];
+
+        var icon = new Image
+        { 
+            Source = command.GetIcon(), 
+            Width = IconDimensions, Height = IconDimensions,
+            Opacity = command.CanExecute() ? 1 : 0.75
+        };
+
+        icon.IsVisible.WhenAnyValue(v => v).Subscribe(newValue =>
+        {
+            icon.Opacity = command.CanExecute() ? 1 : 0.75;
+
+        });
+
+        item.Command = Command.GetICommand(command, false);
+        item.Icon = icon;
+        item.Bind(MenuItem.InputGestureProperty, ShortcutBinding.GetBinding(command, null));
+    }
+
+    private static void HandleDesignMode(MenuItem item, string name)
+    {
+        var command = DesignCommandHelpers.GetCommandAttribute(name);
+        item.InputGesture = new KeyCombination(command.Key, command.Modifiers).ToKeyGesture();
+    }
+}

+ 42 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/XAML/ShortcutBinding.cs

@@ -0,0 +1,42 @@
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Markup.Xaml;
+using PixiEditor.Helpers;
+using PixiEditor.Models.DataHolders;
+using ActualCommand = PixiEditor.Models.Commands.Commands.Command;
+
+namespace PixiEditor.Models.Commands.XAML;
+
+internal class ShortcutBinding : MarkupExtension
+{
+    private static CommandController commandController;
+
+    public string Name { get; set; }
+
+    public IValueConverter Converter { get; set; }
+    
+    public ShortcutBinding() { }
+
+    public ShortcutBinding(string name) => Name = name;
+
+    public override object ProvideValue(IServiceProvider serviceProvider)
+    {
+        if (ViewModelMain.Current == null)
+        {
+            var attribute = DesignCommandHelpers.GetCommandAttribute(Name);
+            return new KeyCombination(attribute.Key, attribute.Modifiers).ToString();
+        }
+
+        commandController ??= ViewModelMain.Current.CommandController;
+        return GetBinding(commandController.Commands[Name], Converter).ProvideValue(serviceProvider);
+    }
+
+    public static Binding GetBinding(ActualCommand command, IValueConverter converter) => new Binding
+    {
+        Source = command,
+        Path = new("Shortcut"),
+        Mode = BindingMode.OneWay,
+        StringFormat = "",
+        Converter = converter
+    };
+}

+ 6 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Descriptors/ISearchHandler.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.Containers;
+
+public interface ISearchHandler
+{
+    public void OpenSearchWindow(string searchQuery);
+}

+ 44 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Descriptors/IToolHandler.cs

@@ -0,0 +1,44 @@
+using Avalonia.Input;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.Models.Containers;
+
+public interface IToolHandler
+{
+    public bool IsTransient { get; set; }
+    public LocalizedString DisplayName => new LocalizedString(ToolNameLocalizationKey);
+    public string ToolName => GetType().Name.Replace("Tool", string.Empty).Replace("ViewModel", string.Empty);
+    public string ToolNameLocalizationKey { get; }
+    public string ImagePath => $"/Images/Tools/{ToolName}Image.png";
+    //public virtual BrushShape BrushShape => BrushShape.Square;
+
+    public bool HideHighlight { get; }
+
+    public abstract LocalizedString Tooltip { get; }
+
+    /// <summary>
+    /// Determines if secondary color should be used if right click mode is set to secondary color
+    /// </summary>
+    public virtual bool UsesColor => false;
+
+    /// <summary>
+    /// Determines if PixiEditor should switch to the Eraser when right click mode is set to erase
+    /// </summary>
+    public virtual bool IsErasable => false;
+
+    /// <summary>
+    /// The mouse button that is being used with the tool
+    /// </summary>
+    public MouseButton UsedWith { get; set; }
+    public LocalizedString ActionDisplay { get; set; }
+    public bool IsActive { get; set; }
+    public Cursor Cursor { get; set; }
+    //public Toolbar Toolbar { get; set; }
+
+    public void ModifierKeyChanged(bool ctrlIsDown, bool shiftIsDown, bool altIsDown);
+    public void UseTool(VecD pos);
+    public void OnSelected();
+
+    public void OnDeselecting();
+}

+ 6 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Descriptors/IToolsHandler.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.Containers;
+
+public interface IToolsHandler
+{
+    public void SetTool(object parameter);
+}

+ 24 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Dialogs/NoticeDialog.cs

@@ -0,0 +1,24 @@
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Localization;
+using PixiEditor.Views.Dialogs;
+
+namespace PixiEditor.Models.Dialogs;
+
+internal static class NoticeDialog
+{
+    /// <summary>
+    ///     Shows a notice dialog with specified message and title.
+    /// </summary>
+    /// <param name="message">Localized string key for message.</param>
+    /// <param name="title">Localized string key for title.</param>
+    public static void Show(LocalizedString message, LocalizedString title)
+    {
+        NoticePopup popup = new()
+        {
+            Body = message,
+            Title = title
+        };
+
+        popup.ShowDialog();
+    }
+}

+ 53 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Input/KeyCombination.cs

@@ -0,0 +1,53 @@
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using System.Diagnostics;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Windows.Input;
+using Avalonia.Input;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Extensions.Helpers;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.Models.DataHolders;
+
+[DebuggerDisplay("{GetDebuggerDisplay(),nq}")]
+public record struct KeyCombination(Key Key, KeyModifiers Modifiers)
+{
+    public static KeyCombination None => new(Key.None, KeyModifiers.None);
+
+    public override string ToString() => ToString(false);
+
+    public KeyGesture ToKeyGesture() => new(Key, Modifiers);
+
+    private string ToString(bool forceInvariant)
+    {
+        StringBuilder builder = new();
+
+        foreach (var modifier in Modifiers.GetFlags().OrderByDescending(x => x != KeyModifiers.Alt))
+        {
+            if (modifier == KeyModifiers.None) continue;
+
+            string key = modifier switch
+            {
+                KeyModifiers.Control => new LocalizedString("CTRL_KEY"),
+                KeyModifiers.Shift => new LocalizedString("SHIFT_KEY"),
+                KeyModifiers.Alt => new LocalizedString("ALT_KEY"),
+                _ => modifier.ToString()
+            };
+
+            builder.Append($"{key}+");
+        }
+
+        if (Key != Key.None)
+        {
+            builder.Append(InputKeyHelpers.GetKeyboardKey(Key, forceInvariant));
+        }
+
+        builder.Append('‎'); // left-to-right marker ensures WPF does not reverse the string when using punctuations as key
+        return builder.ToString();
+    }
+
+    private string GetDebuggerDisplay() => ToString(true);
+}

+ 159 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Structures/OneToManyDictionary.cs

@@ -0,0 +1,159 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace PixiEditor.Models.DataHolders;
+
+[DebuggerDisplay("Count = {Count}")]
+internal class OneToManyDictionary<TKey, T> : ICollection<KeyValuePair<TKey, IEnumerable<T>>>
+{
+    private readonly Dictionary<TKey, List<T>> _dictionary;
+
+    public OneToManyDictionary()
+    {
+        _dictionary = new Dictionary<TKey, List<T>>();
+    }
+
+    public OneToManyDictionary(IEnumerable<KeyValuePair<TKey, IEnumerable<T>>> enumerable)
+    {
+        _dictionary = new Dictionary<TKey, List<T>>(enumerable
+            .Select(x => new KeyValuePair<TKey, List<T>>(x.Key, x.Value.ToList())));
+    }
+
+    public int Count => _dictionary.Count;
+
+    public bool IsReadOnly => false;
+
+    [NotNull]
+    public List<T> this[TKey key]
+    {
+        get
+        {
+            if (_dictionary.TryGetValue(key, out List<T> values))
+            {
+                return values;
+            }
+
+            List<T> newList = new();
+            _dictionary.Add(key, newList);
+
+            return newList;
+        }
+    }
+
+    public void Add(TKey key, T value)
+    {
+        if (_dictionary.TryGetValue(key, out List<T> values))
+        {
+            values.Add(value);
+            return;
+        }
+
+        _dictionary.Add(key, new() { value });
+    }
+
+    public void AddRange(TKey key, IEnumerable<T> enumerable)
+    {
+        if (_dictionary.TryGetValue(key, out List<T> values))
+        {
+            foreach (T value in enumerable)
+            {
+                values.Add(value);
+            }
+
+            return;
+        }
+
+        _dictionary.Add(key, new(enumerable));
+    }
+
+    public void Add(KeyValuePair<TKey, IEnumerable<T>> item) => AddRange(item.Key, item.Value);
+
+    public void Clear() => _dictionary.Clear();
+
+    public void Clear(TKey key)
+    {
+        if (_dictionary.TryGetValue(key, out List<T> value))
+        {
+            value.Clear();
+        }
+    }
+
+    public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key);
+
+    public bool Contains(KeyValuePair<TKey, IEnumerable<T>> item)
+    {
+        if (_dictionary.TryGetValue(item.Key, out List<T> values))
+        {
+            return item.Value.All(x => values.Contains(x));
+        }
+
+        return false;
+    }
+
+    public void CopyTo(KeyValuePair<TKey, IEnumerable<T>>[] array, int arrayIndex)
+    {
+        using var enumerator = GetEnumerator();
+
+        for (int i = arrayIndex; i < array.Length; i++)
+        {
+            if (!enumerator.MoveNext())
+            {
+                break;
+            }
+
+            array[i] = enumerator.Current;
+        }
+    }
+
+    public IEnumerator<KeyValuePair<TKey, IEnumerable<T>>> GetEnumerator()
+    {
+        foreach (var pair in _dictionary)
+        {
+            yield return new(pair.Key, pair.Value);
+        }
+    }
+
+    public bool Remove(KeyValuePair<TKey, IEnumerable<T>> item)
+    {
+        if (!_dictionary.TryGetValue(item.Key, out List<T> values))
+            return false;
+
+        bool success = true;
+        foreach (var enumerableItem in item.Value)
+        {
+            success &= values.Remove(enumerableItem);
+        }
+
+        return success;
+    }
+
+    /// <summary>
+    /// Removes <paramref name="item"/> from all enumerables in the dictionary.
+    /// Returns true if any entry was removed
+    /// </summary>
+    public bool Remove(T item)
+    {
+        bool anyRemoved = false;
+
+        foreach (var enumItem in _dictionary)
+        {
+            anyRemoved |= enumItem.Value.Remove(item);
+        }
+
+        return anyRemoved;
+    }
+
+    public bool Remove(TKey key, T item)
+    {
+        if (!_dictionary.ContainsKey(key))
+            return false;
+        return _dictionary[key].Remove(item);
+    }
+
+    public bool Remove(TKey key) => _dictionary.Remove(key);
+
+    IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator();
+}

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

@@ -47,4 +47,12 @@
     <ItemGroup>
       <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.2.0" />
     </ItemGroup>
+  
+    <ItemGroup>
+      <AvaloniaXaml Include="Views\Dialogs\NoticePopup.axaml">
+        <Generator>MSBuild:Compile</Generator>
+        <XamlRuntime>Wpf</XamlRuntime>
+        <SubType>Designer</SubType>
+      </AvaloniaXaml>
+    </ItemGroup>
 </Project>

+ 15 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/AdditionalContent/AdditionalContentViewModel.cs

@@ -0,0 +1,15 @@
+using PixiEditor.Avalonia.ViewModels;
+using PixiEditor.Platform;
+
+namespace PixiEditor.ViewModels.SubViewModels.AdditionalContent;
+
+internal class AdditionalContentViewModel : ViewModelBase
+{
+    public IAdditionalContentProvider AdditionalContentProvider { get; }
+    public AdditionalContentViewModel(IAdditionalContentProvider additionalContentProvider)
+    {
+        AdditionalContentProvider = additionalContentProvider;
+    }
+
+    public bool IsSupporterPackAvailable => AdditionalContentProvider != null && AdditionalContentProvider.IsContentInstalled(AdditionalContentProduct.SupporterPack);
+}

+ 14 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SubViewModels/SubViewModel.cs

@@ -0,0 +1,14 @@
+using PixiEditor.Avalonia.ViewModels;
+
+namespace PixiEditor.ViewModels.SubViewModels;
+
+internal class SubViewModel<T> : ViewModelBase
+    where T : ViewModelBase
+{
+    public T Owner { get; protected set; }
+
+    public SubViewModel(T owner)
+    {
+        Owner = owner;
+    }
+}

+ 42 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Dialogs/NoticePopup.axaml

@@ -0,0 +1,42 @@
+<controls:Window x:Class="PixiEditor.Views.Dialogs.NoticePopup"
+        x:ClassModifier="internal"
+        xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
+        xmlns:dial="clr-namespace:PixiEditor.Views.Dialogs"
+        xmlns:views="clr-namespace:PixiEditor.Views"
+        xmlns:helpers="clr-namespace:PixiEditor.Helpers"
+        xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
+        xmlns:controls="https://github.com/avaloniaui"
+        mc:Ignorable="d"
+        d:Title="Notice" Height="180" Width="400" MinHeight="180" MinWidth="400"
+        WindowStartupLocation="CenterScreen"
+        x:Name="popup"
+        ui:Translator.Key="{Binding ElementName=popup, Path=Title}"
+        FlowDirection="{helpers:Localization FlowDirection}">
+
+    <!--<WindowChrome.WindowChrome>
+        <WindowChrome CaptionHeight="32"  GlassFrameThickness="0.1"
+                      ResizeBorderThickness="{x:Static SystemParameters.WindowResizeBorderThickness}" />
+    </WindowChrome.WindowChrome>-->
+
+    <DockPanel Background="{StaticResource AccentColor}" Focusable="True">
+        <Interaction.Behaviors>
+            <!--<behaviours:ClearFocusOnClickBehavior/>-->
+        </Interaction.Behaviors>
+
+        <!--<dial:DialogTitleBar DockPanel.Dock="Top"
+            TitleKey="{Binding ElementName=popup, Path=Title}" CloseCommand="{Binding DataContext.CancelCommand, ElementName=popup}" />-->
+
+        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,15">
+            <Button Width="70" IsDefault="True" Click="OkButton_Close" ui:Translator.Key="CLOSE"/>
+        </StackPanel>
+
+        <TextBlock 
+            Grid.Row="1" Text="{Binding Body, ElementName=popup}" TextAlignment="Center"
+            VerticalAlignment="Center" FontSize="15" Foreground="White" Margin="20,0" d:Text="The file does not exist"
+            TextWrapping="WrapWithOverflow" TextTrimming="WordEllipsis" />
+    </DockPanel>
+</controls:Window>

+ 37 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Dialogs/NoticePopup.axaml.cs

@@ -0,0 +1,37 @@
+using System.Windows;
+using Avalonia;
+using Avalonia.Interactivity;
+
+namespace PixiEditor.Views.Dialogs;
+
+/// <summary>
+/// Interaction logic for NoticePopup.xaml.
+/// </summary>
+internal partial class NoticePopup : Window
+{
+    public static readonly StyledProperty<string> BodyProperty =
+        AvaloniaProperty.Register<NoticePopup, string>(nameof(Body));
+
+    public new string Title
+    {
+        get => base.Title;
+        set => base.Title = value;
+    }
+
+    public string Body
+    {
+        get => (string)GetValue(BodyProperty);
+        set => SetValue(BodyProperty, value);
+    }
+
+    public NoticePopup()
+    {
+        InitializeComponent();
+    }
+
+
+    private void OkButton_Close(object? sender, RoutedEventArgs e)
+    {
+        Close();
+    }
+}

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

@@ -0,0 +1,6 @@
+namespace PixiEditor.OperatingSystem;
+
+public interface IOperatingSystem
+{
+    public string Name { get; }
+}

+ 9 - 0
src/PixiEditor.OperatingSystem/PixiEditor.OperatingSystem.csproj

@@ -0,0 +1,9 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net7.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+</Project>

+ 13 - 0
src/PixiEditor.Windows/PixiEditor.Windows.csproj

@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net7.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\PixiEditor.OperatingSystem\PixiEditor.OperatingSystem.csproj" />
+    </ItemGroup>
+
+</Project>

+ 8 - 0
src/PixiEditor.Windows/WindowsOperatingSystem.cs

@@ -0,0 +1,8 @@
+using PixiEditor.OperatingSystem;
+
+namespace PixiEditor.Windows;
+
+public class WindowsOperatingSystem : IOperatingSystem
+{
+    public string Name => "Windows";
+}

+ 92 - 0
src/PixiEditor.sln

@@ -72,6 +72,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Avalonia.Desktop
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Avalonia.Browser", "PixiEditor.Avalonia\PixiEditor.Avalonia.Browser\PixiEditor.Avalonia.Browser.csproj", "{6B71BBFF-27D2-4A65-9357-1376356B5ECC}"
 EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OperatingSystems", "OperatingSystems", "{2CC7ED59-C25E-4EED-8FED-D48E13EB9CC0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.OperatingSystem", "PixiEditor.OperatingSystem\PixiEditor.OperatingSystem.csproj", "{16519035-0FF4-456F-B3F0-0ACA16E6920C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Windows", "PixiEditor.Windows\PixiEditor.Windows.csproj", "{3DF64622-87E3-4870-B694-05D565251BB9}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -1177,6 +1183,90 @@ Global
 		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevSteam|x64.Build.0 = DevSteam|x64
 		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevSteam|Any CPU.ActiveCfg = DevSteam|Any CPU
 		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevSteam|Any CPU.Build.0 = DevSteam|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Debug|x64.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Debug|x86.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevRelease|Any CPU.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevRelease|Any CPU.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevRelease|x64.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevRelease|x64.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevRelease|x86.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevRelease|x86.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX Debug|Any CPU.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX Debug|x86.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX Debug|x86.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX|Any CPU.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX|Any CPU.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX|x64.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX|x86.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.MSIX|x86.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Release|x64.ActiveCfg = Release|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Release|x64.Build.0 = Release|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Release|x86.ActiveCfg = Release|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Release|x86.Build.0 = Release|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Steam|Any CPU.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Steam|Any CPU.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Steam|x64.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Steam|x64.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Steam|x86.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.Steam|x86.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevSteam|x86.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevSteam|x86.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevSteam|x64.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevSteam|x64.Build.0 = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevSteam|Any CPU.ActiveCfg = Debug|Any CPU
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C}.DevSteam|Any CPU.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Debug|x64.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Debug|x86.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevRelease|Any CPU.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevRelease|Any CPU.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevRelease|x64.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevRelease|x64.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevRelease|x86.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevRelease|x86.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX Debug|Any CPU.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX Debug|x86.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX Debug|x86.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX|Any CPU.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX|Any CPU.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX|x64.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX|x86.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.MSIX|x86.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Release|Any CPU.Build.0 = Release|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Release|x64.ActiveCfg = Release|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Release|x64.Build.0 = Release|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Release|x86.ActiveCfg = Release|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Release|x86.Build.0 = Release|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Steam|Any CPU.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Steam|Any CPU.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Steam|x64.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Steam|x64.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Steam|x86.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.Steam|x86.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevSteam|x86.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevSteam|x86.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevSteam|x64.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevSteam|x64.Build.0 = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevSteam|Any CPU.ActiveCfg = Debug|Any CPU
+		{3DF64622-87E3-4870-B694-05D565251BB9}.DevSteam|Any CPU.Build.0 = Debug|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -1211,5 +1301,7 @@ Global
 		{27B4583C-539B-4D75-AF2C-71CAB1B4A153} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
 		{20406C86-4833-4E8B-8880-8674984AC03C} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
 		{6B71BBFF-27D2-4A65-9357-1376356B5ECC} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
+		{16519035-0FF4-456F-B3F0-0ACA16E6920C} = {2CC7ED59-C25E-4EED-8FED-D48E13EB9CC0}
+		{3DF64622-87E3-4870-B694-05D565251BB9} = {2CC7ED59-C25E-4EED-8FED-D48E13EB9CC0}
 	EndGlobalSection
 EndGlobal

+ 1 - 0
src/PixiEditor/ViewModels/CrashReportViewModel.cs

@@ -3,6 +3,7 @@ using System.IO;
 using System.Net.Http;
 using System.Text;
 using System.Windows;
+using System.Windows.Media;
 using PixiEditor.Helpers;
 using PixiEditor.Models.DataHolders;
 using PixiEditor.Views;

+ 14 - 14
src/PixiEditor/ViewModels/SubViewModels/Tools/ToolViewModel.cs

@@ -26,24 +26,24 @@ internal abstract class ToolViewModel : NotifyableObject
 
     public virtual BrushShape BrushShape => BrushShape.Square;
 
-    public virtual bool HideHighlight { get; }
+                                                 public virtual bool HideHighlight { get; }
 
-    public abstract LocalizedString Tooltip { get; }
+                                                 public abstract LocalizedString Tooltip { get; }
 
-    /// <summary>
-    /// Determines if secondary color should be used if right click mode is set to secondary color
-    /// </summary>
-    public virtual bool UsesColor => false;
+                                                 /// <summary>
+                                                 /// Determines if secondary color should be used if right click mode is set to secondary color
+                                                 /// </summary>
+                                                 public virtual bool UsesColor => false;
 
-    /// <summary>
-    /// Determines if PixiEditor should switch to the Eraser when right click mode is set to erase
-    /// </summary>
-    public virtual bool IsErasable => false;
+                                                 /// <summary>
+                                                 /// Determines if PixiEditor should switch to the Eraser when right click mode is set to erase
+                                                 /// </summary>
+                                                 public virtual bool IsErasable => false;
 
-    /// <summary>
-    /// The mouse button that is being used with the tool
-    /// </summary>
-    public MouseButton UsedWith { get; set; }
+                                                 /// <summary>
+                                                 /// The mouse button that is being used with the tool
+                                                 /// </summary>
+                                                 public MouseButton UsedWith { get; set; }
 
     private LocalizedString actionDisplay = string.Empty;
     public LocalizedString ActionDisplay