Răsfoiți Sursa

Worked on Search, implemented TransientKeys and added Shortcut Editor

CPKreuz 3 ani în urmă
părinte
comite
04f6cea049
45 a modificat fișierele cu 892 adăugiri și 182 ștergeri
  1. 97 0
      PixiEditor/Helpers/Extensions/EnumerableHelpers.cs
  2. 1 5
      PixiEditor/Helpers/Extensions/ServiceCollectionHelpers.cs
  3. 12 0
      PixiEditor/Helpers/Extensions/SkiaWPFHelpers.cs
  4. 5 1
      PixiEditor/Models/Commands/Attributes/Commands/ToolAttribute.cs
  5. 8 1
      PixiEditor/Models/Commands/CommandCollection.cs
  6. 29 3
      PixiEditor/Models/Commands/CommandController.cs
  7. 3 0
      PixiEditor/Models/Commands/Commands/ToolCommand.cs
  8. 35 0
      PixiEditor/Models/Commands/Search/ColorSearchResult.cs
  9. 0 1
      PixiEditor/Models/Commands/Search/FileSearchResult.cs
  10. 10 3
      PixiEditor/Models/Commands/Search/SearchResult.cs
  11. 27 8
      PixiEditor/Models/Commands/ShortcutFile.cs
  12. 4 1
      PixiEditor/Models/Commands/XAML/Command.cs
  13. 18 41
      PixiEditor/Models/Controllers/Shortcuts/ShortcutController.cs
  14. 4 0
      PixiEditor/Models/DataHolders/EnumerableDictionary.cs
  15. 3 1
      PixiEditor/Models/Tools/Tool.cs
  16. 1 1
      PixiEditor/Models/Tools/Tools/BrightnessTool.cs
  17. 1 1
      PixiEditor/Models/Tools/Tools/CircleTool.cs
  18. 2 2
      PixiEditor/Models/Tools/Tools/ColorPickerTool.cs
  19. 1 1
      PixiEditor/Models/Tools/Tools/EraserTool.cs
  20. 1 1
      PixiEditor/Models/Tools/Tools/FloodFillTool.cs
  21. 1 1
      PixiEditor/Models/Tools/Tools/LineTool.cs
  22. 1 1
      PixiEditor/Models/Tools/Tools/MagicWandTool.cs
  23. 1 1
      PixiEditor/Models/Tools/Tools/MoveTool.cs
  24. 2 2
      PixiEditor/Models/Tools/Tools/MoveViewportTool.cs
  25. 1 1
      PixiEditor/Models/Tools/Tools/PenTool.cs
  26. 1 1
      PixiEditor/Models/Tools/Tools/RectangleTool.cs
  27. 1 1
      PixiEditor/Models/Tools/Tools/SelectTool.cs
  28. 1 1
      PixiEditor/Models/Tools/Tools/ZoomTool.cs
  29. 7 1
      PixiEditor/NotifyableObject.cs
  30. 2 2
      PixiEditor/PixiEditor.csproj
  31. 28 6
      PixiEditor/ViewModels/CommandSearchViewModel.cs
  32. 5 6
      PixiEditor/ViewModels/SettingsWindowViewModel.cs
  33. 33 1
      PixiEditor/ViewModels/SubViewModels/Main/ClipboardViewModel.cs
  34. 19 9
      PixiEditor/ViewModels/SubViewModels/Main/IoViewModel.cs
  35. 36 0
      PixiEditor/ViewModels/SubViewModels/Main/SearchViewModel.cs
  36. 1 1
      PixiEditor/ViewModels/SubViewModels/Main/ToolsViewModel.cs
  37. 1 1
      PixiEditor/ViewModels/SubViewModels/UserPreferences/SettingsGroup.cs
  38. 6 6
      PixiEditor/ViewModels/ViewModelMain.cs
  39. 18 1
      PixiEditor/Views/Dialogs/SettingsWindow.xaml
  40. 3 1
      PixiEditor/Views/MainWindow.xaml
  41. 74 64
      PixiEditor/Views/UserControls/CommandSearchControl.xaml
  42. 97 3
      PixiEditor/Views/UserControls/CommandSearchControl.xaml.cs
  43. 63 0
      PixiEditor/Views/UserControls/KeyCombinationBox.xaml
  44. 160 0
      PixiEditor/Views/UserControls/KeyCombinationBox.xaml.cs
  45. 68 0
      PixiEditor/Views/UserControls/ShortcutBox.cs

+ 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);
+        }
+    }
+}

+ 1 - 5
PixiEditor/Helpers/Extensions/ServiceCollectionHelpers.cs

@@ -8,11 +8,6 @@ using PixiEditor.Models.Tools.Tools;
 using PixiEditor.Models.UserPreferences;
 using PixiEditor.ViewModels;
 using PixiEditor.ViewModels.SubViewModels.Main;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
 
 namespace PixiEditor.Helpers.Extensions
 {
@@ -41,6 +36,7 @@ namespace PixiEditor.Helpers.Extensions
                 .AddSingleton<MiscViewModel>()
                 .AddSingleton(x => new DiscordViewModel(x.GetService<ViewModelMain>(), "764168193685979138"))
                 .AddSingleton<DebugViewModel>()
+                .AddSingleton<SearchViewModel>()
                 // Controllers
                 .AddSingleton<ShortcutController>()
                 .AddSingleton<CommandController>()

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

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

+ 5 - 1
PixiEditor/Models/Commands/Attributes/Commands/ToolAttribute.cs

@@ -1,10 +1,14 @@
-namespace PixiEditor.Models.Commands.Attributes;
+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)
         {
         }

+ 8 - 1
PixiEditor/Models/Commands/CommandCollection.cs

@@ -38,7 +38,7 @@ namespace PixiEditor.Models.Commands
 
         public bool Contains(Command item) => _commandNames.ContainsKey(item.Name);
 
-        public void CopyTo(Command[] array, int arrayIndex) => throw new NotImplementedException();
+        public void CopyTo(Command[] array, int arrayIndex) => _commandNames.Values.CopyTo(array, arrayIndex);
 
         public IEnumerator<Command> GetEnumerator() => _commandNames.Values.GetEnumerator();
 
@@ -52,6 +52,13 @@ namespace PixiEditor.Models.Commands
             return anyRemoved;
         }
 
+        public void AddShortcut(Command command, KeyCombination shortcut) => _commandShortcuts.Add(shortcut, command);
+
+        public void RemoveShortcut(Command command, KeyCombination shortcut) => _commandShortcuts.Remove(shortcut, command);
+
+        public IEnumerable<KeyValuePair<KeyCombination, IEnumerable<Command>>> GetShortcuts() =>
+            _commandShortcuts;
+
         IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
     }
 }

+ 29 - 3
PixiEditor/Models/Commands/CommandController.cs

@@ -1,7 +1,9 @@
 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;
@@ -10,6 +12,8 @@ namespace PixiEditor.Models.Commands
 {
     public class CommandController
     {
+        private readonly ShortcutFile shortcutFile;
+
         public static CommandController Current { get; private set; }
 
         public CommandCollection Commands { get; set; }
@@ -24,6 +28,13 @@ namespace PixiEditor.Models.Commands
         {
             Current ??= this;
 
+            shortcutFile =
+                new(Path.Join(
+                        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+                        "PixiEditor",
+                        "shortcuts.json"),
+                    this);
+
             Commands = new();
             FactoryEvaluators = new();
             CanExecuteEvaluators = new();
@@ -34,6 +45,9 @@ namespace PixiEditor.Models.Commands
 
         public void Init(IServiceProvider services)
         {
+            KeyValuePair<KeyCombination, IEnumerable<string>>[] shortcuts = shortcutFile.GetShortcuts()?.ToArray()
+                ?? Array.Empty<KeyValuePair<KeyCombination, IEnumerable<string>>>();
+
             var types = typeof(CommandController).Assembly.GetTypes();
 
             foreach (var type in types)
@@ -96,8 +110,8 @@ namespace PixiEditor.Models.Commands
                                 IconPath = attribute.Icon,
                                 IconEvaluator = xIcon,
                                 DefaultShortcut = attribute.GetShortcut(),
+                                Shortcut = GetShortcut(name, attribute.GetShortcut()),
                                 Parameter = basic.Parameter,
-                                Shortcut = attribute.GetShortcut(),
                             });
                         }
                     }
