소스 검색

Ported command search

Krzysztof Krysiński 1 년 전
부모
커밋
e1e199e59f

+ 31 - 0
src/PixiEditor.AvaloniaUI/Helpers/Behaviours/TextBlockExtensions.cs

@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+
+namespace PixiEditor.AvaloniaUI.Helpers.Behaviours;
+
+internal class TextBlockExtensions : AvaloniaObject
+{
+    public static readonly AttachedProperty<IEnumerable<Inline>> BindableInlinesProperty =
+        AvaloniaProperty.RegisterAttached<TextBlockExtensions, TextBlock, IEnumerable<Inline>>("BindableInlines");
+
+    public static void SetBindableInlines(TextBlock obj, IEnumerable<Inline> value) => obj.SetValue(BindableInlinesProperty, value);
+    public static IEnumerable<Inline> GetBindableInlines(TextBlock obj) => obj.GetValue(BindableInlinesProperty);
+
+    static TextBlockExtensions()
+    {
+        BindableInlinesProperty.Changed.Subscribe(OnBindableInlinesChanged);
+    }
+
+    private static void OnBindableInlinesChanged(AvaloniaPropertyChangedEventArgs<IEnumerable<Inline>> e)
+    {
+        if (e.Sender is not TextBlock target)
+        {
+            return;
+        }
+
+        target.Inlines.Clear();
+        target.Inlines.AddRange(e.NewValue.Value);
+    }
+}

+ 98 - 0
src/PixiEditor.AvaloniaUI/Helpers/Extensions/EnumerableExtensions.cs

@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+
+namespace PixiEditor.AvaloniaUI.Helpers.Extensions;
+
+internal static class EnumerableExtensions
+{
+    /// <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);
+    }
+}

+ 3 - 1
src/PixiEditor.AvaloniaUI/Models/Commands/Search/SearchResult.cs

@@ -3,7 +3,9 @@ using System.Linq;
 using System.Text.RegularExpressions;
 using System.Windows.Input;
 using Avalonia;
+using Avalonia.Controls;
 using Avalonia.Controls.Documents;
+using Avalonia.Controls.Metadata;
 using Avalonia.Media;
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
@@ -42,7 +44,6 @@ internal abstract class SearchResult : ObservableObject
         set => SetProperty(ref isMouseSelected, value);
     }
 
-
     public abstract void Execute();
 
     public virtual KeyCombination Shortcut { get; }
@@ -51,6 +52,7 @@ internal abstract class SearchResult : ObservableObject
 
     public SearchResult()
     {
+
         ExecuteCommand = new RelayCommand(Execute, () => CanExecute);
     }
 

+ 8 - 0
src/PixiEditor.AvaloniaUI/Styles/Templates/SearchResultTemplate.axaml

@@ -0,0 +1,8 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                    xmlns:search="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.Search">
+
+    <!--<ControlTheme TargetType="search:SearchResult">
+
+    </ControlTheme>-->
+</ResourceDictionary>

+ 1 - 1
src/PixiEditor.AvaloniaUI/ViewModels/SubViewModels/ClipboardViewModel.cs

@@ -165,7 +165,7 @@ internal class ClipboardViewModel : SubViewModel<ViewModelMain>
         {
             color = (CopyColor)command.Parameter;
         }
-        else if (data is CommandSearchResult result)
+        else if (data is Models.Commands.Search.CommandSearchResult result)
         {
             color = (CopyColor)((Models.Commands.Commands.Command.BasicCommand)result.Command).Parameter;
         }

+ 144 - 0
src/PixiEditor.AvaloniaUI/Views/Main/CommandSearch/CommandSearchControl.axaml

