Browse Source

Merge pull request #302 from PixiEditor/command-manager

Command Manager
Egor Mozgovoy 3 years ago
parent
commit
13a45b6718
100 changed files with 2387 additions and 296 deletions
  1. 2 0
      Custom.ruleset
  2. 1 0
      PixiEditor/App.xaml
  3. BIN
      PixiEditor/Fonts/feather.ttf
  4. 1 1
      PixiEditor/Helpers/Behaviours/ClearFocusOnClickBehavior.cs
  5. 1 1
      PixiEditor/Helpers/Behaviours/GlobalShortcutFocusBehavior.cs
  6. 5 8
      PixiEditor/Helpers/Behaviours/MouseBehavior.cs
  7. 33 0
      PixiEditor/Helpers/Behaviours/TextBlockExtensions.cs
  8. 2 4
      PixiEditor/Helpers/Behaviours/TextBoxFocusBehavior.cs
  9. 2 7
      PixiEditor/Helpers/BindingProxy.cs
  10. 16 6
      PixiEditor/Helpers/Converters/EqualityBoolToVisibilityConverter.cs
  11. 8 11
      PixiEditor/Helpers/Converters/FileExtensionToColorConverter.cs
  12. 1 5
      PixiEditor/Helpers/Converters/KeyToStringConverter.cs
  13. 32 0
      PixiEditor/Helpers/Converters/ModifierFlagToModifiersConverter.cs
  14. 10 1
      PixiEditor/Helpers/Converters/SKColorToMediaColorConverter.cs
  15. 36 0
      PixiEditor/Helpers/DesignCommandHelpers.cs
  16. 97 0
      PixiEditor/Helpers/Extensions/EnumerableHelpers.cs
  17. 4 3
      PixiEditor/Helpers/Extensions/ServiceCollectionHelpers.cs
  18. 14 0
      PixiEditor/Helpers/Extensions/SkiaWPFHelpers.cs
  19. 17 12
      PixiEditor/Helpers/GlobalMouseHook.cs
  20. 52 0
      PixiEditor/Helpers/IconEvaluators.cs
  21. 28 24
      PixiEditor/Helpers/InputKeyHelpers.cs
  22. 11 9
      PixiEditor/Helpers/PaletteHelpers.cs
  23. 2 7
      PixiEditor/Helpers/UI/TreeViewItemHelper.cs
  24. BIN
      PixiEditor/Images/Commands/PixiEditor/Clipboard/Copy.png
  25. BIN
      PixiEditor/Images/Commands/PixiEditor/Clipboard/Cut.png
  26. BIN
      PixiEditor/Images/Commands/PixiEditor/Clipboard/Paste.png
  27. BIN
      PixiEditor/Images/Commands/PixiEditor/Colors/Swap.png
  28. BIN
      PixiEditor/Images/Commands/PixiEditor/Document/CenterContent.png
  29. BIN
      PixiEditor/Images/Commands/PixiEditor/Document/ClipCanvas.png
  30. BIN
      PixiEditor/Images/Commands/PixiEditor/Document/ResizeCanvas.png
  31. BIN
      PixiEditor/Images/Commands/PixiEditor/Document/ResizeDocument.png
  32. BIN
      PixiEditor/Images/Commands/PixiEditor/File/Export.png
  33. BIN
      PixiEditor/Images/Commands/PixiEditor/File/New.png
  34. BIN
      PixiEditor/Images/Commands/PixiEditor/File/Open.png
  35. BIN
      PixiEditor/Images/Commands/PixiEditor/Links/OpenDocumentation.png
  36. BIN
      PixiEditor/Images/Commands/PixiEditor/Links/OpenRepository.png
  37. BIN
      PixiEditor/Images/Commands/PixiEditor/Search/Toggle.png
  38. BIN
      PixiEditor/Images/Commands/PixiEditor/Selection/Clear.png
  39. BIN
      PixiEditor/Images/Commands/PixiEditor/Selection/SelectAll.png
  40. BIN
      PixiEditor/Images/Commands/PixiEditor/View/ToggleGrid.png
  41. BIN
      PixiEditor/Images/Commands/PixiEditor/View/ZoomIn.png
  42. BIN
      PixiEditor/Images/Commands/PixiEditor/View/ZoomOut.png
  43. BIN
      PixiEditor/Images/Commands/PixiEditor/Window/OpenNavigationWindow.png
  44. BIN
      PixiEditor/Images/Commands/PixiEditor/Window/OpenSettingsWindow.png
  45. BIN
      PixiEditor/Images/Commands/PixiEditor/Window/OpenStartupWindow.png
  46. 36 0
      PixiEditor/Models/Commands/Attributes/Commands/BasicAttribute.cs
  47. 48 0
      PixiEditor/Models/Commands/Attributes/Commands/CommandAttribute.cs
  48. 17 0
      PixiEditor/Models/Commands/Attributes/Commands/DebugAttribute.cs
  49. 22 0
      PixiEditor/Models/Commands/Attributes/Commands/GroupAttribute.cs
  50. 27 0
      PixiEditor/Models/Commands/Attributes/Commands/InternalAttribute.cs
  51. 16 0
      PixiEditor/Models/Commands/Attributes/Commands/ToolAttribute.cs
  52. 20 0
      PixiEditor/Models/Commands/Attributes/Evaluators/CanExecuteAttribute.cs
  53. 15 0
      PixiEditor/Models/Commands/Attributes/Evaluators/EvaluatorAttribute.cs
  54. 12 0
      PixiEditor/Models/Commands/Attributes/Evaluators/IconAttribute.cs
  55. 84 0
      PixiEditor/Models/Commands/CommandCollection.cs
  56. 358 0
      PixiEditor/Models/Commands/CommandController.cs
  57. 55 0
      PixiEditor/Models/Commands/CommandGroup.cs
  58. 27 0
      PixiEditor/Models/Commands/CommandMethods.cs
  59. 16 0
      PixiEditor/Models/Commands/Commands/BasicCommand.cs
  60. 57 0
      PixiEditor/Models/Commands/Commands/Command.cs
  61. 19 0
      PixiEditor/Models/Commands/Commands/ToolCommand.cs
  62. 21 0
      PixiEditor/Models/Commands/Evaluators/CanExecuteEvaluator.cs
  63. 17 0
      PixiEditor/Models/Commands/Evaluators/Evaluator.cs
  64. 78 0
      PixiEditor/Models/Commands/Evaluators/IconEvaluator.cs
  65. 15 0
      PixiEditor/Models/Commands/Exceptions/CommandNotFoundException.cs
  66. 58 0
      PixiEditor/Models/Commands/Search/ColorSearchResult.cs
  67. 22 0
      PixiEditor/Models/Commands/Search/CommandSearchResult.cs
  68. 36 0
      PixiEditor/Models/Commands/Search/FileSearchResult.cs
  69. 75 0
      PixiEditor/Models/Commands/Search/SearchResult.cs
  70. 16 0
      PixiEditor/Models/Commands/ShortcutChangedEventArgs.cs
  71. 45 0
      PixiEditor/Models/Commands/ShortcutFile.cs
  72. 6 0
      PixiEditor/Models/Commands/Templates/IShortcutDefaults.cs
  73. 8 0
      PixiEditor/Models/Commands/Templates/IShortcutFile.cs
  74. 8 0
      PixiEditor/Models/Commands/Templates/IShortcutInstallation.cs
  75. 28 0
      PixiEditor/Models/Commands/Templates/Providers/AsepriteProvider.cs
  76. 38 0
      PixiEditor/Models/Commands/Templates/Providers/DebugProvider.cs
  77. 25 0
      PixiEditor/Models/Commands/Templates/ShortcutCollection.cs
  78. 42 0
      PixiEditor/Models/Commands/Templates/ShortcutProvider.cs
  79. 83 0
      PixiEditor/Models/Commands/XAML/Command.cs
  80. 48 0
      PixiEditor/Models/Commands/XAML/ContextMenu.cs
  81. 48 0
      PixiEditor/Models/Commands/XAML/Menu.cs
  82. 47 0
      PixiEditor/Models/Commands/XAML/ShortcutBinding.cs
  83. 4 0
      PixiEditor/Models/Controllers/BitmapManager.cs
  84. 67 0
      PixiEditor/Models/Controllers/ShortcutController.cs
  85. 0 46
      PixiEditor/Models/Controllers/Shortcuts/Shortcut.cs
  86. 0 90
      PixiEditor/Models/Controllers/Shortcuts/ShortcutController.cs
  87. 0 43
      PixiEditor/Models/Controllers/Shortcuts/ShortcutGroup.cs
  88. 44 0
      PixiEditor/Models/DataHolders/KeyCombination.cs
  89. 158 0
      PixiEditor/Models/DataHolders/OneToManyDictionary.cs
  90. 74 0
      PixiEditor/Models/Dialogs/OptionsDialog.cs
  91. 32 0
      PixiEditor/Models/Services/CommandProvider.cs
  92. 8 0
      PixiEditor/Models/Services/DocumentProvider.cs
  93. 3 1
      PixiEditor/Models/Tools/Tool.cs
  94. 4 3
      PixiEditor/Models/Tools/Tools/BrightnessTool.cs
  95. 4 1
      PixiEditor/Models/Tools/Tools/CircleTool.cs
  96. 4 3
      PixiEditor/Models/Tools/Tools/ColorPickerTool.cs
  97. 5 3
      PixiEditor/Models/Tools/Tools/EraserTool.cs
  98. 4 2
      PixiEditor/Models/Tools/Tools/FloodFillTool.cs
  99. 4 3
      PixiEditor/Models/Tools/Tools/LineTool.cs
  100. 4 2
      PixiEditor/Models/Tools/Tools/MagicWandTool.cs

+ 2 - 0
Custom.ruleset

@@ -29,6 +29,7 @@
     <Rule Id="SA1122" Action="None" />
     <Rule Id="SA1122" Action="None" />
     <Rule Id="SA1124" Action="None" />
     <Rule Id="SA1124" Action="None" />
     <Rule Id="SA1128" Action="None" />
     <Rule Id="SA1128" Action="None" />
+    <Rule Id="SA1129" Action="None" />
     <Rule Id="SA1130" Action="None" />
     <Rule Id="SA1130" Action="None" />
     <Rule Id="SA1132" Action="None" />
     <Rule Id="SA1132" Action="None" />
     <Rule Id="SA1135" Action="None" />
     <Rule Id="SA1135" Action="None" />
@@ -51,6 +52,7 @@
     <Rule Id="SA1309" Action="None" />
     <Rule Id="SA1309" Action="None" />
     <Rule Id="SA1310" Action="None" />
     <Rule Id="SA1310" Action="None" />
     <Rule Id="SA1311" Action="None" />
     <Rule Id="SA1311" Action="None" />
+    <Rule Id="SA1313" Action="None" />
     <Rule Id="SA1400" Action="None" />
     <Rule Id="SA1400" Action="None" />
     <Rule Id="SA1401" Action="None" />
     <Rule Id="SA1401" Action="None" />
     <Rule Id="SA1402" Action="None" />
     <Rule Id="SA1402" Action="None" />

+ 1 - 0
PixiEditor/App.xaml

@@ -28,6 +28,7 @@
                 <ResourceDictionary Source="Styles/AvalonDock/PixiEditorDockTheme.xaml" />
                 <ResourceDictionary Source="Styles/AvalonDock/PixiEditorDockTheme.xaml" />
                 <ResourceDictionary Source="Styles/TreeViewStyle.xaml" />
                 <ResourceDictionary Source="Styles/TreeViewStyle.xaml" />
                 <ResourceDictionary Source="Styles/RadioButtonStyle.xaml" />
                 <ResourceDictionary Source="Styles/RadioButtonStyle.xaml" />
+                <ResourceDictionary Source="Styles/Fonts.xaml" />
                 <ResourceDictionary Source="pack://application:,,,/ColorPicker;component/Styles/DefaultColorPickerStyle.xaml" />
                 <ResourceDictionary Source="pack://application:,,,/ColorPicker;component/Styles/DefaultColorPickerStyle.xaml" />
             </ResourceDictionary.MergedDictionaries>
             </ResourceDictionary.MergedDictionaries>
         </ResourceDictionary>
         </ResourceDictionary>