@@ -110,22 +124,26 @@ namespace PixiEditor.Models.Commands
                     if (toolAttr != null)
                     {
                         var tool = services.GetServices<Tool>().First(x => x.GetType() == type);
+                        string name = $"PixiEditor.Tools.Select.{type.Name}";
 
                         Commands.Add(new Command.ToolCommand()
                         {
-                            Name = $"PixiEditor.Tools.Select.{type.Name}",
+                            Name = name,
                             Display = $"Select {tool.DisplayName} Tool",
                             Description = $"Select {tool.DisplayName} Tool",
                             IconPath = $"@{tool.ImagePath}",
                             IconEvaluator = IconEvaluator.Default,
+                            TransientKey = toolAttr.Transient,
                             DefaultShortcut = toolAttr.GetShortcut(),
+                            Shortcut = GetShortcut(name, toolAttr.GetShortcut()),
                             ToolType = type,
-                            Shortcut = toolAttr.GetShortcut(),
                         });
                     }
                 }
             }
 
+            KeyCombination GetShortcut(string name, KeyCombination defaultShortcut) => shortcuts.FirstOrDefault(x => x.Value.Contains(name), new(defaultShortcut, null)).Key;
+
             void AddEvaluator<TAttr, T, TParameter>(MethodInfo method, object instance, TAttr attribute, IDictionary<string, T> evaluators)
                 where T : Evaluator<TParameter>, new()
                 where TAttr : Evaluator.EvaluatorAttribute
@@ -211,5 +229,13 @@ namespace PixiEditor.Models.Commands
                         attribute.IconEvaluator != null ? IconEvaluators[attribute.IconEvaluator] : IconEvaluator.Default));
             }
         }
+
+        public void UpdateShortcut(Command command, KeyCombination newShortcut)
+        {
+            Commands.RemoveShortcut(command, command.Shortcut);
+            Commands.AddShortcut(command, newShortcut);
+            command.Shortcut = newShortcut;
+            shortcutFile.SaveShortcuts();
+        }
     }
 }

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

@@ -1,4 +1,5 @@
 using PixiEditor.ViewModels;
+using System.Windows.Input;
 
 namespace PixiEditor.Models.Commands
 {
@@ -8,6 +9,8 @@ namespace PixiEditor.Models.Commands
         {
             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"]) { }

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

@@ -0,0 +1,35 @@
+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;
+
+        public override string Text => $"Set color to {color}";
+
+        public override bool CanExecute => true;
+
+        public override ImageSource Icon => icon;
+
+        public override void Execute() => ViewModelMain.Current.ColorsSubViewModel.PrimaryColor = color;
+
+        public ColorSearchResult(SKColor color)
+        {
+            this.color = color;
+            icon = GetIcon(color);
+        }
+
+        public static DrawingImage GetIcon(SKColor color)
+        {
+            var drawing = new GeometryDrawing() { Brush = new SolidColorBrush(color.ToColor()), Pen = new(Brushes.White, 1) };
+            var geometry = new EllipseGeometry(new(5, 5), 5, 5) { };
+            drawing.Geometry = geometry;
+            return new DrawingImage(drawing);
+        }
+    }
+}

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

@@ -22,7 +22,6 @@ namespace PixiEditor.Models.Commands.Search
         public FileSearchResult(string path)
         {
             FilePath = path;
-            icon = new();
             var drawing = new GeometryDrawing() { Brush = FileExtensionToColorConverter.GetBrush(FilePath) };
             var geometry = new RectangleGeometry(new(0, 0, 10, 10), 3, 3) { };
             drawing.Geometry = geometry;

+ 10 - 3
PixiEditor/Models/Commands/Search/SearchResult.cs

@@ -1,5 +1,4 @@
-using GalaSoft.MvvmLight;
-using PixiEditor.Helpers;
+using PixiEditor.Helpers;
 using PixiEditor.Models.DataHolders;
 using System.Text.RegularExpressions;
 using System.Windows.Documents;
@@ -7,8 +6,10 @@ using System.Windows.Media;
 
 namespace PixiEditor.Models.Commands.Search
 {
-    public abstract class SearchResult : ObservableObject
+    public abstract class SearchResult : NotifyableObject
     {
+        private bool isSelected;
+
         public string SearchTerm { get; init; }
 
         public virtual Inline[] TextBlockContent => GetInlines().ToArray();
@@ -23,6 +24,12 @@ namespace PixiEditor.Models.Commands.Search
 
         public abstract ImageSource Icon { get; }
 
+        public bool IsSelected
+        {
+            get => isSelected;
+            set => SetProperty(ref isSelected, value);
+        }
+
         public abstract void Execute();
 
         public virtual KeyCombination Shortcut { get; }

+ 27 - 8
PixiEditor/Models/Commands/ShortcutFile.cs

@@ -1,24 +1,43 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows.Input;
+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)
+        public ShortcutFile(string path, CommandController controller)
         {
+            _commands = controller;
             Path = path;
+
+            if (!File.Exists(path))
+            {
+                Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path));
+                File.Create(Path);
+            }
         }
 
-        public void SaveShortcut(string name, Key key, ModifierKeys modifiers)
+        public void SaveShortcuts()
         {
+            EnumerableDictionary<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.Name);
+                }
+            }
+
+            File.WriteAllText(Path, JsonConvert.SerializeObject(shortcuts));
         }
+
+        public IEnumerable<KeyValuePair<KeyCombination, IEnumerable<string>>> GetShortcuts() =>
+            JsonConvert.DeserializeObject<IEnumerable<KeyValuePair<KeyCombination, IEnumerable<string>>>>(File.ReadAllText(Path));
     }
 }

+ 4 - 1
PixiEditor/Models/Commands/XAML/Command.cs

@@ -16,6 +16,8 @@ namespace PixiEditor.Models.Commands.XAML
 
         public bool UseProvided { get; set; }
 
+        public bool GetPixiCommand { get; set; }
+
         public Command() { }
 
         public Command(string name) => Name = name;
@@ -41,7 +43,8 @@ namespace PixiEditor.Models.Commands.XAML
                     }, false);
             }
 
-            return GetICommand(commandController.Commands[Name], UseProvided);
+            var command = commandController.Commands[Name];
+            return GetPixiCommand ? command : GetICommand(command, UseProvided);
         }
 
         public static ICommand GetICommand(Commands.Command command, bool useProvidedParameter) => new ProvidedICommand()

+ 18 - 41
PixiEditor/Models/Controllers/Shortcuts/ShortcutController.cs

@@ -1,30 +1,19 @@
-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 PixiEditor.Models.Commands;
+using PixiEditor.Models.DataHolders;
+using PixiEditor.Models.Tools;
 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; }
+        private static readonly List<string> _shortcutExecutionBlockers = new List<string>();
 
-        public Shortcut LastShortcut { get; private set; }
+        public IEnumerable<Command> LastCommands { get; private set; }
 
-        public Dictionary<Key, Tool> TransientShortcuts { get; set; } = new Dictionary<Key, Tool>();
+        public Dictionary<KeyCombination, Tool> TransientShortcuts { get; set; } = new();
 
         public static void BlockShortcutExection(string blocker)
         {
@@ -43,46 +32,34 @@ namespace PixiEditor.Models.Controllers.Shortcuts
             _shortcutExecutionBlockers.Clear();
         }
 