@@ -0,0 +1,144 @@
+<UserControl x:Class="PixiEditor.AvaloniaUI.Views.Main.CommandSearch.CommandSearchControl"
+             x:ClassModifier="internal"
+             xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:behaviours="clr-namespace:PixiEditor.AvaloniaUI.Helpers.Behaviours"
+             xmlns:search="clr-namespace:PixiEditor.AvaloniaUI.Models.Commands.Search"
+             mc:Ignorable="d"
+             Foreground="White"
+             d:DesignHeight="450" d:DesignWidth="600"
+             Width="600"
+             x:Name="uc">
+    <Grid x:Name="mainGrid">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="*" />
+            <RowDefinition Height="Auto" />
+        </Grid.RowDefinitions>
+
+        <TextBox Text="{Binding SearchTerm, Mode=TwoWay, ElementName=uc}"
+                 FontSize="17"
+                 Padding="5"
+                 x:Name="textBox">
+            <Interaction.Behaviors>
+                <behaviours:TextBoxFocusBehavior SelectOnMouseClick="{Binding SelectAll, ElementName=uc, Mode=OneWay}" />
+                <behaviours:GlobalShortcutFocusBehavior />
+            </Interaction.Behaviors>
+            <!--<TextBox.Styles>
+                <Style Selector="TextBox">
+                    <Style.Resources>
+                        <Style Selector="Border">
+                            <Setter Property="CornerRadius" Value="5,5,0,0" />
+                        </Style>
+                    </Style.Resources>
+                </Style>
+            </TextBox.Styles>-->
+        </TextBox>
+        <Border Grid.Row="1" BorderThickness="1,0,1,0" BorderBrush="{DynamicResource ThemeBorderMidBrush}"
+                Background="{DynamicResource ThemeBackgroundBrush}">
+            <Grid>
+                <TextBlock Text="{Binding Warnings, ElementName=uc}" TextAlignment="Center" Foreground="Gray"
+                           Margin="0,5,0,0"
+                           IsVisible="{Binding HasWarnings, ElementName=uc}" />
+                <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
+                    <ItemsControl ItemsSource="{Binding Results, ElementName=uc}" x:Name="itemscontrol">
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate DataType="search:SearchResult">
+                                <Button Padding="5" Height="40" BorderThickness="0" Background="{DynamicResource ThemeBackgroundBrush}"
+                                        Command="{Binding ButtonClickedCommand, ElementName=uc}"
+                                        CommandParameter="{Binding}"
+                                        HorizontalContentAlignment="Stretch"
+                                        PointerMoved="Button_MouseMove">
+                                    <!--TODO: Implement-->
+                                    <!--<Button.Styles>
+                                        <Style Selector="Button">
+                                            <Setter Property="Template">
+                                                <Setter.Value>
+                                                    <ControlTemplate TargetType="Button">
+                                                        <Border>
+                                                            <Border.Styles>
+                                                                <Style Selector="Border">
+                                                                    <Style.Triggers>
+                                                                        <DataTrigger
+                                                                            Binding="{Binding IsSelected, Mode=TwoWay}"
+                                                                            Value="False">
+                                                                            <Setter Property="Background"
+                                                                                Value="Transparent" />
+                                                                        </DataTrigger>
+                                                                        <DataTrigger
+                                                                            Binding="{Binding IsMouseSelected, Mode=TwoWay}"
+                                                                            Value="False">
+                                                                            <Setter Property="Background"
+                                                                                Value="Transparent" />
+                                                                        </DataTrigger>
+                                                                        <DataTrigger
+                                                                            Binding="{Binding IsMouseSelected, Mode=TwoWay}"
+                                                                            Value="True">
+                                                                            <Setter Property="Background"
+                                                                                Value="{StaticResource BrighterAccentColor}" />
+                                                                        </DataTrigger>
+                                                                        <DataTrigger
+                                                                            Binding="{Binding IsSelected, Mode=TwoWay}"
+                                                                            Value="True">
+                                                                            <Setter Property="Background"
+                                                                                Value="{StaticResource AlmostLightModeAccentColor}" />
+                                                                        </DataTrigger>
+                                                                        <DataTrigger Binding="{Binding CanExecute}"
+                                                                            Value="False">
+                                                                            <Setter Property="Background"
+                                                                                Value="Transparent" />
+                                                                        </DataTrigger>
+                                                                    </Style.Triggers>
+                                                                </Style>
+                                                            </Border.Styles>
+                                                            <ContentPresenter />
+                                                        </Border>
+                                                    </ControlTemplate>
+                                                </Setter.Value>
+                                            </Setter>
+                                        </Style>
+                                    </Button.Styles>-->
+                                    <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"
+                                                       behaviours:TextBlockExtensions.BindableInlines="{Binding TextBlockContent}" />
+                                        </StackPanel>
+
+                                        <TextBlock Grid.Column="1" VerticalAlignment="Center" Classes="KeyBorder"
+                                                   HorizontalAlignment="Right" Text="{Binding Shortcut}" />
+                                    </Grid>
+                                </Button>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                    </ItemsControl>
+                </ScrollViewer>
+            </Grid>
+        </Border>
+        <Border Grid.Row="2" BorderThickness="1" BorderBrush="{DynamicResource ThemeBorderMidBrush}"
+                CornerRadius="0,0,5,5" Background="{DynamicResource ThemeBackgroundBrush1}" Padding="3">
+            <ContentPresenter Content="{Binding SelectedResult.Description, Mode=OneWay, ElementName=uc}" />
+        </Border>
+    </Grid>
+</UserControl>