BIN
PixiEditor/Fonts/feather.ttf


+ 1 - 1
PixiEditor/Helpers/Behaviours/ClearFocusOnClickBehavior.cs

@@ -1,4 +1,4 @@
-using PixiEditor.Models.Controllers.Shortcuts;
+using PixiEditor.Models.Controllers;
 using System.Windows;
 using System.Windows;
 using System.Windows.Input;
 using System.Windows.Input;
 using System.Windows.Interactivity;
 using System.Windows.Interactivity;

+ 1 - 1
PixiEditor/Helpers/Behaviours/GlobalShortcutFocusBehavior.cs

@@ -5,7 +5,7 @@ using System.Text;
 using System.Threading.Tasks;
 using System.Threading.Tasks;
 using System.Windows;
 using System.Windows;
 using System.Windows.Interactivity;
 using System.Windows.Interactivity;
-using PixiEditor.Models.Controllers.Shortcuts;
+using PixiEditor.Models.Controllers;
 
 
 namespace PixiEditor.Helpers.Behaviours
 namespace PixiEditor.Helpers.Behaviours
 {
 {

+ 5 - 8
PixiEditor/Helpers/Behaviours/MouseBehavior.cs

@@ -6,16 +6,13 @@ namespace PixiEditor.Helpers.Behaviours
 {
 {
     public class MouseBehavior : Behavior<FrameworkElement>
     public class MouseBehavior : Behavior<FrameworkElement>
     {
     {
-        public static readonly DependencyProperty MouseYProperty = DependencyProperty.Register(
-            "MouseY", typeof(double), typeof(MouseBehavior), new PropertyMetadata(default(double)));
+        public static readonly DependencyProperty MouseYProperty = DependencyProperty.Register(nameof(MouseY), typeof(double), typeof(MouseBehavior), new PropertyMetadata(default(double)));
+
+        public static readonly DependencyProperty MouseXProperty = DependencyProperty.Register(nameof(MouseX), typeof(double), typeof(MouseBehavior), new PropertyMetadata(default(double)));
 
 
-        public static readonly DependencyProperty MouseXProperty = DependencyProperty.Register(
-            "MouseX", typeof(double), typeof(MouseBehavior), new PropertyMetadata(default(double)));
 
 
-        // Using a DependencyProperty as the backing store for RelativeTo.  This enables animation, styling, binding, etc...
         public static readonly DependencyProperty RelativeToProperty =
         public static readonly DependencyProperty RelativeToProperty =
-            DependencyProperty.Register(
-                "RelativeTo",
+            DependencyProperty.Register(nameof(RelativeTo),
                 typeof(FrameworkElement),
                 typeof(FrameworkElement),
                 typeof(MouseBehavior),
                 typeof(MouseBehavior),
                 new PropertyMetadata(default(FrameworkElement)));
                 new PropertyMetadata(default(FrameworkElement)));
@@ -64,4 +61,4 @@ namespace PixiEditor.Helpers.Behaviours
 
 
 #if PUBLISH
 #if PUBLISH
 #error Hi
 #error Hi
-#endif
+#endif

+ 33 - 0
PixiEditor/Helpers/Behaviours/TextBlockExtensions.cs

@@ -0,0 +1,33 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+
+namespace PixiEditor.Helpers.Behaviours
+{
+    public static class TextBlockExtensions
+    {
+        public static IEnumerable<Inline> GetBindableInlines(DependencyObject obj)
+        {
+            return (IEnumerable<Inline>)obj.GetValue(BindableInlinesProperty);
+        }
+
+        public static void SetBindableInlines(DependencyObject obj, IEnumerable<Inline> value)
+        {
+            obj.SetValue(BindableInlinesProperty, value);
+        }
+
+        public static readonly DependencyProperty BindableInlinesProperty =
+            DependencyProperty.RegisterAttached("BindableInlines", typeof(IEnumerable<Inline>), typeof(TextBlockExtensions), new PropertyMetadata(null, OnBindableInlinesChanged));
+
+        private static void OnBindableInlinesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+        {
+            if (d is not TextBlock target)
+            {
+                return;
+            }
+
+            target.Inlines.Clear();
+            target.Inlines.AddRange((System.Collections.IEnumerable)e.NewValue);
+        }
+    }
+}

+ 2 - 4
PixiEditor/Helpers/Behaviours/TextBoxFocusBehavior.cs

@@ -1,5 +1,4 @@
-using System.Linq;
-using System.Windows;
+using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Controls;
 using System.Windows.Input;
 using System.Windows.Input;
 using System.Windows.Interactivity;
 using System.Windows.Interactivity;
@@ -46,8 +45,7 @@ namespace PixiEditor.Helpers.Behaviours
             set => SetValue(DeselectOnFocusLossProperty, value);
             set => SetValue(DeselectOnFocusLossProperty, value);
         }
         }
 
 
-        public static readonly DependencyProperty FocusNextProperty = DependencyProperty.Register(
-            "FocusNext", typeof(bool), typeof(TextBoxFocusBehavior), new PropertyMetadata(false));
+        public static readonly DependencyProperty FocusNextProperty = DependencyProperty.Register(nameof(FocusNext), typeof(bool), typeof(TextBoxFocusBehavior), new PropertyMetadata(false));
 
 
         public bool FocusNext
         public bool FocusNext
         {
         {

+ 2 - 7
PixiEditor/Helpers/BindingProxy.cs

@@ -1,9 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
+using System.Windows;
 
 
 namespace PixiEditor.Helpers
 namespace PixiEditor.Helpers
 {
 {
@@ -21,6 +16,6 @@ namespace PixiEditor.Helpers
         }
         }
 
 
         public static readonly DependencyProperty DataProperty =
         public static readonly DependencyProperty DataProperty =
-            DependencyProperty.Register("Data", typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null));
+            DependencyProperty.Register(nameof(Data), typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null));
     }
     }
 }
 }

+ 16 - 6
PixiEditor/Helpers/Converters/EqualityBoolToVisibilityConverter.cs

@@ -1,17 +1,27 @@
-using System;
-using System.Globalization;
+using System.Globalization;
 using System.Windows;
 using System.Windows;
 
 
 namespace PixiEditor.Helpers.Converters
 namespace PixiEditor.Helpers.Converters
 {
 {
-    public class EqualityBoolToVisibilityConverter :
-        SingleInstanceConverter<EqualityBoolToVisibilityConverter>
+    public class EqualityBoolToVisibilityConverter : MarkupConverter
     {
     {
+        public bool Invert { get; set; }
+
         public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
         public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
         {
         {
             if (value == null)
             if (value == null)
-                return false;
-            return value.Equals(parameter) ? Visibility.Visible : Visibility.Collapsed;
+                return Invert;
+
+            if (value.GetType().IsAssignableTo(typeof(Enum)) && parameter is string s)
+            {
+                parameter = Enum.Parse(value.GetType(), s);
+            }
+            else
+            {
+                parameter = System.Convert.ChangeType(parameter, value.GetType());
+            }
+
+            return value.Equals(parameter) != Invert ? Visibility.Visible : Visibility.Collapsed;
         }
         }
     }
     }
 }
 }

+ 8 - 11
PixiEditor/Helpers/Converters/FileExtensionToColorConverter.cs

@@ -1,11 +1,7 @@
-using PixiEditor.Models;
-using System;
-using System.Linq;
-using System.Collections.Generic;
-using System.Drawing.Imaging;
+using PixiEditor.Models.Enums;
 using System.Globalization;
 using System.Globalization;
+using System.IO;
 using System.Windows.Media;
 using System.Windows.Media;