-        public Shortcut GetToolShortcut<T>()
+        public KeyCombination 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>()
+        public KeyCombination GetToolShortcut(Type type)
         {
-            return GetToolShortcutKey(typeof(T));
-        }
-
-        public Key GetToolShortcutKey(Type type)
-        {
-            var sh = GetToolShortcut(type);
-            return sh != null ? sh.ShortcutKey : Key.None;
+            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)
             {
-                Shortcut[] shortcuts = ShortcutGroups.SelectMany(x => x.Shortcuts).ToList().FindAll(x => x.ShortcutKey == key).ToArray();
-                if (shortcuts.Length < 1)
+                var commands = CommandController.Current.Commands[shortcut];
+
+                if (!commands.Any())
                 {
                     return;
                 }
 
-                shortcuts = shortcuts.OrderByDescending(x => x.Modifier).ToArray();
-                for (int i = 0; i < shortcuts.Length; i++)
+                LastCommands = commands;
+
+                foreach (var command in CommandController.Current.Commands[shortcut])
                 {
-                    if (modifiers.HasFlag(shortcuts[i].Modifier))
-                    {
-                        shortcuts[i].Execute();
-                        LastShortcut = shortcuts[i];
-                        break;
-                    }
+                    command.Execute();
                 }
             }
         }

+ 4 - 0
PixiEditor/Models/DataHolders/EnumerableDictionary.cs

@@ -141,6 +141,10 @@ namespace PixiEditor.Models.DataHolders
             return anyRemoved;
         }
 
+        public bool Remove(TKey key, T item) => _dictionary[key].Remove(item);
+
+        public bool Remove(TKey key) => _dictionary.Remove(key);
+
         IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator();
     }
 }

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

@@ -5,12 +5,14 @@ using PixiEditor.Models.Tools.ToolSettings;
 using PixiEditor.Models.Tools.ToolSettings.Toolbars;
 using System.Windows.Input;
 using SkiaSharp;