+ 286 - 0
src/PixiEditor.AvaloniaUI/Views/Main/CommandSearch/CommandSearchControl.axaml.cs

@@ -0,0 +1,286 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Text;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.Input;
+using Hardware.Info;
+using PixiEditor.AvaloniaUI.Helpers.Extensions;
+using PixiEditor.AvaloniaUI.Models.Commands;
+using PixiEditor.AvaloniaUI.Models.Commands.Search;
+using PixiEditor.AvaloniaUI.Models.Input;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+
+namespace PixiEditor.AvaloniaUI.Views.Main.CommandSearch;
+#nullable enable
+internal partial class CommandSearchControl : UserControl, INotifyPropertyChanged
+{
+    public static readonly StyledProperty<string> SearchTermProperty =
+        AvaloniaProperty.Register<CommandSearchControl, string>(
+            nameof(SearchTerm));
+
+    public string SearchTerm
+    {
+        get => GetValue(SearchTermProperty);
+        set => SetValue(SearchTermProperty, value);
+    }
+
+    public static readonly StyledProperty<bool> SelectAllProperty = AvaloniaProperty.Register<CommandSearchControl, bool>(
+        nameof(SelectAll));
+
+    public bool SelectAll
+    {
+        get => GetValue(SelectAllProperty);
+        set => SetValue(SelectAllProperty, value);
+    }
+
+    private string warnings = "";
+    public string Warnings
+    {
+        get => warnings;
+        set
+        {
+            warnings = value;
+            PropertyChanged?.Invoke(this, new(nameof(Warnings)));
+            PropertyChanged?.Invoke(this, new(nameof(HasWarnings)));
+        }
+    }
+
+    public bool HasWarnings => Warnings != string.Empty;
+    public RelayCommand ButtonClickedCommand { get; }
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    private SearchResult? selectedResult;
+    public SearchResult? SelectedResult
+    {
+        get => selectedResult;
+        private set
+        {
+            if (selectedResult is not null)
+                selectedResult.IsSelected = false;
+            if (value is not null)
+                value.IsSelected = true;
+            selectedResult = value;
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedResult)));
+        }
+    }
+
+    private SearchResult? mouseSelectedResult;
+    public SearchResult? MouseSelectedResult
+    {
+        get => mouseSelectedResult;
+        private set
+        {
+            if (mouseSelectedResult is not null)
+                mouseSelectedResult.IsMouseSelected = false;
+            if (value is not null)
+                value.IsMouseSelected = true;
+            mouseSelectedResult = value;
+        }
+    }
+
+    public ObservableCollection<SearchResult> Results { get; } = new();
+
+    static CommandSearchControl()
+    {
+        SearchTermProperty.Changed.Subscribe(OnSearchTermChange);
+    }
+
+    public CommandSearchControl()
+    {
+        ButtonClickedCommand = new RelayCommand(() =>
+        {
+            Hide();
+            MouseSelectedResult?.Execute();
+            MouseSelectedResult = null;
+        });
+
+        InitializeComponent();
+
+        PointerPressed += OnPointerDown;
+        KeyDown += OnPreviewKeyDown;
+        Loaded += (_, _) => UpdateSearchResults();
+    }
+
+    private static void OnIsVisibleChanged(AvaloniaPropertyChangedEventArgs<bool> e)
+    {
+        CommandSearchControl control = ((CommandSearchControl)e.Sender);
+        if (e.NewValue.Value)
+        {
+            Dispatcher.UIThread.Invoke(
+                () =>
+                {
+                    control.textBox.Focus();
+                    control.UpdateSearchResults();
+
+                    // TODO: Mouse capture
+                    /*Mouse.Capture(this, CaptureMode.SubTree);*/
+
+                    if (!control.SelectAll)
+                    {
+                        control.textBox.CaretIndex = control.SearchTerm?.Length ?? 0;
+                    }
+                }, DispatcherPriority.Render);
+        }
+    }
+
+    private void OnPointerDown(object sender, PointerPressedEventArgs e)
+    {
+        var pos = e.GetPosition(this);
+        bool outside = pos.X < 0 || pos.Y < 0 || pos.X > Bounds.Width || pos.Y > Bounds.Height;
+        if (outside)
+            Hide();
+    }
+
+    private void UpdateSearchResults()
+    {
+        Results.Clear();
+        (List<SearchResult> newResults, List<string> warnings) = CommandSearchControlHelper.ConstructSearchResults(SearchTerm);
+        foreach (var result in newResults)
+            Results.Add(result);
+        Warnings = warnings.Aggregate(new StringBuilder(), static (builder, item) =>
+        {
+            builder.AppendLine(item);
+            return builder;
+        }).ToString();
+        SelectedResult = Results.FirstOrDefault(x => x.CanExecute);
+    }
+
+    private void Hide()
+    {
+        // TODO: This
+        /*FocusManager.SetFocusedElement(FocusManager.GetFocusScope(textBox), null);
+        Keyboard.ClearFocus();*/
+        IsVisible = false;
+        //ReleaseMouseCapture();
+    }
+
+    private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
+    {
+        e.Handled = true;
+
+        OneOf<Color, Error, None> result;
+
+        if (e.Key == Key.Enter && SelectedResult is not null)
+        {
+            Hide();
+            SelectedResult.Execute();
+            SelectedResult = null;
+        }
+        else if (e.Key is Key.Down or Key.PageDown)
+        {
+            MoveSelection(1);
+        }
+        else if (e.Key is Key.Up or Key.PageUp)
+        {
+            MoveSelection(-1);
+        }
+        else if (e.Key == Key.Escape ||
+                 CommandController.Current.Commands["PixiEditor.Search.Toggle"].Shortcut
+                 == new KeyCombination(e.Key, e.KeyModifiers))
+        {
+            Hide();
+        }
+        else if (e.Key == Key.R && e.KeyModifiers == KeyModifiers.Control)
+        {
+            SearchTerm = "rgb(,,)";
+            textBox.CaretIndex = 4;
+            /*TODO: Validate below, length should be 0*/
+            textBox.SelectionStart = 4;
+            textBox.SelectionEnd = 4;
+        }
+        else if (e.Key == Key.Space && SearchTerm.StartsWith("rgb") && textBox.CaretIndex > 0 && char.IsDigit(SearchTerm[textBox.CaretIndex - 1]))
+        {
+            var prev = textBox.CaretIndex;
+            if (SearchTerm.Length == textBox.CaretIndex || SearchTerm[textBox.CaretIndex] != ',')
+            {
+                SearchTerm = SearchTerm.Insert(textBox.CaretIndex, ",");
+            }
+            textBox.CaretIndex = prev + 1;
+        }
+        else if (e is { Key: Key.S, KeyModifiers: KeyModifiers.Control } &&
+                 (result = CommandSearchControlHelper.MaybeParseColor(SearchTerm)).IsT0)
+        {
+            SwitchColor(result.AsT0);
+        }
+        else if (e is { Key: Key.D, KeyModifiers: KeyModifiers.Control })
+        {
+            SearchTerm = "~/Documents/";
+            textBox.CaretIndex = SearchTerm.Length;
+        }
+        else if (e is { Key: Key.P, KeyModifiers: KeyModifiers.Control })
+        {
+            SearchTerm = "~/Pictures/";
+            textBox.CaretIndex = SearchTerm.Length;
+        }
+        else
+        {
+            e.Handled = false;
+        }
+    }
+
+    private void SwitchColor(Color color)
+    {
+        if (SearchTerm.StartsWith('#'))
+        {
+            if (color.A == 255)
+            {
+                SearchTerm = $"rgb({color.R},{color.G},{color.B})";
+                textBox.CaretIndex = 4;
+            }
+            else
+            {
+                SearchTerm = $"rgba({color.R},{color.G},{color.B},{color.A})";
+                textBox.CaretIndex = 5;
+            }
+        }
+        else
+        {
+            if (color.A == 255)
+            {
+                SearchTerm = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
+                textBox.CaretIndex = 1;
+            }
+            else
+            {
+                SearchTerm = $"#{color.R:X2}{color.G:X2}{color.B:X2}{color.A:X2}";
+                textBox.CaretIndex = 1;
+            }
+        }
+    }
+
+    private void MoveSelection(int delta)
+    {
+        if (delta == 0)
+            return;
+        if (SelectedResult is null)
+        {
+            SelectedResult = Results.FirstOrDefault(x => x.CanExecute);
+            return;
+        }
+
+        int newIndex = Results.IndexOf(SelectedResult) + delta;
+        newIndex = (newIndex % Results.Count + Results.Count) % Results.Count;
+
+        SelectedResult = delta > 0 ? Results.IndexOrNext(x => x.CanExecute, newIndex) : Results.IndexOrPrevious(x => x.CanExecute, newIndex);
+        itemscontrol.ItemContainerGenerator.ContainerFromIndex(newIndex)?.BringIntoView();
+    }
+
+    private void Button_MouseMove(object sender, PointerEventArgs e)
+    {
+        var searchResult = ((Button)sender).DataContext as SearchResult;
+        MouseSelectedResult = searchResult;
+    }
+
+    private static void OnSearchTermChange(AvaloniaPropertyChangedEventArgs<string> e)
+    {
+        CommandSearchControl control = ((CommandSearchControl)e.Sender);
+        control.UpdateSearchResults();
+        control.PropertyChanged?.Invoke(control, new PropertyChangedEventArgs(nameof(control.SearchTerm)));
+    }
+}