-using PixiEditor.Models.Enums;
 
 
 namespace PixiEditor.Helpers.Converters
 namespace PixiEditor.Helpers.Converters
 {
 {
@@ -29,12 +25,13 @@ namespace PixiEditor.Helpers.Converters
         {
         {
             SupportedFilesHelper.GetFileTypeDialogData(format).Extensions.ForEach(i => extensionsToBrushes[i] = brush);
             SupportedFilesHelper.GetFileTypeDialogData(format).Extensions.ForEach(i => extensionsToBrushes[i] = brush);
         }
         }
-        
-        public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
-        {
-            string extension = (string)value;
 
 
-            return extensionsToBrushes.ContainsKey(extension) ? extensionsToBrushes[extension] : UnknownBrush;
+        public override object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
+            GetBrush((string)value);
+
+        public static Brush GetBrush(string path)
+        {
+            return extensionsToBrushes.GetValueOrDefault(Path.GetExtension(path).ToLower(), UnknownBrush);
         }
         }
 
 
         private static SolidColorBrush ColorBrush(byte r, byte g, byte b)
         private static SolidColorBrush ColorBrush(byte r, byte g, byte b)

+ 1 - 5
PixiEditor/Helpers/Converters/KeyToStringConverter.cs

@@ -11,11 +11,7 @@ namespace PixiEditor.Helpers.Converters
         {
         {
             if (value is Key key)
             if (value is Key key)
             {
             {
-                return key switch
-                {
-                    Key.Space => "Space",
-                    _ => InputKeyHelpers.GetCharFromKey(key),
-                };
+                return InputKeyHelpers.GetKeyboardKey(key);
             }
             }
             else if (value is ModifierKeys)
             else if (value is ModifierKeys)
             {
             {

+ 32 - 0
PixiEditor/Helpers/Converters/ModifierFlagToModifiersConverter.cs

@@ -0,0 +1,32 @@
+using System.Globalization;
+using System.Windows.Input;
+
+namespace PixiEditor.Helpers.Converters;
+
+public class ModifierFlagToModifiersConverter : SingleInstanceConverter<ModifierFlagToModifiersConverter>
+{
+    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+    {
+        return GetModifiers((ModifierKeys)value);
+    }
+    
+    private IEnumerable<ModifierKeys> GetModifiers(ModifierKeys keys)
+    {
+        if (keys.HasFlag(ModifierKeys.Windows))
+        {
+            yield return ModifierKeys.Windows;
+        }
+        else if (keys.HasFlag(ModifierKeys.Control))
+        {
+            yield return ModifierKeys.Control;
+        }
+        else if (keys.HasFlag(ModifierKeys.Shift))
+        {
+            yield return ModifierKeys.Shift;
+        }
+        else if (keys.HasFlag(ModifierKeys.Alt))
+        {
+            yield return ModifierKeys.Alt;
+        }
+    }
+}

+ 10 - 1
PixiEditor/Helpers/Converters/SKColorToMediaColorConverter.cs

@@ -11,7 +11,16 @@ namespace PixiEditor.Helpers.Converters
         public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
         public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
         {
         {
             var skcolor = (SKColor)value;
             var skcolor = (SKColor)value;
-            return Color.FromArgb(skcolor.Alpha, skcolor.Red, skcolor.Green, skcolor.Blue);
+            var color = Color.FromArgb(skcolor.Alpha, skcolor.Red, skcolor.Green, skcolor.Blue);
+
+            if (targetType == typeof(Brush))
+            {
+                return new SolidColorBrush(color);
+            }
+            else
+            {
+                return color;
+            }
         }
         }
 
 
         public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
         public override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)

+ 36 - 0
PixiEditor/Helpers/DesignCommandHelpers.cs

@@ -0,0 +1,36 @@
+using PixiEditor.Models.Commands;
+using CommandAttribute = PixiEditor.Models.Commands.Attributes.Command;
+using System.Reflection;
+using PixiEditor.Models.Commands.Exceptions;
+
+namespace PixiEditor.Helpers
+{
+    /// <summary>
+    /// Helps with debugging when using XAML
+    /// </summary>
+    public 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;
+        }
+    }
+}

+ 97 - 0
PixiEditor/Helpers/Extensions/EnumerableHelpers.cs

@@ -0,0 +1,97 @@
+namespace PixiEditor.Helpers.Extensions
+{
+    public static class EnumerableHelpers
+    {
+        /// <summary>
+        /// Get's the item at the <paramref name="index"/> if it matches the <paramref name="predicate"/> or the first that matches after the <paramref name="index"/>.
+        /// </summary>
+        /// <param name="overrun">Should the enumerator start from the bottom if it can't find the first item in the higher part</param>
+        /// <returns>The first item or null if no item can be found.</returns>
+        public static T IndexOrNext<T>(this IEnumerable<T> collection, Predicate<T> predicate, int index, bool overrun = true)
+        {
+            if (index < 0)
+            {
+                throw new ArgumentOutOfRangeException(nameof(index));
+            }
+
+            var enumerator = collection.GetEnumerator();
+
+            // Iterate to the target index
+            for (int i = 0; i < index; i++)
+            {
+                if (!enumerator.MoveNext())
+                {
+                    return default;
+                }
+            }
+
+            while (enumerator.MoveNext())
+            {
+                if (predicate(enumerator.Current))
+                {
+                    return enumerator.Current;
+                }
+            }
+
+            if (!overrun)
+            {
+                return default;
+            }
+
+            enumerator.Reset();
+
+            for (int i = 0; i < index; i++)
+            {
+                enumerator.MoveNext();
+                if (predicate(enumerator.Current))
+                {
+                    return enumerator.Current;
+                }
+            }
+
+            return default;
+        }
+
+        /// <summary>
+        /// Get's the item at the <paramref name="index"/> if it matches the <paramref name="predicate"/> or the first item that matches before the <paramref name="index"/>.
+        /// </summary>
+        /// <param name="underrun">Should the enumerator start from the top if it can't find the first item in the lower part</param>
+        /// <returns>The first item or null if no item can be found.</returns>
+        public static T IndexOrPrevious<T>(this IEnumerable<T> collection, Predicate<T> predicate, int index, bool underrun = true)
+        {
+            if (index < 0)
+            {
+                throw new ArgumentOutOfRangeException(nameof(index));
+            }
+
+            var enumerator = collection.GetEnumerator();
+            T[] previousItems = new T[index + 1];
+
+            // Iterate to the target index
+            for (int i = 0; i <= index; i++)
+            {
+                if (!enumerator.MoveNext())
+                {
+                    return default;
+                }
+
+                previousItems[i] = enumerator.Current;
+            }
+
+            for (int i = index; i >= 0; i--)
+            {
+                if (predicate(previousItems[i]))
+                {
+                    return previousItems[i];
+                }
+            }
+
+            if (!underrun)
+            {
+                return default;
+            }
+
+            return IndexOrNext(collection, predicate, index, false);
+        }
+    }
+}

+ 4 - 3
PixiEditor/Helpers/Extensions/ServiceCollectionHelpers.cs

@@ -1,6 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Models.Commands;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Controllers;
-using PixiEditor.Models.Controllers.Shortcuts;
 using PixiEditor.Models.DataProviders;
 using PixiEditor.Models.DataProviders;
 using PixiEditor.Models.IO;
 using PixiEditor.Models.IO;
 using PixiEditor.Models.IO.ClsFile;
 using PixiEditor.Models.IO.ClsFile;
@@ -36,12 +36,13 @@ namespace PixiEditor.Helpers.Extensions
                 .AddSingleton<ViewportViewModel>()
                 .AddSingleton<ViewportViewModel>()
                 .AddSingleton<ColorsViewModel>()
                 .AddSingleton<ColorsViewModel>()
                 .AddSingleton<DocumentViewModel>()
                 .AddSingleton<DocumentViewModel>()
-                .AddSingleton<MiscViewModel>()
                 .AddSingleton<RegistryViewModel>()
                 .AddSingleton<RegistryViewModel>()
-                .AddSingleton(x => new DiscordViewModel(x.GetService<ViewModelMain>(), "764168193685979138"))
+                .AddSingleton(static x => new DiscordViewModel(x.GetService<ViewModelMain>(), "764168193685979138"))
                 .AddSingleton<DebugViewModel>()
                 .AddSingleton<DebugViewModel>()
+                .AddSingleton<SearchViewModel>()
                 // Controllers
                 // Controllers
                 .AddSingleton<ShortcutController>()
                 .AddSingleton<ShortcutController>()
+                .AddSingleton<CommandController>()
                 .AddSingleton<BitmapManager>()
                 .AddSingleton<BitmapManager>()
                 // Tools
                 // Tools
                 .AddSingleton<Tool, MoveViewportTool>()
                 .AddSingleton<Tool, MoveViewportTool>()

+ 14 - 0
PixiEditor/Helpers/Extensions/SkiaWPFHelpers.cs

@@ -0,0 +1,14 @@
+using SkiaSharp;
+using System.Windows.Media;
+
+namespace PixiEditor.Helpers.Extensions
+{
+    public static class SkiaWPFHelpers
+    {
+        public static SKColor ToOpaqueSKColor(this Color color) => new(color.R, color.G, color.B);
+        public static SKColor ToSKColor(this Color color) => new(color.R, color.G, color.B, color.A);
+
+        public static Color ToOpaqueColor(this SKColor color) => Color.FromRgb(color.Red, color.Green, color.Blue);
+        public static Color ToColor(this SKColor color) => Color.FromArgb(color.Alpha, color.Red, color.Green, color.Blue);
+    }
+}

+ 17 - 12
PixiEditor/Helpers/GlobalMouseHook.cs

@@ -5,8 +5,8 @@ using System.Diagnostics.CodeAnalysis;
 using System.Runtime.InteropServices;
 using System.Runtime.InteropServices;
 using System.Windows;
 using System.Windows;
 using System.Windows.Input;
 using System.Windows.Input;
-using System.Windows.Threading;
-
+using System.Windows.Threading;
+
 namespace PixiEditor.Helpers
 namespace PixiEditor.Helpers
 {
 {
     public delegate void MouseUpEventHandler(object sender, Point p, MouseButton button);
     public delegate void MouseUpEventHandler(object sender, Point p, MouseButton button);
@@ -14,7 +14,7 @@ namespace PixiEditor.Helpers
     // see https://stackoverflow.com/questions/22659925/how-to-capture-mouseup-event-outside-the-wpf-window
     // see https://stackoverflow.com/questions/22659925/how-to-capture-mouseup-event-outside-the-wpf-window
     [ExcludeFromCodeCoverage]
     [ExcludeFromCodeCoverage]
     public static class GlobalMouseHook
     public static class GlobalMouseHook
-    {
+    {
         private const int WH_MOUSE_LL = 14;
         private const int WH_MOUSE_LL = 14;
         private const int WM_LBUTTONUP = 0x0202;
         private const int WM_LBUTTONUP = 0x0202;
         private const int WM_MBUTTONUP = 0x0208;
         private const int WM_MBUTTONUP = 0x0208;
@@ -23,20 +23,25 @@ namespace PixiEditor.Helpers
         private static int mouseHookHandle;
         private static int mouseHookHandle;
         private static HookProc mouseDelegate;
         private static HookProc mouseDelegate;
 
 
-        private delegate int HookProc(int nCode, int wParam, IntPtr lParam);
-
+        private delegate int HookProc(int nCode, int wParam, IntPtr lParam);
+
         public static event MouseUpEventHandler OnMouseUp
         public static event MouseUpEventHandler OnMouseUp
         {
         {
             add
             add
-            {
+            { 
+// disable low-level hook in debug to prevent mouse lag when pausing in debugger
+#if !DEBUG
                 Subscribe();
                 Subscribe();
+#endif
                 MouseUp += value;
                 MouseUp += value;
             }
             }
-
+
             remove
             remove
             {
             {
                 MouseUp -= value;
                 MouseUp -= value;
+#if !DEBUG
                 Unsubscribe();
                 Unsubscribe();
+#endif
             }
             }
         }
         }
 
 
@@ -91,13 +96,13 @@ namespace PixiEditor.Helpers
                     {
                     {
 
 
                         MouseButton button = wParam == WM_LBUTTONUP ? MouseButton.Left
                         MouseButton button = wParam == WM_LBUTTONUP ? MouseButton.Left
-                            : wParam == WM_MBUTTONUP ? MouseButton.Middle : MouseButton.Right;
-                        Dispatcher.CurrentDispatcher.BeginInvoke(() =>
+                            : wParam == WM_MBUTTONUP ? MouseButton.Middle : MouseButton.Right;
+                        Dispatcher.CurrentDispatcher.BeginInvoke(() =>
                             MouseUp.Invoke(null, new Point(mouseHookStruct.Pt.X, mouseHookStruct.Pt.Y), button));
                             MouseUp.Invoke(null, new Point(mouseHookStruct.Pt.X, mouseHookStruct.Pt.Y), button));
                     }
                     }
                 }
                 }
             }
             }
-
+
             return CallNextHookEx(mouseHookHandle, nCode, wParam, lParam);
             return CallNextHookEx(mouseHookHandle, nCode, wParam, lParam);
         }
         }
 
 
@@ -122,8 +127,8 @@ namespace PixiEditor.Helpers
         private static extern int CallNextHookEx(int idHook, int nCode, int wParam, IntPtr lParam);
         private static extern int CallNextHookEx(int idHook, int nCode, int wParam, IntPtr lParam);
 
 
         [DllImport("kernel32.dll")]
         [DllImport("kernel32.dll")]
-        private static extern IntPtr GetModuleHandle(string name);
-
+        private static extern IntPtr GetModuleHandle(string name);
+
         [StructLayout(LayoutKind.Sequential)]
         [StructLayout(LayoutKind.Sequential)]
         private struct POINT
         private struct POINT
         {
         {

+ 52 - 0
PixiEditor/Helpers/IconEvaluators.cs

@@ -0,0 +1,52 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Commands.Search;
+using Command = PixiEditor.Models.Commands.Command;
+
+namespace PixiEditor.Helpers;
+
+public static class IconEvaluators
+{
+    private static readonly FontFamily segeoMdl2 = new FontFamily("Segoe MDL2 Assets");
+    
+    [Evaluator.Icon("PixiEditor.FontIcon")]
+    public static ImageSource GetFontIcon(object parameter)
+    {
+        string symbolCode = GetIconName(parameter);
+        
+        var textBlock = new TextBlock
+        {
+            FontFamily = segeoMdl2,
+            Foreground = Brushes.White,
+            Text = char.ConvertFromUtf32(int.Parse(symbolCode, NumberStyles.HexNumber)),
+        };
+
+        var brush = new VisualBrush
+        {
+            Visual = textBlock,
+            Stretch = Stretch.Uniform
+        };
+
+        var drawing = new GeometryDrawing
+        {
+            Brush = brush,
+            Geometry = new RectangleGeometry(
+                new Rect(0, 0, 32, 32))
+        };
+
+        return new DrawingImage(drawing);
+    }
+
+    private static string GetIconName(object parameter)
+    {
+        return parameter switch
+        {
+            Command command => command.IconPath,
+            CommandSearchResult cmdResult => cmdResult.Command.IconPath,
+            _ => throw new NotImplementedException($"Parameter typeof {parameter.GetType()} has not been implemented yet.")
+        };
+    }
+}

+ 28 - 24
PixiEditor/Helpers/InputKeyHelpers.cs

@@ -1,43 +1,47 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
+using System.Globalization;
 using System.Runtime.InteropServices;
 using System.Runtime.InteropServices;
 using System.Text;
 using System.Text;
-using System.Threading.Tasks;
 using System.Windows.Input;
 using System.Windows.Input;
 
 
 namespace PixiEditor.Helpers
 namespace PixiEditor.Helpers
 {
 {
     public static class InputKeyHelpers
     public static class InputKeyHelpers
     {
     {
-        public static string GetCharFromKey(Key key)
+        /// <summary>
+        /// Returns the charcter of the <paramref name="key"/> mapped to the users keyboard layout
+        /// </summary>
+        public static string GetKeyboardKey(Key key) => GetKeyboardKey(key, CultureInfo.CurrentCulture);
+
+        public static string GetKeyboardKey(Key key, CultureInfo culture) => key switch
+        {
+            >= Key.NumPad0 and <= Key.Divide => $"Num {GetMappedKey(key, culture)}",
+            Key.Space => nameof(Key.Space),
+            Key.Tab => nameof(Key.Tab),
+            Key.Back => "Backspace",
+            Key.Escape => "Esc",
+            _ => GetMappedKey(key, culture),
+        };
+
+        private static string GetMappedKey(Key key, CultureInfo culture)
         {
         {
             int virtualKey = KeyInterop.VirtualKeyFromKey(key);
             int virtualKey = KeyInterop.VirtualKeyFromKey(key);
             byte[] keyboardState = new byte[256];
             byte[] keyboardState = new byte[256];
-            GetKeyboardState(keyboardState);
 
 
-            uint scanCode = MapVirtualKeyW((uint)virtualKey, MapType.MAPVK_VK_TO_VSC);
-            StringBuilder stringBuilder = new (3);
+            uint scanCode = MapVirtualKeyExW((uint)virtualKey, MapType.MAPVK_VK_TO_VSC, culture.KeyboardLayoutId);
+            StringBuilder stringBuilder = new(3);
 
 
             int result = ToUnicode((uint)virtualKey, scanCode, keyboardState, stringBuilder, stringBuilder.Capacity, 0);
             int result = ToUnicode((uint)virtualKey, scanCode, keyboardState, stringBuilder, stringBuilder.Capacity, 0);
 
 
-            switch (result)
-            {
-                case 0:
-                    {
-                        return key.ToString();
-                    }
+            string stringResult;
 
 
-                case -1:
-                    {
-                        return stringBuilder.ToString().ToUpper();
-                    }
+            stringResult = result switch
+            {
+                0 => key.ToString(),
+                -1 => stringBuilder.ToString().ToUpper(),
+                _ => stringBuilder[result - 1].ToString().ToUpper()
+            };
 
 
-                default:
-                    {
-                        return stringBuilder[result - 1].ToString().ToUpper();
-                    }
-            }
+            return stringResult;
         }
         }
 
 
         private enum MapType : uint
         private enum MapType : uint
@@ -77,6 +81,6 @@ namespace PixiEditor.Helpers
         private static extern bool GetKeyboardState(byte[] lpKeyState);
         private static extern bool GetKeyboardState(byte[] lpKeyState);
 
 
         [DllImport("user32.dll")]
         [DllImport("user32.dll")]
-        private static extern uint MapVirtualKeyW(uint uCode, MapType uMapType);
+        private static extern uint MapVirtualKeyExW(uint uCode, MapType uMapType, int hkl);
     }
     }
 }
 }

+ 11 - 9
PixiEditor/Helpers/PaletteHelpers.cs

@@ -1,22 +1,24 @@
-using System.Collections.Generic;
-using PixiEditor.Models.IO;
+using PixiEditor.Models.IO;
 
 
 namespace PixiEditor.Helpers
 namespace PixiEditor.Helpers
 {
 {
     public static class PaletteHelpers
     public static class PaletteHelpers
     {
     {
-        public static string GetFilter(IList<PaletteFileParser> parsers)
+        public static string GetFilter(IList<PaletteFileParser> parsers, bool includeCommon)
         {
         {
             string filter = "";
             string filter = "";
 
 
-            List<string> allSupportedFormats = new();
-            foreach (var parser in parsers)
+            if (includeCommon)
             {
             {
-                allSupportedFormats.AddRange(parser.SupportedFileExtensions);
+                List<string> allSupportedFormats = new();
+                foreach (var parser in parsers)
+                {
+                    allSupportedFormats.AddRange(parser.SupportedFileExtensions);
+                }
+                string allSupportedFormatsString = string.Join(';', allSupportedFormats).Replace(".", "*.");
+                filter += $"Palette Files ({allSupportedFormatsString})|{allSupportedFormatsString}|";
             }
             }
-            string allSupportedFormatsString = string.Join(';', allSupportedFormats).Replace(".", "*.");
-            filter += $"Palette Files ({allSupportedFormatsString})|{allSupportedFormatsString}|";
-            
+
             foreach (var parser in parsers)
             foreach (var parser in parsers)
             {
             {
                 string supportedFormats = string.Join(';', parser.SupportedFileExtensions).Replace(".", "*.");
                 string supportedFormats = string.Join(';', parser.SupportedFileExtensions).Replace(".", "*.");

+ 2 - 7
PixiEditor/Helpers/UI/TreeViewItemHelper.cs

@@ -1,9 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
+using System.Windows;
 
 
 namespace PixiEditor.Helpers.UI
 namespace PixiEditor.Helpers.UI
 {
 {
@@ -19,7 +14,7 @@ namespace PixiEditor.Helpers.UI
             obj.SetValue(IndentProperty, value);
             obj.SetValue(IndentProperty, value);
         }
         }
 
 
-        // Using a DependencyProperty as the backing store for Indent.  This enables animation, styling, binding, etc...
+
         public static readonly DependencyProperty IndentProperty =
         public static readonly DependencyProperty IndentProperty =
             DependencyProperty.RegisterAttached("Indent", typeof(GridLength), typeof(TreeViewItemHelper), new PropertyMetadata(new GridLength(0)));
             DependencyProperty.RegisterAttached("Indent", typeof(GridLength), typeof(TreeViewItemHelper), new PropertyMetadata(new GridLength(0)));
     }
     }

BIN
PixiEditor/Images/Commands/PixiEditor/Clipboard/Copy.png


BIN
PixiEditor/Images/Commands/PixiEditor/Clipboard/Cut.png


BIN
PixiEditor/Images/Commands/PixiEditor/Clipboard/Paste.png


BIN
PixiEditor/Images/Commands/PixiEditor/Colors/Swap.png


BIN
PixiEditor/Images/Commands/PixiEditor/Document/CenterContent.png


BIN
PixiEditor/Images/Commands/PixiEditor/Document/ClipCanvas.png


BIN
PixiEditor/Images/Commands/PixiEditor/Document/ResizeCanvas.png


BIN
PixiEditor/Images/Commands/PixiEditor/Document/ResizeDocument.png


BIN
PixiEditor/Images/Commands/PixiEditor/File/Export.png


BIN
PixiEditor/Images/Commands/PixiEditor/File/New.png


BIN
PixiEditor/Images/Commands/PixiEditor/File/Open.png


BIN
PixiEditor/Images/Commands/PixiEditor/Links/OpenDocumentation.png


BIN
PixiEditor/Images/Commands/PixiEditor/Links/OpenRepository.png


BIN
PixiEditor/Images/Commands/PixiEditor/Search/Toggle.png


BIN
PixiEditor/Images/Commands/PixiEditor/Selection/Clear.png


BIN
PixiEditor/Images/Commands/PixiEditor/Selection/SelectAll.png


BIN
PixiEditor/Images/Commands/PixiEditor/View/ToggleGrid.png


BIN
PixiEditor/Images/Commands/PixiEditor/View/ZoomIn.png


BIN
PixiEditor/Images/Commands/PixiEditor/View/ZoomOut.png


BIN
PixiEditor/Images/Commands/PixiEditor/Window/OpenNavigationWindow.png


BIN
PixiEditor/Images/Commands/PixiEditor/Window/OpenSettingsWindow.png


BIN
PixiEditor/Images/Commands/PixiEditor/Window/OpenStartupWindow.png


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

@@ -0,0 +1,36 @@
+namespace PixiEditor.Models.Commands.Attributes;
+
+public partial class Command
+{
+    public 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..."</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</param>
+        public BasicAttribute(string internalName, string displayName, string description)
+            : this(internalName, null, displayName, description)
+        {
+        }
+
+        /// <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..."</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</param>
+        public BasicAttribute(string internalName, object parameter, string displayName, string description)
+            : base(internalName, displayName, description)
+        {
+            Parameter = parameter;
+        }
+    }
+}

+ 48 - 0
PixiEditor/Models/Commands/Attributes/Commands/CommandAttribute.cs

@@ -0,0 +1,48 @@
+using PixiEditor.Models.DataHolders;
+using System.Windows.Input;
+
+namespace PixiEditor.Models.Commands.Attributes;
+
+public partial class Command
+{
+    [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
+    public abstract class CommandAttribute : Attribute
+    {
+        public string InternalName { get; }
+
+        public string DisplayName { get; }
+
+        public string 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 ModifierKeys 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(string internalName, string displayName, string description)
+        {
+            InternalName = internalName;
+            DisplayName = displayName;
+            Description = description;
+        }
+
+        public KeyCombination GetShortcut() => new() { Key = Key, Modifiers = Modifiers };
+    }
+}

+ 17 - 0
PixiEditor/Models/Commands/Attributes/Commands/DebugAttribute.cs

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

+ 22 - 0
PixiEditor/Models/Commands/Attributes/Commands/GroupAttribute.cs

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

+ 27 - 0
PixiEditor/Models/Commands/Attributes/Commands/InternalAttribute.cs

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

+ 16 - 0
PixiEditor/Models/Commands/Attributes/Commands/ToolAttribute.cs

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

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

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

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

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

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

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

+ 84 - 0
PixiEditor/Models/Commands/CommandCollection.cs

@@ -0,0 +1,84 @@
+using PixiEditor.Models.DataHolders;
+using System.Collections;
+using System.Diagnostics;
+using System.Windows.Input;
+using OneOf.Types;
+
+namespace PixiEditor.Models.Commands
+{
+    [DebuggerDisplay("Count = {Count}")]
+    public 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 IEnumerable<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: ModifierKeys.None })
+                return;
+            _commandShortcuts.AddRange(KeyCombination.None, _commandShortcuts[shortcut]);
+            _commandShortcuts.Clear(shortcut);      
+        }
+
+        public IEnumerable<KeyValuePair<KeyCombination, IEnumerable<Command>>> GetShortcuts() =>
+            _commandShortcuts;
+
+        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+    }
+}

+ 358 - 0
PixiEditor/Models/Commands/CommandController.cs

@@ -0,0 +1,358 @@
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Commands.Evaluators;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Tools;
+using System.IO;
+using System.Reflection;
+using System.Windows.Media;
+using CommandAttribute = PixiEditor.Models.Commands.Attributes.Command;
+
+namespace PixiEditor.Models.Commands
+{
+    public class CommandController
+    {
+        private readonly 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 Dictionary<string, CanExecuteEvaluator> CanExecuteEvaluators { get; }
+
+        public Dictionary<string, IconEvaluator> IconEvaluators { get; }
+
+        public CommandController(IServiceProvider services)
+        {
+            Current ??= this;
+
+            ShortcutsPath = Path.Join(
+                Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+                "PixiEditor",
+                "shortcuts.json");
+
+            shortcutFile = new(ShortcutsPath, this);
+
+            Commands = new();
+            CommandGroups = new();
+            CanExecuteEvaluators = new();
+            IconEvaluators = new();
+        }
+
+        public void Import(IEnumerable<KeyValuePair<KeyCombination, IEnumerable<string>>> shortcuts, bool save = true)
+        {
+            foreach (var shortcut in shortcuts)
+            {
+                foreach (var command in shortcut.Value)
+                {
+                    ReplaceShortcut(Commands[command], shortcut.Key);
+                }
+            }
+
+            if (save)
+            {
+                shortcutFile.SaveShortcuts();
+            }
+        }
+
+        private static List<(string internalName, string displayName)> FindCommandGroups(Type[] typesToSearchForAttributes)
+        {
+            List<(string internalName, string 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)
+        {
+            KeyValuePair<KeyCombination, IEnumerable<string>>[] shortcuts = shortcutFile.LoadShortcuts()?.ToArray()
+                ?? Array.Empty<KeyValuePair<KeyCombination, IEnumerable<string>>>();
+
+            Type[] allTypesInPixiEditorAssembly = typeof(CommandController).Assembly.GetTypes();
+
+            List<(string internalName, string displayName)> commandGroupsData = FindCommandGroups(allTypesInPixiEditorAssembly);
+            OneToManyDictionary<string, Command> commands = new(); // internal name of the corr. group -> command in that group
+
+            // Find evaluators
+            ForEachMethod(allTypesInPixiEditorAssembly, serviceProvider, (methodInfo, maybeServiceInstance) =>
+            {
+                var evaluatorAttrs = methodInfo.GetCustomAttributes<Evaluator.EvaluatorAttribute>();
+                foreach (var attribute in evaluatorAttrs)
+                {
+                    switch (attribute)
+                    {
+                        case Evaluator.CanExecuteAttribute canExecuteAttribute:
+                            {
+                                var getRequiredEvaluatorsObjectsOfCurrentEvaluator =
+                                    (CommandController controller) =>
+                                        canExecuteAttribute.NamesOfRequiredCanExecuteEvaluators.Select(x => controller.CanExecuteEvaluators[x]);
+
+                                AddEvaluatorFactory<Evaluator.CanExecuteAttribute, CanExecuteEvaluator, bool>(
+                                    methodInfo,
+                                    maybeServiceInstance,
+                                    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, ImageSource>(methodInfo, maybeServiceInstance, icon, IconEvaluators);
+                            break;
+                    }
+                }
+            });
+
+            // Find basic commands
+            ForEachMethod(allTypesInPixiEditorAssembly, serviceProvider, (methodInfo, maybeServiceInstance) =>
+            {
+                var commandAttrs = methodInfo.GetCustomAttributes<CommandAttribute.CommandAttribute>();
+
+                foreach (var attribute in commandAttrs)
+                {
+                    if (attribute is CommandAttribute.BasicAttribute basic)
+                    {
+                        AddCommand(methodInfo, maybeServiceInstance, 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()),
+                            Parameter = basic.Parameter,
+                        });
+                    }
+                }
+            });
+
+            // Find tool commands
+            foreach (var type in allTypesInPixiEditorAssembly)
+            {
+                if (!type.IsAssignableTo(typeof(Tool)))
+                    continue;
+
+                var toolAttr = type.GetCustomAttribute<CommandAttribute.ToolAttribute>();
+                if (toolAttr is null)
+                    continue;
+
+                Tool toolInstance = serviceProvider.GetServices<Tool>().First(x => x.GetType() == type);
+                string internalName = $"PixiEditor.Tools.Select.{type.Name}";
+
+                var command = new Command.ToolCommand()
+                {
+                    InternalName = internalName,
+                    DisplayName = $"Select {toolInstance.DisplayName} Tool",
+                    Description = $"Select {toolInstance.DisplayName} Tool",
+                    IconPath = $"@{toolInstance.ImagePath}",
+                    IconEvaluator = IconEvaluator.Default,
+                    TransientKey = toolAttr.Transient,
+                    DefaultShortcut = toolAttr.GetShortcut(),
+                    Shortcut = GetShortcut(internalName, toolAttr.GetShortcut()),
+                    ToolType = type,
+                };
+
+                Commands.Add(command);
+                AddCommandToCommandsCollection(command);
+            }
+
+            // save all commands into CommandGroups
+            foreach (var (groupInternalName, storedCommands) in commands)
+            {
+                var groupData = commandGroupsData.Where(group => group.internalName == groupInternalName).FirstOrDefault();
+                string groupDisplayName;
+                if (groupData == default)
+                    groupDisplayName = "Misc";
+                else
+                    groupDisplayName = groupData.displayName;
+                CommandGroups.Add(new(groupDisplayName, storedCommands));
+            }
+
+            KeyCombination GetShortcut(string internalName, KeyCombination defaultShortcut)
+                => shortcuts.FirstOrDefault(x => x.Value.Contains(internalName), new(defaultShortcut, null)).Key;
+
+            void AddCommandToCommandsCollection(Command command)
+            {
+                (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);
+            }
+
+            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 });
+
+            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);
+            }
+
+            object CastParameter(object input, Type target)
+            {
+                if (target == typeof(object) || target == input?.GetType())
+                    return input;
+                return Convert.ChangeType(input, target);
+            }
+
+            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();
+
+                Action<object> action;
+
+                if (parameters == null || parameters.Length != 1)
+                {
+                    action = x => method.Invoke(instance, null);
+                }
+                else
+                {
+                    action = x => method.Invoke(instance, new[] { x });
+                }
+
+                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);
+
+                return command;
+            }
+        }
+
+        /// <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();
+        }
+    }
+}