+using PixiEditor.Models.DataHolders;
 
 namespace PixiEditor.Models.Tools
 {
     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 DisplayName => ToolName.AddSpacesBeforeUppercaseLetters();

+ 1 - 1
PixiEditor/Models/Tools/Tools/BrightnessTool.cs

@@ -29,7 +29,7 @@ namespace PixiEditor.Models.Tools.Tools
             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;
 

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

@@ -23,7 +23,7 @@ namespace PixiEditor.Models.Tools.Tools
             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)
         {

+ 2 - 2
PixiEditor/Models/Tools/Tools/ColorPickerTool.cs

@@ -11,7 +11,7 @@ using System.Windows.Input;
 
 namespace PixiEditor.Models.Tools.Tools
 {
-    [Command.Tool(Key = Key.O)]
+    [Command.Tool(Key = Key.O, Transient = Key.LeftAlt)]
     internal class ColorPickerTool : ReadonlyTool
     {
         private readonly DocumentProvider _docProvider;
@@ -29,7 +29,7 @@ namespace PixiEditor.Models.Tools.Tools
 
         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)
         {

+ 1 - 1
PixiEditor/Models/Tools/Tools/EraserTool.cs

@@ -20,7 +20,7 @@ namespace PixiEditor.Models.Tools.Tools
             Toolbar = new BasicToolbar();
             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)
         {

+ 1 - 1
PixiEditor/Models/Tools/Tools/FloodFillTool.cs

@@ -22,7 +22,7 @@ namespace PixiEditor.Models.Tools.Tools
             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)
         {

+ 1 - 1
PixiEditor/Models/Tools/Tools/LineTool.cs

@@ -26,7 +26,7 @@ namespace PixiEditor.Models.Tools.Tools
             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)
         {

+ 1 - 1
PixiEditor/Models/Tools/Tools/MagicWandTool.cs

@@ -26,7 +26,7 @@ namespace PixiEditor.Models.Tools.Tools
         private IEnumerable<Coordinates> oldSelection;
         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;
 

+ 1 - 1
PixiEditor/Models/Tools/Tools/MoveTool.cs

@@ -40,7 +40,7 @@ namespace PixiEditor.Models.Tools.Tools
             BitmapManager = bitmapManager;
         }
 
-        public override string Tooltip => $"Moves selected pixels ({ShortcutKey}). Hold Ctrl to move all layers.";
+        public override string Tooltip => $"Moves selected pixels ({Shortcut}). Hold Ctrl to move all layers.";
 
         public override bool HideHighlight => true;
 

+ 2 - 2
PixiEditor/Models/Tools/Tools/MoveViewportTool.cs

@@ -5,7 +5,7 @@ using System.Windows.Input;
 
 namespace PixiEditor.Models.Tools.Tools
 {
-    [Command.Tool(Key = Key.H)]
+    [Command.Tool(Key = Key.H, Transient = Key.Space)]
     public class MoveViewportTool : ReadonlyTool
     {
         public MoveViewportTool()
@@ -15,7 +15,7 @@ namespace PixiEditor.Models.Tools.Tools
         }
 
         public override bool HideHighlight => true;
-        public override string Tooltip => $"Move viewport. ({ShortcutKey})"; 
+        public override string Tooltip => $"Move viewport. ({Shortcut})"; 
 
         public override void Use(IReadOnlyList<Coordinates> pixels)
         {

+ 1 - 1
PixiEditor/Models/Tools/Tools/PenTool.cs

@@ -48,7 +48,7 @@ namespace PixiEditor.Models.Tools.Tools
             };
         }
 
-        public override string Tooltip => $"Standard brush. ({ShortcutKey})";
+        public override string Tooltip => $"Standard brush. ({Shortcut})";
 
         public bool AutomaticallyResizeCanvas { get; set; } = true;
 

+ 1 - 1
PixiEditor/Models/Tools/Tools/RectangleTool.cs

@@ -18,7 +18,7 @@ namespace PixiEditor.Models.Tools.Tools
             ActionDisplay = defaultActionDisplay;
         }
 
-        public override string Tooltip => $"Draws rectangle on canvas ({ShortcutKey}). Hold Shift to draw a square.";
+        public override string Tooltip => $"Draws rectangle on canvas ({Shortcut}). Hold Shift to draw a square.";
 
         public bool Filled { get; set; } = false;
 

+ 1 - 1
PixiEditor/Models/Tools/Tools/SelectTool.cs

@@ -37,7 +37,7 @@ namespace PixiEditor.Models.Tools.Tools
 
         public SelectionType SelectionType { get; set; } = SelectionType.Add;
 
-        public override string Tooltip => $"Selects area. ({ShortcutKey})";
+        public override string Tooltip => $"Selects area. ({Shortcut})";
 
         public override void BeforeUse()
         {

+ 1 - 1
PixiEditor/Models/Tools/Tools/ZoomTool.cs

@@ -19,7 +19,7 @@ namespace PixiEditor.Models.Tools.Tools
 
         public override bool HideHighlight => true;
 
-        public override string Tooltip => $"Zooms viewport ({ShortcutKey}). Click to zoom in, hold alt and click to zoom out.";
+        public override string Tooltip => $"Zooms viewport ({Shortcut}). Click to zoom in, hold alt and click to zoom out.";
 
         public override void OnKeyDown(Key key)
         {

+ 7 - 1
PixiEditor/NotifyableObject.cs

@@ -41,13 +41,19 @@ namespace PixiEditor.Helpers
             }
         }
 
-        protected bool SetProperty<T>(ref T backingStore, T value, [CallerMemberName] string propertyName = "")
+        protected bool SetProperty<T>(ref T backingStore, T value, Action beforeChange = null, [CallerMemberName] string propertyName = "") =>
+            SetProperty(ref backingStore, value, out _, beforeChange, propertyName);
+
+        protected bool SetProperty<T>(ref T backingStore, T value, out T oldValue, Action beforeChange = null, [CallerMemberName] string propertyName = "")
         {
             if (EqualityComparer<T>.Default.Equals(backingStore, value))
             {
+                oldValue = backingStore;
                 return false;
             }
 
+            beforeChange?.Invoke();
+            oldValue = backingStore;
             backingStore = value;
             PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
             return true;

+ 2 - 2
PixiEditor/PixiEditor.csproj

@@ -205,8 +205,8 @@
 		</PackageReference>
 		<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
 		<PackageReference Include="PixiEditor.ColorPicker" Version="3.2.0" />
-		<PackageReference Include="PixiEditor.Parser" Version="2.0.0.1" />
-		<PackageReference Include="PixiEditor.Parser.Skia" Version="2.0.0.1" />
+		<PackageReference Include="PixiEditor.Parser" Version="2.1.0.2" />
+		<PackageReference Include="PixiEditor.Parser.Skia" Version="2.1.0" />
 		<PackageReference Include="SkiaSharp" Version="2.80.3" />
 		<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
 		<PackageReference Include="WriteableBitmapEx">

+ 28 - 6
PixiEditor/ViewModels/CommandSearchViewModel.cs

@@ -1,6 +1,7 @@
 using PixiEditor.Helpers;
 using PixiEditor.Models.Commands;
 using PixiEditor.Models.Commands.Search;
+using SkiaSharp;
 using System.Collections.ObjectModel;
 using System.Text.RegularExpressions;
 
@@ -26,10 +27,23 @@ namespace PixiEditor.ViewModels
         public SearchResult SelectedResult
         {
             get => selectedCommand;
-            set => SetProperty(ref selectedCommand, value);
+            set
+            {
+                if (SetProperty(ref selectedCommand, value, out var oldValue))
+                {
+                    if (oldValue != null)
+                    {
+                        oldValue.IsSelected = false;
+                    }
+                    if (value != null)
+                    {
+                        value.IsSelected = true;
+                    }
+                }
+            }
         }
 
-        public ObservableCollection<SearchResult> Commands { get; } = new();
+        public ObservableCollection<SearchResult> Results { get; } = new();
 
         public CommandSearchViewModel()
         {
@@ -39,28 +53,34 @@ namespace PixiEditor.ViewModels
         private void UpdateSearchResults()
         {
             CommandController controller = CommandController.Current;
-            Commands.Clear();
+            Results.Clear();
 
             if (string.IsNullOrWhiteSpace(SearchTerm))
             {
                 foreach (var file in ViewModelMain.Current.FileSubViewModel.RecentlyOpened)
                 {
-                    Commands.Add(
+                    Results.Add(
                         new FileSearchResult(file.FilePath)
                         {
                             SearchTerm = searchTerm
                         });
                 }
 
+                SelectedResult = Results.FirstOrDefault(x => x.CanExecute);
                 return;
             }
 
+            if (SearchTerm.StartsWith('#'))
+            {
+                Results.Add(new ColorSearchResult(SKColor.Parse(SearchTerm)));
+            }
+
             foreach (var command in controller.Commands
                 .Where(x => x.Description.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase))
                 .OrderByDescending(x => x.Description.Contains($" {SearchTerm} ", StringComparison.OrdinalIgnoreCase))
                 .Take(12))
             {
-                Commands.Add(
+                Results.Add(
                     new CommandSearchResult(command)
                     {
                         SearchTerm = searchTerm,
@@ -70,7 +90,7 @@ namespace PixiEditor.ViewModels
 
             foreach (var file in ViewModelMain.Current.FileSubViewModel.RecentlyOpened.Where(x => x.FilePath.Contains(searchTerm)))
             {
-                Commands.Add(
+                Results.Add(
                     new FileSearchResult(file.FilePath)
                     {
                         SearchTerm = searchTerm,
@@ -79,6 +99,8 @@ namespace PixiEditor.ViewModels
             }
 
             Match Match(string text) => Regex.Match(text, $"(.*)({Regex.Escape(SearchTerm)})(.*)", RegexOptions.IgnoreCase);
+
+            SelectedResult = Results.FirstOrDefault(x => x.CanExecute);
         }
     }
 }

+ 5 - 6
PixiEditor/ViewModels/SettingsWindowViewModel.cs

@@ -1,10 +1,6 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using PixiEditor.Helpers;
+using PixiEditor.Models.Commands;
 using PixiEditor.ViewModels.SubViewModels.UserPreferences;
+using System.Collections.ObjectModel;
 
 namespace PixiEditor.ViewModels
 {
@@ -24,8 +20,11 @@ namespace PixiEditor.ViewModels
 
         public SettingsViewModel SettingsSubViewModel { get; set; }
 
+        public ObservableCollection<Command> Commands { get; }
+
         public SettingsWindowViewModel()
         {
+            Commands = new(CommandController.Current.Commands.Where(x => !string.IsNullOrWhiteSpace(x.Display)));
             SettingsSubViewModel = new SettingsViewModel(this);
         }
     }

+ 33 - 1
PixiEditor/ViewModels/SubViewModels/Main/ClipboardViewModel.cs

@@ -1,6 +1,12 @@
-using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Commands.Attributes;
+using PixiEditor.Models.Commands.Search;
 using PixiEditor.Models.Controllers;
+using SkiaSharp;
+using System.Text.RegularExpressions;
+using System.Windows;
 using System.Windows.Input;
+using System.Windows.Media;
 
 namespace PixiEditor.ViewModels.SubViewModels.Main
 {
@@ -34,6 +40,12 @@ namespace PixiEditor.ViewModels.SubViewModels.Main
             ClipboardController.PasteFromClipboard(Owner.BitmapManager.ActiveDocument);
         }
 
+        [Command.Basic("PixiEditor.Clipboard.PasteColor", "Paste color", "Paste color from clipboard", CanExecute = "PixiEditor.Clipboard.CanPasteColor", IconEvaluator = "PixiEditor.Clipboard.PasteColorIcon")]
+        public void PasteColor()
+        {
+            Owner.ColorsSubViewModel.PrimaryColor = SKColor.Parse(Clipboard.GetText().Trim());
+        }
+
         [Command.Basic("PixiEditor.Clipboard.Copy", "Copy", "Copy to clipboard", CanExecute = "PixiEditor.HasDocument", Key = Key.C, Modifiers = ModifierKeys.Control)]
         public void Copy()
         {
@@ -45,5 +57,25 @@ namespace PixiEditor.ViewModels.SubViewModels.Main
         {
             return Owner.DocumentIsNotNull(null) && ClipboardController.IsImageInClipboard();
         }
+
+        [Evaluator.CanExecute("PixiEditor.Clipboard.CanPasteColor")]
+        public static bool CanPasteColor() => Regex.IsMatch(Clipboard.GetText().Trim(), "^#?([a-fA-F0-9]{8}|[a-fA-F0-9]{6}|[a-fA-F0-9]{3})$");
+
+        [Evaluator.Icon("PixiEditor.Clipboard.PasteColorIcon")]
+        public static ImageSource GetPasteColorIcon()
+        {
+            Color color;
+
+            if (CanPasteColor())
+            {
+                color = SKColor.Parse(Clipboard.GetText().Trim()).ToColor();
+            }
+            else
+            {
+                color = Colors.Transparent;
+            }
+
+            return ColorSearchResult.GetIcon(color.ToSKColor());
+        }
     }
 }

+ 19 - 9
PixiEditor/ViewModels/SubViewModels/Main/IoViewModel.cs

@@ -1,9 +1,10 @@
 using PixiEditor.Helpers;
+using PixiEditor.Models.Commands;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.Controllers.Shortcuts;
+using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.Tools;
 using PixiEditor.Models.Tools.Tools;
-using System;
 using System.Windows;
 using System.Windows.Input;
 
@@ -83,6 +84,11 @@ namespace PixiEditor.ViewModels.SubViewModels.Main
 
         private void HandleTransientKey(KeyEventArgs args, bool state)
         {
+            if (ShortcutController.ShortcutExecutionBlocked)
+            {
+                return;
+            }
+
             var controller = Owner.ShortcutController;
 
             Key finalKey = args.Key;
@@ -91,16 +97,20 @@ namespace PixiEditor.ViewModels.SubViewModels.Main
                 finalKey = args.SystemKey;
             }
 
-            if (controller.TransientShortcuts.ContainsKey(finalKey))
+            Command.ToolCommand tool = CommandController.Current.Commands
+                .Select(x => x as Command.ToolCommand)
+                .FirstOrDefault(x => x != null && x.TransientKey == finalKey);
+
+            if (tool != null)
             {
-                ChangeToolState(controller.TransientShortcuts[finalKey].GetType(), state);
+                ChangeToolState(tool.ToolType, state);
             }
         }
 
         private void ProcessShortcutDown(bool isRepeat, Key key)
         {
-            if (isRepeat && !restoreToolOnKeyUp && Owner.ShortcutController.LastShortcut != null &&
-                Owner.ShortcutController.LastShortcut.Command == Owner.ToolsSubViewModel.SelectToolCommand)
+            if (isRepeat && !restoreToolOnKeyUp && Owner.ShortcutController.LastCommands != null &&
+                Owner.ShortcutController.LastCommands.Any(x => x is Command.ToolCommand))
             {
                 restoreToolOnKeyUp = true;
                 ShortcutController.BlockShortcutExection("ShortcutDown");
@@ -138,7 +148,7 @@ namespace PixiEditor.ViewModels.SubViewModels.Main
             if (key == Key.System)
                 key = args.SystemKey;
 
-            ProcessShortcutUp(key);
+            ProcessShortcutUp(new(key, args.KeyboardDevice.Modifiers));
 
             if (Owner.BitmapManager.ActiveDocument != null)
                 Owner.BitmapManager.InputTarget.OnKeyUp(key);
@@ -146,10 +156,10 @@ namespace PixiEditor.ViewModels.SubViewModels.Main
             HandleTransientKey(args, false);
         }
 
-        private void ProcessShortcutUp(Key key)
+        private void ProcessShortcutUp(KeyCombination shortcut)
         {
-            if (restoreToolOnKeyUp && Owner.ShortcutController.LastShortcut != null &&
-                Owner.ShortcutController.LastShortcut.ShortcutKey == key)
+            if (restoreToolOnKeyUp && Owner.ShortcutController.LastCommands != null &&
+                Owner.ShortcutController.LastCommands.Any(x => x.Shortcut == shortcut))
             {
                 restoreToolOnKeyUp = false;
                 Owner.ToolsSubViewModel.SetActiveTool(Owner.ToolsSubViewModel.LastActionTool);

+ 36 - 0
PixiEditor/ViewModels/SubViewModels/Main/SearchViewModel.cs

@@ -0,0 +1,36 @@
+using PixiEditor.Models.Commands.Attributes;
+using System.Windows.Input;
+
+namespace PixiEditor.ViewModels.SubViewModels.Main
+{
+    public class SearchViewModel : SubViewModel<ViewModelMain>
+    {
+        private bool searchWindowOpen;
+        private string searchTerm;
+
+        public bool SearchWindowOpen
+        {
+            get => searchWindowOpen;
+            set => SetProperty(ref searchWindowOpen, value);
+        }
+
+        public string SearchTerm
+        {
+            get => searchTerm;
+            set => SetProperty(ref searchTerm, value);
+        }
+
+        public SearchViewModel(ViewModelMain owner) : base(owner)
+        { }
+
+        [Command.Basic("PixiEditor.Search.Toggle", "", "Command Search", "Open/close the command search window", Key = Key.K, Modifiers = ModifierKeys.Control)]
+        public void ToggleSearchWindow(string searchTerm)
+        {
+            SearchWindowOpen = !SearchWindowOpen;
+            if (SearchWindowOpen)
+            {
+                SearchTerm = searchTerm;
+            }
+        }
+    }
+}

+ 1 - 1
PixiEditor/ViewModels/SubViewModels/Main/ToolsViewModel.cs

@@ -81,7 +81,7 @@ namespace PixiEditor.ViewModels.SubViewModels.Main
         {
             foreach (var tool in ToolSet)
             {
-                tool.ShortcutKey = Owner.ShortcutController.GetToolShortcutKey(tool.GetType());
+                tool.Shortcut = Owner.ShortcutController.GetToolShortcut(tool.GetType());
             }
         }
 

+ 1 - 1
PixiEditor/ViewModels/SubViewModels/UserPreferences/SettingsGroup.cs

@@ -29,7 +29,7 @@ namespace PixiEditor.ViewModels.SubViewModels.UserPreferences
 
         protected void RaiseAndUpdatePreference<T>(ref T backingStore, T value, [CallerMemberName]string name = "")
         {
-            SetProperty(ref backingStore, value, name);
+            SetProperty(ref backingStore, value, propertyName: name);
             IPreferences.Current.UpdatePreference(name, value);
         }
     }

+ 6 - 6
PixiEditor/ViewModels/ViewModelMain.cs

@@ -79,6 +79,8 @@ namespace PixiEditor.ViewModels
 
         public WindowViewModel WindowSubViewModel { get; set; }
 
+        public SearchViewModel SearchSubViewModel { get; set; }
+
         public IPreferences Preferences { get; set; }
 
         public string ActionDisplay
@@ -151,18 +153,16 @@ namespace PixiEditor.ViewModels
             WindowSubViewModel = services.GetService<WindowViewModel>();
             StylusSubViewModel = services.GetService<StylusViewModel>();
 
-            ShortcutController = new ShortcutController();
-
             MiscSubViewModel = services.GetService<MiscViewModel>();
 
-            ShortcutController.TransientShortcuts[Key.Space] = ToolsSubViewModel.ToolSet.First(x => x is MoveViewportTool);
-            ShortcutController.TransientShortcuts[Key.LeftAlt] = ToolsSubViewModel.ToolSet.First(x => x is ColorPickerTool);
-
             BitmapManager.PrimaryColor = ColorsSubViewModel.PrimaryColor;
 
+            CommandController = services.GetService<CommandController>();
+            ShortcutController = new ShortcutController();
+
             ToolsSubViewModel?.SetupToolsTooltipShortcuts(services);
 
-            CommandController = services.GetService<CommandController>();
+            SearchSubViewModel = services.GetService<SearchViewModel>();
         }
 
         /// <summary>

+ 18 - 1
PixiEditor/Views/Dialogs/SettingsWindow.xaml

@@ -53,6 +53,7 @@
                 <x:Array Type="{x:Type sys:String}">
                     <sys:String>General</sys:String>
                     <sys:String>Discord</sys:String>
+                    <sys:String>Keybinds</sys:String>
                 </x:Array>
             </ListBox.ItemsSource>
         </ListBox>
@@ -112,7 +113,7 @@
 
                 <CheckBox Grid.Row="8" Grid.Column="1" VerticalAlignment="Center"
                     IsChecked="{Binding SettingsSubViewModel.Tools.EnableSharedToolbar}">Enable shared toolbar</CheckBox>
-                
+
                 <Label Grid.Row="9" Grid.ColumnSpan="2" Style="{StaticResource SettingsHeader}">Automatic updates</Label>
 
                 <CheckBox Grid.Row="10" Grid.Column="1" VerticalAlignment="Center"
@@ -161,6 +162,22 @@
                     Detail="{Binding SettingsSubViewModel.Discord.DetailPreview}" 
                     IsPlaying="{Binding SettingsSubViewModel.Discord.EnableRichPresence}"/>
             </StackPanel>
+
+            <Grid Visibility="{Binding SelectedItem, ElementName=pages, Converter={converters:EqualityBoolToVisibilityConverter}, ConverterParameter='Keybinds'}"
+                        Margin="10">
+                <ScrollViewer>
+                    <ItemsControl ItemsSource="{Binding Commands}" Foreground="White">
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate>
+                                <Grid Margin="0,5,5,0">
+                                    <TextBlock Text="{Binding Display}" ToolTip="{Binding Description}"/>
+                                    <usercontrols:ShortcutBox Width="120" Command="{Binding}" HorizontalAlignment="Right"/>
+                                </Grid>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                    </ItemsControl>
+                </ScrollViewer>
+            </Grid>
         </Grid>
     </DockPanel>
 </Window>

+ 3 - 1
PixiEditor/Views/MainWindow.xaml

@@ -432,6 +432,8 @@
                 </StackPanel>
             </Grid>
         </Grid>
-        <usercontrols:CommandSearchControl HorizontalAlignment="Center" Height="700"/>
+        <usercontrols:CommandSearchControl Visibility="{Binding SearchSubViewModel.SearchWindowOpen, Converter={BoolToVisibilityConverter}, Mode=TwoWay}"
+                                           SearchTerm="{Binding SearchSubViewModel.SearchTerm, Mode=TwoWay}"
+                                           HorizontalAlignment="Center" Height="700"/>
     </Grid>
 </Window>

+ 74 - 64
PixiEditor/Views/UserControls/CommandSearchControl.xaml

@@ -7,9 +7,12 @@
              xmlns:vm="clr-namespace:PixiEditor.ViewModels"
              xmlns:behaves="clr-namespace:PixiEditor.Helpers.Behaviours"
              xmlns:cmdssearch="clr-namespace:PixiEditor.Models.Commands.Search"
+             xmlns:conv="clr-namespace:PixiEditor.Helpers.Converters"
+             xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
              mc:Ignorable="d"
              Foreground="White"
-             d:DesignHeight="450" d:DesignWidth="600">
+             d:DesignHeight="450" d:DesignWidth="600"
+             x:Name="uc">
     <Grid DataContext="{DynamicResource viewModel}" x:Name="mainGrid">
         <Grid.Resources>
             <ResourceDictionary>
@@ -22,7 +25,12 @@
             <RowDefinition Height="Auto"/>
         </Grid.RowDefinitions>
 
-        <TextBox Text="{Binding SearchTerm, UpdateSourceTrigger=PropertyChanged}" FontSize="18" Padding="5">
+        <TextBox Text="{Binding SearchTerm, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" FontSize="18" Padding="5"
+                 x:Name="textBox">
+            <i:Interaction.Behaviors>
+                <behaves:TextBoxFocusBehavior SelectOnMouseClick="True" />
+                <behaves:GlobalShortcutFocusBehavior/>
+            </i:Interaction.Behaviors>
             <TextBox.Style>
                 <Style TargetType="TextBox" BasedOn="{StaticResource DarkTextBoxStyle}">
                     <Style.Resources>
@@ -35,72 +43,74 @@
         </TextBox>
         <Border Grid.Row="1" BorderThickness="1,0,1,0" BorderBrush="{StaticResource BrighterAccentColor}"
                 Background="{StaticResource AccentColor}">
-            <ItemsControl ItemsSource="{Binding Commands}" MinWidth="400">
-                <ItemsControl.ItemTemplate>
-                    <DataTemplate DataType="cmdssearch:SearchTerm">
-                        <Button Padding="5" Height="40" BorderThickness="0" Background="Transparent"
+            <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
+                <ItemsControl ItemsSource="{Binding Results}" MinWidth="400">
+                    <ItemsControl.ItemTemplate>
+                        <DataTemplate DataType="cmdssearch:SearchTerm">
+                            <Button Padding="5" Height="40" BorderThickness="0" Background="Transparent"
                                 Command="{Binding ExecuteCommand}"
                                 CommandParameter="{Binding}"
                                 MouseMove="Button_MouseMove">
-                            <Button.Style>
-                                <Style TargetType="Button">
-                                    <Setter Property="Template">
-                                        <Setter.Value>
-                                            <ControlTemplate TargetType="Button">
-                                                <Border>
-                                                    <Border.Style>
-                                                        <Style TargetType="Border">
-                                                            <Style.Triggers>
-                                                                <Trigger Property="IsMouseOver" Value="False">
-                                                                    <Setter Property="Background" Value="Transparent"/>
-                                                                </Trigger>
-                                                                <Trigger Property="IsMouseOver" Value="True">
-                                                                    <Setter Property="Background" Value="{StaticResource BrighterAccentColor}"/>
-                                                                </Trigger>
-                                                                <DataTrigger Binding="{Binding CanExecute}" Value="False">
-                                                                    <Setter Property="Background" Value="Transparent"/>
-                                                                </DataTrigger>
-                                                            </Style.Triggers>
-                                                        </Style>
-                                                    </Border.Style>
-                                                    <ContentPresenter/>
-                                                </Border>
-                                            </ControlTemplate>
-                                        </Setter.Value>
-                                    </Setter>
-                                </Style>
-                            </Button.Style>
-                            <Button.Resources>
-                                <Style TargetType="TextBlock">
-                                    <Setter Property="FontSize" Value="16"/>
-                                    <Style.Triggers>
-                                        <DataTrigger Binding="{Binding CanExecute}" Value="True">
-                                            <Setter Property="Foreground" Value="White"/>
-                                        </DataTrigger>
-                                        <DataTrigger Binding="{Binding CanExecute}" Value="False">
-                                            <Setter Property="Foreground" Value="Gray"/>
-                                        </DataTrigger>
-                                    </Style.Triggers>
-                                </Style>
-                            </Button.Resources>
-                            <Grid VerticalAlignment="Center" x:Name="dp" Margin="5,0,10,0">
-                                <Grid.ColumnDefinitions>
-                                    <ColumnDefinition/>
-                                    <ColumnDefinition Width="Auto"/>
-                                </Grid.ColumnDefinitions>
-                                <StackPanel Orientation="Horizontal">
-                                    <Border Width="25" Margin="0,0,5,0" Padding="1">
-                                        <Image HorizontalAlignment="Center" Source="{Binding Icon}"/>
-                                    </Border>
-                                    <TextBlock VerticalAlignment="Center"
+                                <Button.Style>
+                                    <Style TargetType="Button">
+                                        <Setter Property="Template">
+                                            <Setter.Value>
+                                                <ControlTemplate TargetType="Button">
+                                                    <Border>
+                                                        <Border.Style>
+                                                            <Style TargetType="Border">
+                                                                <Style.Triggers>
+                                                                    <DataTrigger Binding="{Binding IsSelected, Mode=TwoWay}" Value="False">
+                                                                        <Setter Property="Background" Value="Transparent"/>
+                                                                    </DataTrigger>
+                                                                    <DataTrigger Binding="{Binding IsSelected, Mode=TwoWay}" Value="True">
+                                                                        <Setter Property="Background" Value="{StaticResource BrighterAccentColor}"/>
+                                                                    </DataTrigger>
+                                                                    <DataTrigger Binding="{Binding CanExecute}" Value="False">
+                                                                        <Setter Property="Background" Value="Transparent"/>
+                                                                    </DataTrigger>
+                                                                </Style.Triggers>
+                                                            </Style>
+                                                        </Border.Style>
+                                                        <ContentPresenter/>
+                                                    </Border>
+                                                </ControlTemplate>
+                                            </Setter.Value>
+                                        </Setter>
+                                    </Style>
+                                </Button.Style>
+                                <Button.Resources>
+                                    <Style TargetType="TextBlock">
+                                        <Setter Property="FontSize" Value="16"/>
+                                        <Style.Triggers>
+                                            <DataTrigger Binding="{Binding CanExecute}" Value="True">
+                                                <Setter Property="Foreground" Value="White"/>
+                                            </DataTrigger>
+                                            <DataTrigger Binding="{Binding CanExecute}" Value="False">
+                                                <Setter Property="Foreground" Value="Gray"/>
+                                            </DataTrigger>
+                                        </Style.Triggers>
+                                    </Style>
+                                </Button.Resources>
+                                <Grid VerticalAlignment="Center" x:Name="dp" Margin="5,0,10,0">
+                                    <Grid.ColumnDefinitions>
+                                        <ColumnDefinition/>
+                                        <ColumnDefinition Width="Auto"/>
+                                    </Grid.ColumnDefinitions>
+                                    <StackPanel Orientation="Horizontal">
+                                        <Border Width="25" Margin="0,0,5,0" Padding="1">
+                                            <Image HorizontalAlignment="Center" Source="{Binding Icon}"/>
+                                        </Border>
+                                        <TextBlock VerticalAlignment="Center"
                                                behaves:TextBlockExtensions.BindableInlines="{Binding TextBlockContent}"/>
-                                </StackPanel>
-                                <TextBlock Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Right" Text="{Binding Shortcut}"/>
-                            </Grid>
-                        </Button>
-                    </DataTemplate>
-                </ItemsControl.ItemTemplate>
-            </ItemsControl>
+                                    </StackPanel>
+                                    <TextBlock Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Right" Text="{Binding Shortcut}"/>
+                                </Grid>
+                            </Button>
+                        </DataTemplate>
+                    </ItemsControl.ItemTemplate>
+                </ItemsControl>
+            </ScrollViewer>
         </Border>
         <Border Grid.Row="2" BorderThickness="1" BorderBrush="{StaticResource BrighterAccentColor}"
                 CornerRadius="0,0,5,5" Background="{StaticResource AccentColor}">

+ 97 - 3
PixiEditor/Views/UserControls/CommandSearchControl.xaml.cs

@@ -1,7 +1,12 @@
-using PixiEditor.Models.Commands.Search;
+using PixiEditor.Models.Commands;
+using PixiEditor.Models.Commands.Search;
 using PixiEditor.ViewModels;
+using System.ComponentModel;
+using System.Windows;
 using System.Windows.Controls;
+using PixiEditor.Models.DataHolders;
 using System.Windows.Input;
+using PixiEditor.Helpers.Extensions;
 
 namespace PixiEditor.Views.UserControls
 {
@@ -10,17 +15,106 @@ namespace PixiEditor.Views.UserControls
     /// </summary>
     public partial class CommandSearchControl : UserControl
     {
+        private readonly CommandSearchViewModel _viewModel;
+
+        public static readonly DependencyProperty SearchTermProperty =
+            DependencyProperty.Register(nameof(SearchTerm), typeof(string), typeof(CommandSearchControl), new(SearchTermPropertyChanged));
+
+        public string SearchTerm
+        {
+            get => _viewModel.SearchTerm;
+            set => _viewModel.SearchTerm = value;
+        }
+
         public CommandSearchControl()
         {
             InitializeComponent();
+            _viewModel = mainGrid.DataContext as CommandSearchViewModel;
+            _viewModel.PropertyChanged += (s, e) =>
+            {
+                SetValue(SearchTermProperty, _viewModel.SearchTerm);
+            };
+
+            var descriptor = DependencyPropertyDescriptor.FromProperty(VisibilityProperty, typeof(UserControl));
+            descriptor.AddValueChanged(this, (sender, e) =>
+            {
+                if (Visibility == Visibility.Visible)
+                {
+                    textBox.Focus();
+                    Keyboard.Focus(textBox);
+                }
+            });
+
+            textBox.LostFocus += TextBox_LostFocus;
+            textBox.PreviewKeyDown += TextBox_PreviewKeyDown;
+        }
+
+        private void TextBox_LostFocus(object sender, RoutedEventArgs e)
+        {
+            Visibility = Visibility.Collapsed;
+            _viewModel.SelectedResult = null;
+        }
+
+        private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
+        {
+            e.Handled = true;
+
+            if (e.Key == Key.Enter)
+            {
+                Visibility = Visibility.Collapsed;
+                _viewModel.SelectedResult.Execute();
+            }
+            else if (e.Key == Key.Down || e.Key == Key.PageDown)
+            {
+                var newIndex = _viewModel.Results.IndexOf(_viewModel.SelectedResult) + 1;
+                if (newIndex >= _viewModel.Results.Count)
+                {
+                    newIndex = 0;
+                }
+
+                _viewModel.SelectedResult = _viewModel.Results.IndexOrNext(x => x.CanExecute, newIndex);
+            }
+            else if (e.Key == Key.Up || e.Key == Key.PageUp)
+            {
+                var newIndex = _viewModel.Results.IndexOf(_viewModel.SelectedResult);
+                if (newIndex == -1)
+                {
+                    newIndex = 0;
+                }
+                if (newIndex == 0)
+                {
+                    newIndex = _viewModel.Results.Count - 1;
+                }
+                else
+                {
+                    newIndex--;
+                }
+
+                _viewModel.SelectedResult = _viewModel.Results.IndexOrPrevious(x => x.CanExecute, newIndex);
+            }
+            else if (CommandController.Current.Commands["PixiEditor.Search.Toggle"].Shortcut
+                == new KeyCombination(e.Key, Keyboard.Modifiers))
+            {
+                Keyboard.ClearFocus();
+            }
+            else
+            {
+                e.Handled = false;
+            }
+        }
+
+        private static void SearchTermPropertyChanged(DependencyObject dp, DependencyPropertyChangedEventArgs e)
+        {
+            var control = dp as CommandSearchControl;
+
+            control._viewModel.SearchTerm = e.NewValue as string;
         }
 
         private void Button_MouseMove(object sender, MouseEventArgs e)
         {
-            var dataContext = mainGrid.DataContext as CommandSearchViewModel;
             var searchResult = (sender as Button).DataContext as SearchResult;
 
-            dataContext.SelectedResult = searchResult;
+            _viewModel.SelectedResult = searchResult;
         }
     }
 }

+ 63 - 0
PixiEditor/Views/UserControls/KeyCombinationBox.xaml

@@ -0,0 +1,63 @@
+<UserControl x:Class="PixiEditor.Views.UserControls.KeyCombinationBox"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
+             xmlns:local="clr-namespace:PixiEditor.Views.UserControls"
+             mc:Ignorable="d" 
+             d:DesignHeight="30" d:DesignWidth="80">
+    <Grid>
+        <Grid.ColumnDefinitions>
+            <ColumnDefinition/>
+            <ColumnDefinition Width="20"/>
+        </Grid.ColumnDefinitions>
+        <TextBox Style="{StaticResource DarkTextBoxStyle}"
+                 PreviewKeyDown="TextBox_PreviewKeyDown"
+                 PreviewKeyUp="TextBox_PreviewKeyUp"
+                 x:Name="textBox"
+                 CaretBrush="Transparent"
+                 GotKeyboardFocus="TextBox_GotKeyboardFocus"
+                 LostKeyboardFocus="TextBox_LostKeyboardFocus">
+            <TextBox.Resources>
+                <Style TargetType="Border">
+                    <Setter Property="CornerRadius" Value="5,0,0,5"/>
+                </Style>
+            </TextBox.Resources>
+        </TextBox>
+        <Button Grid.Column="1" x:Name="button" Content="&#xE711;" FontFamily="Segoe MDL2 Assets"
+                Click="Button_Click">
+            <Button.Style>
+                <Style TargetType="Button">
+                    <Setter Property="Template">
+                        <Setter.Value>
+                            <ControlTemplate TargetType="Button">
+                                <Border CornerRadius="0,5,5,0" BorderThickness="1"
+                                        Background="{TemplateBinding Background}"
+                                        BorderBrush="{TemplateBinding BorderBrush}">
+                                    <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
+                                </Border>
+                            </ControlTemplate>
+                        </Setter.Value>
+                    </Setter>
+                    <Style.Triggers>
+                        <Trigger Property="IsMouseOver" Value="False">
+                            <Setter Property="Background" Value="{StaticResource BrighterAccentColor}"/>
+                            <Setter Property="BorderBrush" Value="{StaticResource BrighterAccentColor}"/>
+                            <Setter Property="Foreground" Value="White"/>
+                        </Trigger>
+                        <Trigger Property="IsMouseOver" Value="True">
+                            <Setter Property="Background" Value="{StaticResource AlmostLightModeAccentColor}"/>
+                            <Setter Property="BorderBrush" Value="{StaticResource AlmostLightModeAccentColor}"/>
+                            <Setter Property="Foreground" Value="White"/>
+                        </Trigger>
+                        <Trigger Property="IsEnabled" Value="False">
+                            <Setter Property="Background" Value="{StaticResource AccentColor}"/>
+                            <Setter Property="BorderBrush" Value="{StaticResource BrighterAccentColor}"/>
+                            <Setter Property="Foreground" Value="Gray"/>
+                        </Trigger>
+                    </Style.Triggers>
+                </Style>
+            </Button.Style>
+        </Button>
+    </Grid>
+</UserControl>

+ 160 - 0
PixiEditor/Views/UserControls/KeyCombinationBox.xaml.cs

@@ -0,0 +1,160 @@
+using PixiEditor.Models.DataHolders;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace PixiEditor.Views.UserControls
+{
+    /// <summary>
+    /// Interaction logic for KeyCombinationBox.xaml
+    /// </summary>
+    public partial class KeyCombinationBox : UserControl
+    {
+        private KeyCombination currentCombination;
+        private bool ignoreButtonPress;
+
+        public static readonly DependencyProperty KeyCombinationProperty =
+            DependencyProperty.Register(nameof(KeyCombination), typeof(KeyCombination), typeof(KeyCombinationBox), new PropertyMetadata(CombinationUpdate));
+
+        public event EventHandler<KeyCombination> KeyCombinationChanged;
+
+        public KeyCombination KeyCombination
+        {
+            get => (KeyCombination)GetValue(KeyCombinationProperty);
+            set => SetValue(KeyCombinationProperty, value);
+        }
+
+        public static readonly DependencyProperty DefaultCombinationProperty =
+            DependencyProperty.Register(nameof(DefaultCombination), typeof(KeyCombination), typeof(KeyCombinationBox), new PropertyMetadata(DefaultCombinationUpdate));
+
+        public KeyCombination DefaultCombination
+        {
+            get => (KeyCombination)GetValue(DefaultCombinationProperty);
+            set => SetValue(DefaultCombinationProperty, value);
+        }
+
+        public KeyCombinationBox()
+        {
+            InitializeComponent();
+
+            UpdateText();
+            UpdateButton();
+        }
+
+        private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
+        {
+            e.Handled = true;
+
+            if (GetModifier(e.Key == Key.System ? e.SystemKey : e.Key) is ModifierKeys modifier)
+            {
+                currentCombination = new(currentCombination.Key, currentCombination.Modifiers | modifier);
+            }
+            else
+            {
+                currentCombination = new(e.Key, currentCombination.Modifiers);
+            }
+
+            UpdateText();
+            UpdateButton();
+        }
+
+        private void TextBox_PreviewKeyUp(object sender, KeyEventArgs e)
+        {
+            e.Handled = true;
+
+            if (GetModifier((e.Key == Key.System ? e.SystemKey : e.Key)) is ModifierKeys modifier)
+            {
+                currentCombination = new(currentCombination.Key, currentCombination.Modifiers ^ modifier);
+                UpdateText();
+            }
+            else
+            {
+                KeyCombination = new(e.Key, currentCombination.Modifiers);
+                Keyboard.ClearFocus();
+            }
+
+            UpdateButton();
+        }
+
+        private void TextBox_GotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
+        {
+            currentCombination = new();
+            textBox.Text = "Press any key";
+            UpdateButton();
+        }
+
+        private void TextBox_LostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
+        {
+            ignoreButtonPress = e.NewFocus == button;
+            currentCombination = KeyCombination;
+
+            UpdateText();
+            UpdateButton();
+        }
+
+        private void Button_Click(object sender, RoutedEventArgs e)
+        {
+            if (ignoreButtonPress)
+            {
+                ignoreButtonPress = false;
+                return;
+            }
+
+            if (KeyCombination != DefaultCombination)
+            {
+                KeyCombination = DefaultCombination;
+            }
+            else
+            {
+                KeyCombination = default;
+            }
+        }
+
+        private void UpdateText() => textBox.Text = currentCombination != default ? currentCombination.ToString() : "None";
+
+        private void UpdateButton()
+        {
+            if (textBox.IsKeyboardFocused)
+            {
+                button.IsEnabled = true;
+                button.Content = "\uE711";
+            }
+            else if (KeyCombination != DefaultCombination)
+            {
+                button.IsEnabled = true;
+                button.Content = "\uE72B";
+            }
+            else
+            {
+                button.IsEnabled = KeyCombination != default;
+                button.Content = "\uE738";
+            }
+        }
+
+        private static void CombinationUpdate(DependencyObject obj, DependencyPropertyChangedEventArgs e)
+        {
+            var box = (KeyCombinationBox)obj;
+
+            box.currentCombination = box.KeyCombination;
+            box.textBox.Text = box.KeyCombination.ToString();
+            box.KeyCombinationChanged.Invoke(box, box.currentCombination);
+
+            box.UpdateText();
+            box.UpdateButton();
+        }
+
+        private static void DefaultCombinationUpdate(DependencyObject obj, DependencyPropertyChangedEventArgs e)
+        {
+            var box = (KeyCombinationBox)obj;
+            box.UpdateButton();
+        }
+
+        private static ModifierKeys? GetModifier(Key key) => key switch
+        {
+            Key.LeftCtrl or Key.RightCtrl => ModifierKeys.Control,
+            Key.LeftAlt or Key.RightAlt => ModifierKeys.Alt,
+            Key.LeftShift or Key.RightShift => ModifierKeys.Shift,
+            _ => null
+        };
+    }
+}

+ 68 - 0
PixiEditor/Views/UserControls/ShortcutBox.cs

@@ -0,0 +1,68 @@
+using PixiEditor.Models.Commands;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace PixiEditor.Views.UserControls
+{
+    public class ShortcutBox : ContentControl
+    {
+        private readonly KeyCombinationBox box;
+        private bool changingCombination;
+
+        public static readonly DependencyProperty CommandProperty =
+            DependencyProperty.Register(nameof(Command), typeof(Command), typeof(ShortcutBox), new PropertyMetadata(null, CommandUpdated));
+
+        public Command Command
+        {
+            get => (Command)GetValue(CommandProperty);
+            set => SetValue(CommandProperty, value);
+        }
+
+        public ShortcutBox()
+        {
+            Content = box = new KeyCombinationBox();
+            box.KeyCombinationChanged += Box_KeyCombinationChanged;
+        }
+
+        private void Command_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
+        {
+            if (e.PropertyName == nameof(Command.Shortcut))
+            {
+                UpdateBoxCombination();
+            }
+        }
+
+        private void Box_KeyCombinationChanged(object sender, Models.DataHolders.KeyCombination e)
+        {
+            if (!changingCombination)
+            {
+                CommandController.Current.UpdateShortcut(Command, e);
+            }
+        }
+
+        private void UpdateBoxCombination()
+        {
+            changingCombination = true;
+            box.KeyCombination = Command?.Shortcut ?? default;
+            box.DefaultCombination = Command?.DefaultShortcut ?? default;
+            changingCombination = false;
+        }
+
+        private static void CommandUpdated(DependencyObject dp, DependencyPropertyChangedEventArgs e)
+        {
+            var box = dp as ShortcutBox;
+
+            if (e.OldValue is Command oldValue)
+            {
+                oldValue.PropertyChanged -= box.Command_PropertyChanged;
+            }
+
+            if (e.NewValue is Command newValue)
+            {
+                newValue.PropertyChanged += box.Command_PropertyChanged;
+            }
+
+            box.UpdateBoxCombination();
+        }
+    }
+}