+ 219 - 0
src/PixiEditor.AvaloniaUI/Views/Main/CommandSearch/CommandSearchControlHelper.cs

@@ -0,0 +1,219 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using PixiEditor.AvaloniaUI.Helpers;
+using PixiEditor.AvaloniaUI.Models.Commands;
+using PixiEditor.AvaloniaUI.Models.Commands.Search;
+using PixiEditor.AvaloniaUI.ViewModels;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using CommandSearchResult = PixiEditor.AvaloniaUI.Models.Commands.Search.CommandSearchResult;
+
+namespace PixiEditor.AvaloniaUI.Views.Main.CommandSearch;
+
+#nullable enable
+internal static class CommandSearchControlHelper
+{
+    public static (List<SearchResult> results, List<string> warnings) ConstructSearchResults(string query)
+    {
+        // avoid xaml designer error
+        if (ViewModelMain.Current is null)
+            return (new(), new());
+
+        List<SearchResult> newResults = new();
+        List<string> warnings = new();
+
+        if (string.IsNullOrWhiteSpace(query))
+        {
+            // show all recently opened
+            newResults.AddRange(ViewModelMain.Current.FileSubViewModel.RecentlyOpened
+                .Select(file => (SearchResult)new FileSearchResult(file.FilePath)
+                {
+                    SearchTerm = query
+                }));
+            return (newResults, warnings);
+        }
+
+        var controller = CommandController.Current;
+
+        if (query.StartsWith(':') && query.Length > 1)
+        {
+            string searchTerm = query[1..].Replace(" ", "");
+            int index = searchTerm.IndexOf(':');
+
+            string menu;
+            string additional;
+            
+            if (index > 0)
+            {
+                menu = searchTerm[..index];
+                additional = searchTerm[(index + 1)..];
+            }
+            else
+            {
+                menu = searchTerm;
+                additional = string.Empty;
+            }
+
+            var menuCommands = controller.FilterCommands
+                .Where(x => x.Key.Replace(" ", "").Contains(menu, StringComparison.OrdinalIgnoreCase))
+                .SelectMany(x => x.Value);
+
+            newResults.AddRange(menuCommands
+                .Where(x => index == -1 || x.DisplayName.Value.Replace(" ", "").Contains(additional, StringComparison.OrdinalIgnoreCase))
+                .Select(command => new CommandSearchResult(command) { SearchTerm = searchTerm }));
+
+            return (newResults, warnings);
+        }
+        
+        // add matching colors
+        MaybeParseColor(query).Switch(
+            color =>
+            {
+                newResults.Add(new ColorSearchResult(color)
+                {
+                    SearchTerm = query
+                });
+                newResults.Add(ColorSearchResult.PastePalette(color, query));
+            },
+            (Error _) => warnings.Add("Invalid color"),
+            static (None _) => { }
+            );
+
+        // add matching commands
+        newResults.AddRange(
+            controller.Commands
+                .Where(x => x.Description.Value.Replace(" ", "").Contains(query.Replace(" ", ""), StringComparison.OrdinalIgnoreCase))
+                .Where(static x => ViewModelMain.Current.DebugSubViewModel.UseDebug ? true : !x.IsDebug)
+                .OrderByDescending(x => x.Description.Value.Contains($" {query} ", StringComparison.OrdinalIgnoreCase))
+                .Take(18)
+                .Select(command => new CommandSearchResult(command)
+                {
+                    SearchTerm = query,
+                    Match = Match(command.Description, query)
+                }));
+
+        try
+        {
+            // add matching files
+            newResults.AddRange(MaybeParseFilePaths(query));
+        }
+        catch
+        {
+            // ignored
+        }
+
+        // add matching recent files
+        newResults.AddRange(
+            ViewModelMain.Current.FileSubViewModel.RecentlyOpened
+                .Where(x => x.FilePath.Contains(query))
+                .Select(file => new FileSearchResult(file.FilePath)
+                {
+                    SearchTerm = query,
+                    Match = Match(file.FilePath, query)
+                }));
+
+        return (newResults, warnings);
+    }
+
+    private static Match Match(string text, string searchTerm) =>
+            Regex.Match(text, $"(.*)({Regex.Escape(searchTerm ?? string.Empty)})(.*)", RegexOptions.IgnoreCase);
+
+    private static IEnumerable<SearchResult> MaybeParseFilePaths(string query)
+    {
+        var filePath = query.Trim(' ', '"', '\'');
+
+        if (filePath.StartsWith("~"))
+            filePath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), filePath[1..]);
+
+        if (!Path.IsPathFullyQualified(filePath))
+            return Enumerable.Empty<SearchResult>();
+
+        GetDirectory(filePath, out var directory, out var name);
+        var files = Directory.EnumerateFiles(directory)
+            .Where(x => SupportedFilesHelper.IsExtensionSupported(Path.GetExtension(x)));
+
+        if (name is not (null or ""))
+        {
+            files = files.Where(x => x.Contains(name, StringComparison.OrdinalIgnoreCase));
+        }
+
+        string[] array = files as string[] ?? files.ToArray();
+        
+        if (array.Length != 1)
+        {
+            return array
+                .Select(static file => Path.GetFullPath(file))
+                .Select(path => new FileSearchResult(path)
+                {
+                    SearchTerm = name, Match = Match($".../{Path.GetFileName(path)}", name ?? "")
+                });
+        }
+
+        return array.Length >= 1 ? new[] { new FileSearchResult(array[0]), new FileSearchResult(array[0], true) } : ArraySegment<SearchResult>.Empty;
+    }
+
+    private static bool GetDirectory(string path, out string directory, out string file)
+    {
+        if (Directory.Exists(path))
+        {
+            directory = path;
+            file = string.Empty;
+            return true;
+        }
+
+        directory = Path.GetDirectoryName(path) ?? @"C:\";
+        file = Path.GetFileName(path);
+
+        return Directory.Exists(directory);
+    }
+
+    public static OneOf<Color, Error, None> MaybeParseColor(string query)
+    {
+        if (query.StartsWith('#'))
+        {
+            if (!Color.TryParse(query, out var color))
+                return new Error();
+            return color;
+        }
+        else if (query.StartsWith("rgb") || query.StartsWith("rgba"))
+        {
+            // matches strings that:
+            // - start with "rgb" or "rgba"
+            // - have a list of 3 or 4 numbers each up to 255 (rgb with 4 numbers is still allowed)
+            // - can have parenteses around the list
+            // - can have spaces in any reasonable places
+            Match match = Regex.Match(query, @"^rgba? *(?(?=\()\((?=.+\))|(?!.+\))) *(?<r>(?:1?\d{1,2})|(?:2[1-4]\d)|(?:25[1-5])) *, *(?<g>(?:1?\d{1,2})|(?:2[1-4]\d)|(?:25[1-5])) *, *(?<b>(?:1?\d{1,2})|(?:2[1-4]\d)|(?:25[1-5])) *(?:, *(?<a>(?:1?\d{1,2})|(?:2[1-4]\d)|(?:25[1-5])))?\)?$");
+
+            if (match.Success)
+            {
+                var maybeColor = ParseRGB(match);
+                return maybeColor is null ? new Error() : maybeColor.Value;
+            }
+            else if (query.StartsWith("rgb(") || query.StartsWith("rgba("))
+            {
+                return new Error();
+            }
+        }
+        return new None();
+    }
+
+    private static Color? ParseRGB(Match match)
+    {
+        bool invalid = !(
+            byte.TryParse(match.Groups["r"].ValueSpan, out var r) &
+            byte.TryParse(match.Groups["g"].ValueSpan, out var g) &
+            byte.TryParse(match.Groups["b"].ValueSpan, out var b)
+        );
+
+        if (invalid)
+            return null;
+
+        var aText = match.Groups["a"].Value;
+        byte a = 255;
+        if (!string.IsNullOrEmpty(aText) && !byte.TryParse(aText, out a))
+            return null;
+
+        return new Color(r, g, b, a);
+    }
+}