+ 55 - 0
PixiEditor/Models/Commands/CommandGroup.cs

@@ -0,0 +1,55 @@
+using PixiEditor.Models.DataHolders;
+using System.Collections;
+using System.Windows.Input;
+
+namespace PixiEditor.Models.Commands
+{
+    public class CommandGroup : IEnumerable<Command>
+    {
+        private readonly Command[] commands;
+        private readonly Command[] visibleCommands;
+
+        public string DisplayName { get; set; }
+
+        public bool HasAssignedShortcuts { get; set; }
+
+        public IEnumerable<Command> Commands => commands;
+
+        public IEnumerable<Command> VisibleCommands => visibleCommands;
+
+        public CommandGroup(string displayName, IEnumerable<Command> commands)
+        {
+            DisplayName = displayName;
+            this.commands = commands.ToArray();
+            visibleCommands = commands.Where(x => !string.IsNullOrEmpty(x.DisplayName)).ToArray();
+
+            foreach (var command in commands)
+            {
+                HasAssignedShortcuts |= command.Shortcut.Key != Key.None;
+                command.ShortcutChanged += Command_ShortcutChanged;
+            }
+        }
+
+        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();
+
+        IEnumerator IEnumerable.GetEnumerator() => Commands.GetEnumerator();
+    }
+}