+ 27 - 0
src/PixiEditor.AvaloniaUI/Views/Main/MainTitleBar.axaml

@@ -9,6 +9,7 @@
              xmlns:dialogs="clr-namespace:PixiEditor.AvaloniaUI.Views.Dialogs"
              xmlns:input="clr-namespace:PixiEditor.AvaloniaUI.Views.Input;assembly=PixiEditor.UI.Common"
              xmlns:avaloniaUi="clr-namespace:PixiEditor.AvaloniaUI"
+             xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.AvaloniaUI.Views.Main.MainTitleBar">
     <Grid>
@@ -298,5 +299,31 @@
                 </MenuItem>
             </MenuItem>
         </xaml:Menu>
+        <Border Width="300" Height="25"
+                Background="{DynamicResource ThemeBackgroundBrush}"
+                CornerRadius="5" BorderThickness="1"
+                Margin="10,-6,0,0"
+                Cursor="IBeam">
+            <Border.Styles>
+                <Style Selector="Border">
+                    <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderMidBrush}"/>
+                </Style>
+                <Style Selector="Border:pointerover">
+                    <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderHighBrush}"/>
+                </Style>
+            </Border.Styles>
+            <Interaction.Behaviors>
+                <EventTriggerBehavior
+                    EventName="PointerPressed">
+                    <InvokeCommandAction
+                        Command="{xaml:Command PixiEditor.Search.Toggle}"/>
+                </EventTriggerBehavior>
+            </Interaction.Behaviors>
+            <Grid Margin="5,0" VerticalAlignment="Center">
+                <TextBlock ui:Translator.Key="SEARCH"/>
+                <TextBlock Text="{xaml:ShortcutBinding PixiEditor.Search.Toggle}"
+                           HorizontalAlignment="Right"/>
+            </Grid>
+        </Border>
     </Grid>
 </UserControl>