+ 27 - 0
PixiEditor/Models/Commands/CommandMethods.cs

@@ -0,0 +1,27 @@
+using PixiEditor.Models.Commands.Evaluators;
+
+namespace PixiEditor.Models.Commands;
+
+public 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);
+}

+ 16 - 0
PixiEditor/Models/Commands/Commands/BasicCommand.cs

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

+ 57 - 0
PixiEditor/Models/Commands/Commands/Command.cs

@@ -0,0 +1,57 @@
+using PixiEditor.Helpers;
+using PixiEditor.Models.Commands.Evaluators;
+using PixiEditor.Models.DataHolders;
+using System.Diagnostics;
+using System.Windows.Media;
+
+namespace PixiEditor.Models.Commands
+{
+    [DebuggerDisplay("{InternalName,nq} ('{DisplayName,nq}')")]
+    public abstract partial class Command : NotifyableObject
+    {
+        private KeyCombination _shortcut;
+
+        public bool IsDebug { get; init; }
+
+        public string InternalName { get; init; }
+
+        public string IconPath { get; init; }
+
+        public IconEvaluator IconEvaluator { get; init; }
+
+        public string DisplayName { get; init; }
+
+        public string Description { get; init; }
+
+        public CommandMethods Methods { get; init; }
+
+        public KeyCombination DefaultShortcut { get; init; }
+
+        public KeyCombination Shortcut
+        {
+            get => _shortcut;
+            set
+            {
+                if (SetProperty(ref _shortcut, value, out var oldValue))
+                {
+                    ShortcutChanged?.Invoke(this, new(oldValue, value));
+                }
+            }
+        }
+
+        public event ShortcutChangedEventHandler ShortcutChanged;
+
+        protected abstract object GetParameter();
+
+        protected Command(Action<object> onExecute, CanExecuteEvaluator canExecute) =>
+            Methods = new(this, onExecute, canExecute);
+
+        public void Execute() => Methods.Execute(GetParameter());
+
+        public bool CanExecute() => Methods.CanExecute(GetParameter());
+
+        public ImageSource GetIcon() => IconEvaluator.CallEvaluate(this, GetParameter());
+
+        public delegate void ShortcutChangedEventHandler(Command command, ShortcutChangedEventArgs args);
+    }
+}

+ 19 - 0
PixiEditor/Models/Commands/Commands/ToolCommand.cs

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

+ 21 - 0
PixiEditor/Models/Commands/Evaluators/CanExecuteEvaluator.cs

@@ -0,0 +1,21 @@
+namespace PixiEditor.Models.Commands.Evaluators
+{
+    public 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
PixiEditor/Models/Commands/Evaluators/Evaluator.cs

@@ -0,0 +1,17 @@
+using System.Diagnostics;
+
+namespace PixiEditor.Models.Commands.Evaluators
+{
+    [DebuggerDisplay("{Name,nq}")]
+    public 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);
+    }
+}

+ 78 - 0
PixiEditor/Models/Commands/Evaluators/IconEvaluator.cs

@@ -0,0 +1,78 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Reflection;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace PixiEditor.Models.Commands.Evaluators
+{
+    public class IconEvaluator : Evaluator<ImageSource>
+    {
+        public static IconEvaluator Default { get; } = new CommandNameEvaluator();
+
+        public override ImageSource CallEvaluate(Command command, object parameter) =>
+            base.CallEvaluate(command, parameter ?? command);
+
+        [DebuggerDisplay("IconEvaluator.Default")]
+        private class CommandNameEvaluator : IconEvaluator
+        {
+            public static string[] resources = GetResourceNames();
+
+            public static Dictionary<string, BitmapImage> images = new();
+
+            public override ImageSource CallEvaluate(Command command, object parameter)
+            {
+                string path;
+
+                if (command.IconPath != null)
+                {
+                    if (command.IconPath.StartsWith('@'))
+                    {
+                        path = command.IconPath[1..];
+                    }
+                    else
+                    {
+                        path = $"Images/{command.IconPath}";
+                    }
+                }
+                else
+                {
+                    path = $"Images/Commands/{command.InternalName.Replace('.', '/')}.png";
+                }
+
+                path = path.ToLower();
+
+                if (path.StartsWith("/"))
+                {
+                    path = path[1..];
+                }
+
+                if (resources.Contains(path))
+                {
+                    var image = images.GetValueOrDefault(path);
+
+                    if (image == null)
+                    {
+                        image = new BitmapImage(new($"pack://application:,,,/{path}"));
+                        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
PixiEditor/Models/Commands/Exceptions/CommandNotFoundException.cs

@@ -0,0 +1,15 @@
+namespace PixiEditor.Models.Commands.Exceptions;
+
+[Serializable]
+public 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) { }
+}

+ 58 - 0
PixiEditor/Models/Commands/Search/ColorSearchResult.cs

@@ -0,0 +1,58 @@
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.ViewModels;
+using SkiaSharp;
+using System.Windows.Media;
+
+namespace PixiEditor.Models.Commands.Search
+{
+    public class ColorSearchResult : SearchResult
+    {
+        private readonly DrawingImage icon;
+        private readonly SKColor color;
+        private string text;
+        private bool requiresDocument;
+        private readonly Action<SKColor> target;
+
+        public override string Text => text;
+
+        public override string Description => $"{color} rgba({color.Red}, {color.Green}, {color.Blue}, {color.Alpha})";
+
+        public override bool CanExecute => !requiresDocument || (requiresDocument && ViewModelMain.Current.BitmapManager.ActiveDocument != null);
+
+        public override ImageSource Icon => icon;
+
+        public override void Execute() => target(color);
+
+        private ColorSearchResult(SKColor color, Action<SKColor> target)
+        {
+            this.color = color;
+            icon = GetIcon(color);
+            this.target = target;
+        }
+        
+        public ColorSearchResult(SKColor color) : this(color, x => ViewModelMain.Current.ColorsSubViewModel.PrimaryColor = x)
+        {
+            text = $"Set color to {color}";
+        }
+
+        public static ColorSearchResult PastePalette(SKColor color, string searchTerm = null)
+        {
+            var result = new ColorSearchResult(color, x => ViewModelMain.Current.BitmapManager.ActiveDocument.Palette.Add(x))
+            {
+                SearchTerm = searchTerm
+            };
+            result.text = $"Add color {color} to palette";
+            result.requiresDocument = true;
+            
+            return result;
+        }
+
+        public static DrawingImage GetIcon(SKColor color)
+        {
+            var drawing = new GeometryDrawing() { Brush = new SolidColorBrush(color.ToOpaqueColor()), Pen = new(Brushes.White, 1) };
+            var geometry = new EllipseGeometry(new(5, 5), 5, 5) { };
+            drawing.Geometry = geometry;
+            return new DrawingImage(drawing);
+        }
+    }
+}

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

@@ -0,0 +1,22 @@
+using PixiEditor.Models.DataHolders;
+using System.Windows.Media;
+
+namespace PixiEditor.Models.Commands.Search
+{
+    public 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();
+    }
+}

+ 36 - 0
PixiEditor/Models/Commands/Search/FileSearchResult.cs

@@ -0,0 +1,36 @@
+using PixiEditor.Helpers.Converters;
+using System.IO;
+using System.Windows;
+using System.Windows.Media;
+
+namespace PixiEditor.Models.Commands.Search
+{
+    public class FileSearchResult : SearchResult
+    {
+        private readonly DrawingImage icon;
+
+        public string FilePath { get; }
+
+        public override string Text => $"...\\{Path.GetFileName(FilePath)}";
+
+        public override string Description => FilePath;
+
+        public override bool CanExecute => true;
+
+        public override ImageSource Icon => icon;
+
+        public FileSearchResult(string path)
+        {
+            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);
+        }
+
+        public override void Execute()
+        {
+            CommandController.Current.Commands["PixiEditor.File.OpenRecent"].Methods.Execute(FilePath);
+        }
+    }
+}

+ 75 - 0
PixiEditor/Models/Commands/Search/SearchResult.cs

@@ -0,0 +1,75 @@
+using PixiEditor.Helpers;
+using PixiEditor.Models.DataHolders;
+using System.Text.RegularExpressions;
+using System.Windows.Documents;
+using System.Windows.Media;
+
+namespace PixiEditor.Models.Commands.Search
+{
+    public abstract class SearchResult : NotifyableObject
+    {
+        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 string Description { get; }
+
+        public abstract bool CanExecute { get; }
+
+        public abstract ImageSource Icon { get; }
+
+        public bool IsSelected
+        {
+            get => isSelected;
+            set => SetProperty(ref isSelected, value);
+        }
+
+        public bool IsMouseSelected
+        {
+            get => isMouseSelected;
+            set => SetProperty(ref isMouseSelected, value);
+        }
+
+
+        public abstract void Execute();
+
+        public virtual KeyCombination Shortcut { get; }
+
+        public RelayCommand ExecuteCommand { get; }
+
+        public SearchResult()
+        {
+            ExecuteCommand = new(_ => Execute(), _ => CanExecute);
+        }
+
+        private IEnumerable<Inline> GetInlines()
+        {
+            if (Match == null)
+            {
+                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))
+                {
+                    yield return new Bold(run);
+                }
+                else
+                {
+                    yield return run;
+                }
+            }
+        }
+    }
+}

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

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

+ 45 - 0
PixiEditor/Models/Commands/ShortcutFile.cs

@@ -0,0 +1,45 @@
+using Newtonsoft.Json;
+using PixiEditor.Models.DataHolders;
+using System.IO;
+
+namespace PixiEditor.Models.Commands
+{
+    public 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()
+        {
+            OneToManyDictionary<KeyCombination, string> shortcuts = new();
+
+            foreach (var shortcut in _commands.Commands.GetShortcuts())
+            {
+                foreach (var command in shortcut.Value.Where(x => x.Shortcut != x.DefaultShortcut))
+                {
+                    shortcuts.Add(shortcut.Key, command.InternalName);
+                }
+            }
+
+            File.WriteAllText(Path, JsonConvert.SerializeObject(shortcuts));
+        }
+
+        public IEnumerable<KeyValuePair<KeyCombination, IEnumerable<string>>> LoadShortcuts() => LoadShortcuts(Path);
+
+        public static IEnumerable<KeyValuePair<KeyCombination, IEnumerable<string>>> LoadShortcuts(string path) =>
+            JsonConvert.DeserializeObject<IEnumerable<KeyValuePair<KeyCombination, IEnumerable<string>>>>(File.ReadAllText(path));
+    }
+}

+ 6 - 0
PixiEditor/Models/Commands/Templates/IShortcutDefaults.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.Commands.Templates;
+
+public interface IShortcutDefaults
+{
+    ShortcutCollection DefaultShortcuts { get; }
+}

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

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

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

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

+ 28 - 0
PixiEditor/Models/Commands/Templates/Providers/AsepriteProvider.cs

@@ -0,0 +1,28 @@
+using System.Windows.Input;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+public partial class ShortcutProvider
+{
+    public static AsepriteProvider Aseprite { get; } = new();
+    
+    public class AsepriteProvider : ShortcutProvider, IShortcutDefaults
+    {
+        public AsepriteProvider() : base("Aseprite")
+        {
+        }
+
+        public ShortcutCollection DefaultShortcuts { get; } = new()
+        {
+            { "PixiEditor.File.SaveAsNew", Key.S, ModifierKeys.Control | ModifierKeys.Alt },
+            { "PixiEditor.Window.OpenSettingsWindow", Key.K, ModifierKeys.Control },
+            // Tools
+            { "PixiEditor.Tools.Select.CircleTool", Key.U, ModifierKeys.Shift },
+            { "PixiEditor.Tools.Select.ColorPickerTool", Key.I, ModifierKeys.None },
+            { "PixiEditor.Tools.Select.RectangleTool", Key.U, ModifierKeys.None },
+            { "PixiEditor.Tools.Select.SelectTool", Key.V, ModifierKeys.None },
+            // Not actually in aseprite, but should be included
+            { "PixiEditor.Search.Toggle", Key.OemComma, ModifierKeys.Control }
+        };
+    }
+}

+ 38 - 0
PixiEditor/Models/Commands/Templates/Providers/DebugProvider.cs

@@ -0,0 +1,38 @@
+using System.IO;
+using System.Windows.Input;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+public partial class ShortcutProvider
+{
+    private static DebugProvider Debug { get; } = new();
+    
+    public 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 ShortcutCollection DefaultShortcuts { get; } = new()
+        {
+            // Add shortcuts for undo and redo
+            { "PixiEditor.Undo.Undo", Key.Z, ModifierKeys.Control },
+            { "PixiEditor.Undo.Redo", Key.Y, ModifierKeys.Control },
+            "PixiEditor.Colors.Swap"
+        };
+        
+        public string Filter => "json (*.json)|*.json";
+
+        public ShortcutCollection GetShortcuts(string path) => new(ShortcutFile.LoadShortcuts(path));
+
+        public bool InstallationPresent => File.Exists(InstallationPath);
+        
+        public ShortcutCollection GetInstalledShortcuts() => new(ShortcutFile.LoadShortcuts(InstallationPath));
+    }
+}

+ 25 - 0
PixiEditor/Models/Commands/Templates/ShortcutCollection.cs

@@ -0,0 +1,25 @@
+using System.Windows.Input;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+public class ShortcutCollection : OneToManyDictionary<KeyCombination, string>
+{
+    public ShortcutCollection() {}
+    
+    public ShortcutCollection(IEnumerable<KeyValuePair<KeyCombination, IEnumerable<string>>> enumerable) : base(enumerable) 
+    { }
+    
+    public void Add(string commandName, Key key, ModifierKeys modifiers)
+    {
+        Add(new(key, modifiers), commandName);
+    }
+    
+    /// <summary>
+    /// Unassigns a shortcut.
+    /// </summary>
+    public void Add(string commandName)
+    {
+        Add(KeyCombination.None, commandName);
+    }
+}

+ 42 - 0
PixiEditor/Models/Commands/Templates/ShortcutProvider.cs

@@ -0,0 +1,42 @@
+using System.Collections;
+using System.Windows.Input;
+using PixiEditor.Models.DataHolders;
+
+namespace PixiEditor.Models.Commands.Templates;
+
+public partial class ShortcutProvider
+{
+    public string Name { 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 IEnumerable<ShortcutProvider> GetProviders() => new ShortcutProvider[]
+    {
+        #if DEBUG
+        Debug,
+        #endif
+        Aseprite
+    };
+}

+ 83 - 0
PixiEditor/Models/Commands/XAML/Command.cs

@@ -0,0 +1,83 @@
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Helpers;
+using PixiEditor.ViewModels;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Markup;
+
+namespace PixiEditor.Models.Commands.XAML
+{
+    public 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 (commandController == null)
+            {
+                commandController = ViewModelMain.Current.CommandController;
+            }
+
+            if (Windows.ApplicationModel.DesignMode.DesignModeEnabled)
+            {
+                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);
+            }
+
+            var command = commandController.Commands[Name];
+            return GetPixiCommand ? command : GetICommand(command, UseProvided);
+        }
+
+        public static ICommand GetICommand(Commands.Command command, bool useProvidedParameter) => new ProvidedICommand()
+        {
+            Command = command,
+            UseProvidedParameter = useProvidedParameter,
+        };
+
+        class ProvidedICommand : ICommand
+        {
+            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();
+                }
+            }
+        }
+    }
+}

+ 48 - 0
PixiEditor/Models/Commands/XAML/ContextMenu.cs

@@ -0,0 +1,48 @@
+using PixiEditor.Helpers;
+using PixiEditor.Models.DataHolders;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace PixiEditor.Models.Commands.XAML
+{
+    public class ContextMenu : System.Windows.Controls.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));
+        }
+
+        private static void HandleDesignMode(MenuItem item, string name)
+        {
+            var command = DesignCommandHelpers.GetCommandAttribute(name);
+            item.InputGestureText = new KeyCombination(command.Key, command.Modifiers).ToString();
+        }
+    }
+}

+ 48 - 0
PixiEditor/Models/Commands/XAML/Menu.cs

@@ -0,0 +1,48 @@
+using PixiEditor.Helpers;
+using PixiEditor.Models.DataHolders;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace PixiEditor.Models.Commands.XAML
+{
+    public class Menu : System.Windows.Controls.Menu
+    {
+        public static readonly DependencyProperty CommandNameProperty =
+            DependencyProperty.RegisterAttached(
+                "Command",
+                typeof(string),
+                typeof(Menu),
+                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(Menu)}.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));
+        }
+
+        private static void HandleDesignMode(MenuItem item, string name)
+        {
+            var command = DesignCommandHelpers.GetCommandAttribute(name);
+            item.InputGestureText = new KeyCombination(command.Key, command.Modifiers).ToString();
+        }
+    }
+}

+ 47 - 0
PixiEditor/Models/Commands/XAML/ShortcutBinding.cs

@@ -0,0 +1,47 @@
+using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.Helpers;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.ViewModels;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Markup;
+using ActualCommand = PixiEditor.Models.Commands.Command;
+
+namespace PixiEditor.Models.Commands.XAML
+{
+    public class ShortcutBinding : MarkupExtension
+    {
+        private static CommandController commandController;
+
+        public string Name { get; set; }
+
+        public ShortcutBinding() { }
+
+        public ShortcutBinding(string name) => Name = name;
+
+        public override object ProvideValue(IServiceProvider serviceProvider)
+        {
+            if (commandController == null)
+            {
+                commandController = ViewModelMain.Current.CommandController;
+            }
+
+            if (Windows.ApplicationModel.DesignMode.DesignModeEnabled)
+            {
+                var attribute = DesignCommandHelpers.GetCommandAttribute(Name);
+                return new KeyCombination(attribute.Key, attribute.Modifiers).ToString();
+            }
+
+            return GetBinding(commandController.Commands[Name]).ProvideValue(serviceProvider);
+        }
+
+        public static Binding GetBinding(ActualCommand command) => new Binding
+        {
+            Source = command,
+            Path = new("Shortcut"),
+            Mode = BindingMode.OneWay,
+            StringFormat = ""
+        };
+    }
+}

+ 4 - 0
PixiEditor/Models/Controllers/BitmapManager.cs

@@ -11,6 +11,7 @@ using System;
 using System.Collections.ObjectModel;
 using System.Collections.ObjectModel;
 using System.Diagnostics;
 using System.Diagnostics;
 using System.Windows;
 using System.Windows;
+using PixiEditor.Models.Commands.Attributes;
 
 
 namespace PixiEditor.Models.Controllers
 namespace PixiEditor.Models.Controllers
 {
 {
@@ -110,6 +111,9 @@ namespace PixiEditor.Models.Controllers
             _highlightColor = new SKColor(0, 0, 0, 77);
             _highlightColor = new SKColor(0, 0, 0, 77);
         }
         }
 
 