+ 28 - 19
src/PixiEditor.AvaloniaUI/Views/MainView.axaml

@@ -12,29 +12,38 @@
              xmlns:zoombox="clr-namespace:PixiEditor.Zoombox;assembly=PixiEditor.Zoombox"
              xmlns:layers="clr-namespace:PixiEditor.AvaloniaUI.Views.Layers"
              xmlns:tools="clr-namespace:PixiEditor.AvaloniaUI.Views.Main.Tools"
+             xmlns:commandSearch="clr-namespace:PixiEditor.AvaloniaUI.Views.Main.CommandSearch"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.AvaloniaUI.Views.MainView"
              x:DataType="viewModels1:ViewModelMain">
     <Interaction.Behaviors>
         <EventTriggerBehavior EventName="Loaded">
-            <InvokeCommandAction Command="{Binding StartupCommand}"/>
+            <InvokeCommandAction Command="{Binding StartupCommand}" />
         </EventTriggerBehavior>
     </Interaction.Behaviors>
-    <DockPanel>
-        <main1:MainTitleBar DockPanel.Dock="Top"/>
-        <Grid Focusable="True">
-            <Grid.RowDefinitions>
-                <RowDefinition Height="40"/>
-                <RowDefinition Height="*"/>
-            </Grid.RowDefinitions>
-        
-            <tools:Toolbar Grid.Row="0" DataContext="{Binding .}"/>
-            <main1:ToolsPicker ZIndex="2" Grid.Row="1"
-                               Margin="10 0 0 0"
-                               HorizontalAlignment="Left"
-                               VerticalAlignment="Center"
-                               Tools="{Binding Path=ToolsSubViewModel.ToolSet}"/>
-            <DockControl Grid.Row="1" Layout="{Binding LayoutDockSubViewModel.Layout}"/>
-        </Grid>
-    </DockPanel>
-</UserControl>
+    <Grid>
+        <DockPanel>
+            <main1:MainTitleBar DockPanel.Dock="Top" />
+            <Grid Focusable="True">
+                <Grid.RowDefinitions>
+                    <RowDefinition Height="40" />
+                    <RowDefinition Height="*" />
+                </Grid.RowDefinitions>
+
+                <tools:Toolbar Grid.Row="0" DataContext="{Binding .}" />
+                <main1:ToolsPicker ZIndex="2" Grid.Row="1"
+                                   Margin="10 0 0 0"
+                                   HorizontalAlignment="Left"
+                                   VerticalAlignment="Center"
+                                   Tools="{Binding Path=ToolsSubViewModel.ToolSet}" />
+                <DockControl Grid.Row="1" Layout="{Binding LayoutDockSubViewModel.Layout}" />
+            </Grid>
+        </DockPanel>
+        <commandSearch:CommandSearchControl
+            IsVisible="{Binding SearchSubViewModel.SearchWindowOpen, Mode=TwoWay}"
+            SearchTerm="{Binding SearchSubViewModel.SearchTerm, Mode=TwoWay}"
+            HorizontalAlignment="Center"
+            Height="700"
+            MaxWidth="920" />
+    </Grid>
+</UserControl>