+        [Evaluator.CanExecute("PixiEditor.HasDocument")]
+        public bool DocumentNotNull() => ActiveDocument != null;
+
         public void CloseDocument(Document document)
         public void CloseDocument(Document document)
         {
         {
             int nextIndex = 0;
             int nextIndex = 0;

+ 67 - 0
PixiEditor/Models/Controllers/ShortcutController.cs

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

+ 0 - 46
PixiEditor/Models/Controllers/Shortcuts/Shortcut.cs

@@ -1,46 +0,0 @@
-using System.Linq;
-using System.Windows.Input;
-using PixiEditor.Helpers.Extensions;
-
-namespace PixiEditor.Models.Controllers.Shortcuts
-{
-    public class Shortcut
-    {
-        public Shortcut(Key shortcutKey, ICommand command, object commandParameter = null, ModifierKeys modifier = ModifierKeys.None)
-        {
-            ShortcutKey = shortcutKey;
-            Modifier = modifier;
-            Command = command;
-            CommandParameter = commandParameter;
-        }
-
-        public Shortcut(Key shortcutKey, ICommand command, string description, object commandParameter = null, ModifierKeys modifier = ModifierKeys.None)
-            : this(shortcutKey, command, commandParameter, modifier)
-        {
-            Description = description;
-        }
-
-        public Key ShortcutKey { get; set; }
-
-        public ModifierKeys Modifier { get; set; }
-
-        /// <summary>
-        /// Gets all <see cref="ModifierKeys"/> as an array.
-        /// </summary>
-        public ModifierKeys[] Modifiers { get => Modifier.GetFlags().Except(new ModifierKeys[] { ModifierKeys.None }).ToArray(); }
-
-        public ICommand Command { get; set; }
-
-        public string Description { get; set; }
-
-        public object CommandParameter { get; set; }
-
-        public void Execute()
-        {
-            if (Command.CanExecute(CommandParameter))
-            {
-                Command.Execute(CommandParameter);
-            }
-        }
-    }
-}

+ 0 - 90
PixiEditor/Models/Controllers/Shortcuts/ShortcutController.cs

@@ -1,90 +0,0 @@
-using PixiEditor.Models.Tools;
-using PixiEditor.Models.Tools.Tools;
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Linq;
-using System.Windows.Documents;
-using System.Windows.Input;
-
-namespace PixiEditor.Models.Controllers.Shortcuts
-{
-    public class ShortcutController
-    {
-        public ShortcutController(params ShortcutGroup[] shortcutGroups)
-        {
-            ShortcutGroups = new ObservableCollection<ShortcutGroup>(shortcutGroups);
-        }
-
-        public static bool ShortcutExecutionBlocked => _shortcutExecutionBlockers.Count > 0;
-
-        private static List<string> _shortcutExecutionBlockers = new List<string>();
-
-        public ObservableCollection<ShortcutGroup> ShortcutGroups { get; init; }
-
-        public Shortcut LastShortcut { get; private set; }
-
-        public Dictionary<Key, Tool> TransientShortcuts { get; set; } = new Dictionary<Key, Tool>();
-
-        public static void BlockShortcutExection(string blocker)
-        {
-            if (_shortcutExecutionBlockers.Contains(blocker)) return;
-            _shortcutExecutionBlockers.Add(blocker);
-        }
-
-        public static void UnblockShortcutExecution(string blocker)
-        {
-            if (!_shortcutExecutionBlockers.Contains(blocker)) return;
-            _shortcutExecutionBlockers.Remove(blocker);
-        }
-
-        public static void UnblockShortcutExecutionAll()
-        {
-            _shortcutExecutionBlockers.Clear();
-        }
-
-        public Shortcut GetToolShortcut<T>()
-        {
-            return GetToolShortcut(typeof(T));
-        }
-
-        public Shortcut GetToolShortcut(Type type)
-        {
-            return ShortcutGroups.SelectMany(x => x.Shortcuts).ToList().Where(i => i.CommandParameter is Type nextType && nextType == type).SingleOrDefault();
-        }
-
-        public Key GetToolShortcutKey<T>()
-        {
-            return GetToolShortcutKey(typeof(T));
-        }
-
-        public Key GetToolShortcutKey(Type type)
-        {
-            var sh = GetToolShortcut(type);
-            return sh != null ? sh.ShortcutKey : Key.None;
-        }
-
-        public void KeyPressed(Key key, ModifierKeys modifiers)
-        {
-            if (!ShortcutExecutionBlocked)
-            {
-                Shortcut[] shortcuts = ShortcutGroups.SelectMany(x => x.Shortcuts).ToList().FindAll(x => x.ShortcutKey == key).ToArray();
-                if (shortcuts.Length < 1)
-                {
-                    return;
-                }
-
-                shortcuts = shortcuts.OrderByDescending(x => x.Modifier).ToArray();
-                for (int i = 0; i < shortcuts.Length; i++)
-                {
-                    if (modifiers.HasFlag(shortcuts[i].Modifier))
-                    {
-                        shortcuts[i].Execute();
-                        LastShortcut = shortcuts[i];
-                        break;
-                    }
-                }
-            }
-        }
-    }
-}

+ 0 - 43
PixiEditor/Models/Controllers/Shortcuts/ShortcutGroup.cs

@@ -1,43 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace PixiEditor.Models.Controllers.Shortcuts
-{
-    public class ShortcutGroup
-    {
-        /// <summary>
-        /// Gets or sets the shortcuts in the shortcuts group.
-        /// </summary>
-        public ObservableCollection<Shortcut> Shortcuts { get; set; }
-
-        /// <summary>
-        /// Gets or sets the name of the shortcut group.
-        /// </summary>
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets a value indicating whether gets or sets the shortcut group visible in the shortcut popup.
-        /// </summary>
-        public bool IsVisible { get; set; }
-
-        public ShortcutGroup(string name, params Shortcut[] shortcuts)
-        {
-            Name = name;
-            Shortcuts = new ObservableCollection<Shortcut>(shortcuts);
-            IsVisible = true;
-        }
-
-        /// <param name="name">The name of the group.</param>
-        /// <param name="shortcuts">The shortcuts that belong in the group.</param>
-        /// <param name="isVisible">Is the group visible in the shortcut popup.</param>
-        public ShortcutGroup(string name, bool isVisible = true, params Shortcut[] shortcuts)
-            : this(name, shortcuts)
-        {
-            IsVisible = isVisible;
-        }
-    }
-}

+ 44 - 0
PixiEditor/Models/DataHolders/KeyCombination.cs

@@ -0,0 +1,44 @@
+using PixiEditor.Helpers;
+using PixiEditor.Helpers.Extensions;
+using System.Diagnostics;
+using System.Globalization;
+using System.Text;
+using System.Windows.Input;
+
+namespace PixiEditor.Models.DataHolders
+{
+    [DebuggerDisplay("{GetDebuggerDisplay(),nq}")]
+    public record struct KeyCombination(Key Key, ModifierKeys Modifiers)
+    {
+        public static KeyCombination None => new(Key.None, ModifierKeys.None);
+
+        public override string ToString() => ToString(CultureInfo.CurrentCulture);
+
+        public string ToString(CultureInfo culture)
+        {
+            StringBuilder builder = new();
+
+            foreach (ModifierKeys modifier in Modifiers.GetFlags().OrderByDescending(x => x != ModifierKeys.Alt))
+            {
+                if (modifier == ModifierKeys.None) continue;
+
+                string key = modifier switch
+                {
+                    ModifierKeys.Control => "Ctrl",
+                    _ => modifier.ToString()
+                };
+
+                builder.Append($"{key}+");
+            }
+
+            if (Key != Key.None)
+            {
+                builder.Append(InputKeyHelpers.GetKeyboardKey(Key, culture));
+            }
+
+            return builder.ToString();
+        }
+
+        private string GetDebuggerDisplay() => ToString(CultureInfo.InvariantCulture);
+    }
+}

+ 158 - 0
PixiEditor/Models/DataHolders/OneToManyDictionary.cs

@@ -0,0 +1,158 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace PixiEditor.Models.DataHolders
+{
+    [DebuggerDisplay("Count = {Count}")]
+    public 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 IEnumerable<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();
+    }
+}

+ 74 - 0
PixiEditor/Models/Dialogs/OptionsDialog.cs

@@ -0,0 +1,74 @@
+using PixiEditor.Views.Dialogs;
+using System.Collections;
+using System.Windows.Controls;
+using System.Windows.Media;
+
+namespace PixiEditor.Models.Dialogs
+{
+    public class OptionsDialog<T> : CustomDialog, IEnumerable<T>
+    {
+        private Dictionary<T, Action<T>> _results = new();
+
+        public string Title { get; set; }
+
+        public object Content { get; set; }
+
+        public T Result { get; private set; }
+
+        public OptionsDialog(string title, object content)
+        {
+            Title = title;
+
+            if (content is not Visual)
+            {
+                Content = new TextBlock()
+                {
+                    Text = content.ToString(),
+                    FontSize = 15,
+                    TextAlignment = System.Windows.TextAlignment.Center,
+                    TextTrimming = System.Windows.TextTrimming.WordEllipsis,
+                    TextWrapping = System.Windows.TextWrapping.WrapWithOverflow,
+                    HorizontalAlignment = System.Windows.HorizontalAlignment.Center,
+                    VerticalAlignment = System.Windows.VerticalAlignment.Center,
+                };
+            }
+            else
+            {
+                Content = content;
+            }
+        }
+
+        public OptionsDialog(string title, object content, IEnumerable<KeyValuePair<T, Action<T>>> options) : this(title, content)
+        {
+            _results = new(options);
+        }
+
+        public Action<T> this[T name]
+        {
+            get => _results[name];
+            set => _results.Add(name, value);
+        }
+
+        public override bool ShowDialog()
+        {
+            var popup = new OptionPopup(Title, Content, new(_results.Keys.Select(x => (object)x)));
+            var popupResult = popup.ShowDialog();
+
+            Result = (T)popup.Result;
+            if (Result != null)
+            {
+                _results[Result]?.Invoke(Result);
+            }
+
+            return popupResult.GetValueOrDefault(false);
+        }
+
+        public void Add(T name) => _results.Add(name, null);
+
+        public void Add(T name, Action<T> action) => _results.Add(name, action);
+
+        public IEnumerator<T> GetEnumerator() => _results.Keys.GetEnumerator();
+
+        IEnumerator IEnumerable.GetEnumerator() => _results.Keys.GetEnumerator();
+    }
+}

+ 32 - 0
PixiEditor/Models/Services/CommandProvider.cs

@@ -0,0 +1,32 @@
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Evaluators;
+using System.Windows.Input;
+using System.Windows.Media;
+using XAMLCommand = PixiEditor.Models.Commands.XAML.Command;
+
+namespace PixiEditor.Models.Services
+{
+    public class CommandProvider
+    {
+        private readonly CommandController _controller;
+
+        public CommandProvider(CommandController controller)
+        {
+            _controller = controller;
+        }
+
+        public Command GetCommand(string name) => _controller.Commands[name];
+
+        public CanExecuteEvaluator GetCanExecute(string name) => _controller.CanExecuteEvaluators[name];
+
+        public bool CanExecute(string name, Command command, object argument) =>
+            _controller.CanExecuteEvaluators[name].CallEvaluate(command, argument);
+
+        public IconEvaluator GetIconEvaluator(string name) => _controller.IconEvaluators[name];
+
+        public ImageSource GetIcon(string name, Command command, object argument) =>
+            _controller.IconEvaluators[name].CallEvaluate(command, argument);
+
+        public ICommand GetICommand(string name, bool useProvidedArgument = false) => XAMLCommand.GetICommand(_controller.Commands[name], useProvidedArgument);
+    }
+}

+ 8 - 0
PixiEditor/Models/Services/DocumentProvider.cs

@@ -32,6 +32,11 @@ namespace PixiEditor.Models.Services
         /// </summary>
         /// </summary>
         public IEnumerable<Layer> GetLayers() => _bitmapManager.ActiveDocument?.Layers;
         public IEnumerable<Layer> GetLayers() => _bitmapManager.ActiveDocument?.Layers;
 
 
+        /// <summary>
+        /// Gets the layer structure of the opened document
+        /// </summary>
+        public LayerStructure GetStructure() => _bitmapManager.ActiveDocument?.LayerStructure;
+
         /// <summary>
         /// <summary>
         /// Gets the active layer
         /// Gets the active layer
         /// </summary>
         /// </summary>
@@ -47,6 +52,9 @@ namespace PixiEditor.Models.Services
         /// </summary>
         /// </summary>
         public Layer GetReferenceLayer() => _bitmapManager.ActiveDocument?.ReferenceLayer;
         public Layer GetReferenceLayer() => _bitmapManager.ActiveDocument?.ReferenceLayer;
 
 
+        /// <summary>
+        /// Gets the reference layer surface of the active document
+        /// </summary>
         public Surface GetReferenceSurface() => _bitmapManager.ActiveDocument?.ReferenceLayer?.LayerBitmap;
         public Surface GetReferenceSurface() => _bitmapManager.ActiveDocument?.ReferenceLayer?.LayerBitmap;
 
 
         /// <summary>
         /// <summary>

+ 3 - 1
PixiEditor/Models/Tools/Tool.cs

@@ -5,12 +5,14 @@ using PixiEditor.Models.Tools.ToolSettings;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using System.Windows.Input;
 using System.Windows.Input;
 using SkiaSharp;
 using SkiaSharp;
+using PixiEditor.Models.DataHolders;
 
 
 namespace PixiEditor.Models.Tools
 namespace PixiEditor.Models.Tools
 {
 {
     public abstract class Tool : NotifyableObject
     public abstract class Tool : NotifyableObject
     {
     {
-        public Key ShortcutKey { get; set; }
+        public KeyCombination Shortcut { get; set; }
+
         public virtual string ToolName => GetType().Name.Replace("Tool", string.Empty);
         public virtual string ToolName => GetType().Name.Replace("Tool", string.Empty);
 
 
         public virtual string DisplayName => ToolName.AddSpacesBeforeUppercaseLetters();
         public virtual string DisplayName => ToolName.AddSpacesBeforeUppercaseLetters();

+ 4 - 3
PixiEditor/Models/Tools/Tools/BrightnessTool.cs

@@ -1,18 +1,19 @@
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Models.Colors;
 using PixiEditor.Models.Colors;
+using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Enums;
 using PixiEditor.Models.Enums;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using SkiaSharp;
 using SkiaSharp;
-using System;
-using System.Collections.Generic;
 using System.Windows;
 using System.Windows;
+using System.Windows.Input;
 
 
 namespace PixiEditor.Models.Tools.Tools
 namespace PixiEditor.Models.Tools.Tools
 {
 {
+    [Command.Tool(Key = Key.U)]
     public class BrightnessTool : BitmapOperationTool
     public class BrightnessTool : BitmapOperationTool
     {
     {
         private const float CorrectionFactor = 5f; // Initial correction factor
         private const float CorrectionFactor = 5f; // Initial correction factor
@@ -28,7 +29,7 @@ namespace PixiEditor.Models.Tools.Tools
             Toolbar = new BrightnessToolToolbar(CorrectionFactor);
             Toolbar = new BrightnessToolToolbar(CorrectionFactor);
         }
         }
 
 
-        public override string Tooltip => $"Makes pixels brighter or darker ({ShortcutKey}). Hold Ctrl to make pixels darker.";
+        public override string Tooltip => $"Makes pixels brighter or darker ({Shortcut}). Hold Ctrl to make pixels darker.";
 
 
         public BrightnessMode Mode { get; set; } = BrightnessMode.Default;
         public BrightnessMode Mode { get; set; } = BrightnessMode.Default;
 
 

+ 4 - 1
PixiEditor/Models/Tools/Tools/CircleTool.cs

@@ -1,4 +1,5 @@
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
+using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
@@ -7,10 +8,12 @@ using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Linq;
 using System.Linq;
 using System.Windows;
 using System.Windows;
+using System.Windows.Input;
 using System.Windows.Media;
 using System.Windows.Media;
 
 
 namespace PixiEditor.Models.Tools.Tools
 namespace PixiEditor.Models.Tools.Tools
 {
 {
+    [Command.Tool(Key = Key.C)]
     public class CircleTool : ShapeTool
     public class CircleTool : ShapeTool
     {
     {
         private string defaultActionDisplay = "Click and move mouse to draw a circle. Hold Shift to draw an even one.";
         private string defaultActionDisplay = "Click and move mouse to draw a circle. Hold Shift to draw an even one.";
@@ -20,7 +23,7 @@ namespace PixiEditor.Models.Tools.Tools
             ActionDisplay = defaultActionDisplay;
             ActionDisplay = defaultActionDisplay;
         }
         }
 
 
-        public override string Tooltip => $"Draws circle on canvas ({ShortcutKey}). Hold Shift to draw even circle.";
+        public override string Tooltip => $"Draws circle on canvas ({Shortcut}). Hold Shift to draw even circle.";
 
 
         public override void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
         public override void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
         {
         {

+ 4 - 3
PixiEditor/Models/Tools/Tools/ColorPickerTool.cs

@@ -1,3 +1,4 @@
+using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.ImageManipulation;
 using PixiEditor.Models.ImageManipulation;
@@ -6,11 +7,11 @@ using PixiEditor.Models.Position;
 using PixiEditor.Models.Services;
 using PixiEditor.Models.Services;
 using PixiEditor.ViewModels;
 using PixiEditor.ViewModels;
 using SkiaSharp;
 using SkiaSharp;
-using System;
-using System.Collections.Generic;
+using System.Windows.Input;
 
 
 namespace PixiEditor.Models.Tools.Tools
 namespace PixiEditor.Models.Tools.Tools
 {
 {
+    [Command.Tool(Key = Key.O, Transient = Key.LeftAlt)]
     internal class ColorPickerTool : ReadonlyTool
     internal class ColorPickerTool : ReadonlyTool
     {
     {
         private readonly DocumentProvider _docProvider;
         private readonly DocumentProvider _docProvider;
@@ -28,7 +29,7 @@ namespace PixiEditor.Models.Tools.Tools
 
 
         public override bool RequiresPreciseMouseData => true;
         public override bool RequiresPreciseMouseData => true;
 
 
-        public override string Tooltip => $"Picks the primary color from the canvas. ({ShortcutKey})";
+        public override string Tooltip => $"Picks the primary color from the canvas. ({Shortcut})";
 
 
         public override void Use(IReadOnlyList<Coordinates> recordedMouseMovement)
         public override void Use(IReadOnlyList<Coordinates> recordedMouseMovement)
         {
         {

+ 5 - 3
PixiEditor/Models/Tools/Tools/EraserTool.cs

@@ -1,13 +1,15 @@
-using PixiEditor.Models.Controllers;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using SkiaSharp;
 using SkiaSharp;
-using System.Collections.Generic;
+using System.Windows.Input;
 
 
 namespace PixiEditor.Models.Tools.Tools
 namespace PixiEditor.Models.Tools.Tools
 {
 {
+    [Command.Tool(Key = Key.E)]
     internal class EraserTool : BitmapOperationTool
     internal class EraserTool : BitmapOperationTool
     {
     {
         private readonly PenTool pen;
         private readonly PenTool pen;
@@ -18,7 +20,7 @@ namespace PixiEditor.Models.Tools.Tools
             Toolbar = new BasicToolbar();
             Toolbar = new BasicToolbar();
             pen = new PenTool(bitmapManager);
             pen = new PenTool(bitmapManager);
         }

         }

-        public override string Tooltip => $"Erasers color from pixel. ({ShortcutKey})";
+        public override string Tooltip => $"Erasers color from pixel. ({Shortcut})";
 
 
         public override void Use(Layer activeLayer, Layer previewLayer, IEnumerable<Layer> allLayers, IReadOnlyList<Coordinates> recordedMouseMovement, SKColor color)
         public override void Use(Layer activeLayer, Layer previewLayer, IEnumerable<Layer> allLayers, IReadOnlyList<Coordinates> recordedMouseMovement, SKColor color)
         {
         {

+ 4 - 2
PixiEditor/Models/Tools/Tools/FloodFillTool.cs

@@ -1,13 +1,15 @@
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Position;
 using SkiaSharp;
 using SkiaSharp;
-using System.Collections.Generic;
 using System.Windows;
 using System.Windows;
+using System.Windows.Input;
 
 
 namespace PixiEditor.Models.Tools.Tools
 namespace PixiEditor.Models.Tools.Tools
 {
 {
+    [Command.Tool(Key = Key.G)]
     internal class FloodFillTool : BitmapOperationTool
     internal class FloodFillTool : BitmapOperationTool
     {
     {
         private BitmapManager BitmapManager { get; }
         private BitmapManager BitmapManager { get; }
@@ -20,7 +22,7 @@ namespace PixiEditor.Models.Tools.Tools
             UseDocumentRectForUndo = true;
             UseDocumentRectForUndo = true;
         }
         }
 
 
-        public override string Tooltip => $"Fills area with color. ({ShortcutKey})";
+        public override string Tooltip => $"Fills area with color. ({Shortcut})";
 
 
         public override void Use(Layer activeLayer, Layer previewLayer, IEnumerable<Layer> allLayers, IReadOnlyList<Coordinates> recordedMouseMovement, SKColor color)
         public override void Use(Layer activeLayer, Layer previewLayer, IEnumerable<Layer> allLayers, IReadOnlyList<Coordinates> recordedMouseMovement, SKColor color)
         {
         {

+ 4 - 3
PixiEditor/Models/Tools/Tools/LineTool.cs

@@ -1,15 +1,16 @@
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
+using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Layers;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Position;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
 using PixiEditor.Models.Tools.ToolSettings.Settings;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using SkiaSharp;
 using SkiaSharp;
-using System;
-using System.Collections.Generic;
 using System.Windows;
 using System.Windows;
+using System.Windows.Input;
 
 
 namespace PixiEditor.Models.Tools.Tools
 namespace PixiEditor.Models.Tools.Tools
 {
 {
+    [Command.Tool(Key = Key.L)]
     public class LineTool : ShapeTool
     public class LineTool : ShapeTool
     {
     {
         private List<Coordinates> linePoints = new List<Coordinates>();
         private List<Coordinates> linePoints = new List<Coordinates>();
@@ -25,7 +26,7 @@ namespace PixiEditor.Models.Tools.Tools
             Toolbar = new BasicToolbar();
             Toolbar = new BasicToolbar();
         }
         }
 
 
-        public override string Tooltip => $"Draws line on canvas ({ShortcutKey}). Hold Shift to draw even line.";
+        public override string Tooltip => $"Draws line on canvas ({Shortcut}). Hold Shift to draw even line.";
 
 
         public override void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
         public override void UpdateActionDisplay(bool ctrlIsDown, bool shiftIsDown, bool altIsDown)
         {
         {

+ 4 - 2
PixiEditor/Models/Tools/Tools/MagicWandTool.cs

@@ -1,5 +1,6 @@
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Commands.Attributes;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.Enums;
 using PixiEditor.Models.Enums;
@@ -9,12 +10,13 @@ using PixiEditor.Models.Position;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using PixiEditor.ViewModels;
 using PixiEditor.ViewModels;
 using SkiaSharp;
 using SkiaSharp;
-using System.Collections.Generic;
 using System.Collections.ObjectModel;
 using System.Collections.ObjectModel;
 using System.Windows;
 using System.Windows;
+using System.Windows.Input;
 
 
 namespace PixiEditor.Models.Tools.Tools
 namespace PixiEditor.Models.Tools.Tools
 {
 {
+    [Command.Tool(Key = Key.W)]
     internal class MagicWandTool : ReadonlyTool, ICachedDocumentTool
     internal class MagicWandTool : ReadonlyTool, ICachedDocumentTool
     {
     {
         private static Selection ActiveSelection { get => ViewModelMain.Current.BitmapManager.ActiveDocument.ActiveSelection; }
         private static Selection ActiveSelection { get => ViewModelMain.Current.BitmapManager.ActiveDocument.ActiveSelection; }
@@ -24,7 +26,7 @@ namespace PixiEditor.Models.Tools.Tools
         private IEnumerable<Coordinates> oldSelection;
         private IEnumerable<Coordinates> oldSelection;
         private List<Coordinates> newSelection = new List<Coordinates>();
         private List<Coordinates> newSelection = new List<Coordinates>();
 
 
-        public override string Tooltip => $"Magic Wand ({ShortcutKey}). Flood's the selection";
+        public override string Tooltip => $"Magic Wand ({Shortcut}). Flood's the selection";
 
 
         private Layer cachedDocument;
         private Layer cachedDocument;
 
 

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