+ 0 - 13
src/PixiEditor.AvaloniaUI/Views/Shortcuts/ImportShortcutTemplatePopup.axaml

@@ -20,19 +20,6 @@
     ui:Translator.UseLanguageFlowDirection="True"
     ui:Translator.Key="IMPORT_FROM_TEMPLATE">
 
-    
-    <!--TODO figure out what these are for <Window.CommandBindings>
-        <CommandBinding
-            Command="{x:Static SystemCommands.CloseWindowCommand}"
-            CanExecute="CommandBinding_CanExecute"
-            Executed="CommandBinding_Executed_Close" />
-    </Window.CommandBindings>-->
-
-    <!--<WindowChrome.WindowChrome>
-        <WindowChrome CaptionHeight="32" GlassFrameThickness="0.1"
-                      ResizeBorderThickness="{x:Static SystemParameters.WindowResizeBorderThickness}" />
-    </WindowChrome.WindowChrome>-->
-
     <Grid>
         <Grid.RowDefinitions>
             <RowDefinition Height="32" />

+ 1 - 1
src/PixiEditor.AvaloniaUI/Views/Shortcuts/ShortcutsTemplateCard.axaml

@@ -7,7 +7,7 @@
     d:DesignWidth="800"
     d:DesignHeight="450"
     x:Class="PixiEditor.AvaloniaUI.Views.Shortcuts.ShortcutsTemplateCard">
-    <Border BorderThickness="1" Height="150" Width="150" Background="{StaticResource MainColor}" 
+    <Border BorderThickness="1" Height="150" Width="150" Background="{DynamicResource ThemeBackgroundBrush}"
             CornerRadius="15" PointerEntered="OnBorderPointerEntered" PointerExited="OnBorderPointerExited">
         <!-- TODO <Border.Triggers>
             <EventTrigger RoutedEvent="Border.MouseEnter">