Skip to content

Commit

Permalink
Add a command palette (#80)
Browse files Browse the repository at this point in the history
This PR adds a basic command palette.

## Changes

- `KeyModifiers` can now retrieve its constituent keys in a human-readable format, ordered by how keybindings are typically written down.
- `Keybind` stores its constituent keys, and overrides `ToString`.
- `VIRTUAL_KEY` has an extendion method to return it as a human-readable string (i.e., without the `VK_` prefix).
- Moved `FocusIndicatorWindow`'s logic for creating a borderless window to an extension method of `Microsoft.UI.Xaml.Window`, in `WindowExtensions`.
- `IWindow` can now focus and force windows to the top of the Z-index, with `FocusForceForeground`.

## Additions

A basic command palette, which has configurable matching (ranking and filters). It takes advantage of the command system added in #79.

The command palette can be activated on a given monitor. Currently, it activates on the currently focused monitor.

## General notes

Performance is lacking in the following scenario:

1. Query the command palette.
2. Delete some characters from the query.

There is a noticeable lag between deleting characters from the query, and the additional rows being shown.
  • Loading branch information
dalyIsaac authored May 28, 2022
1 parent ec3c65c commit b483dbe
Show file tree
Hide file tree
Showing 28 changed files with 1,315 additions and 31 deletions.
36 changes: 36 additions & 0 deletions Whim.sln
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.gitignore = .gitignore
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Whim.CommandPalette", "src\Whim.CommandPalette\Whim.CommandPalette.csproj", "{05AB1322-761E-4C82-BF41-9613CA563C5A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Whim.CommandPalette.Tests", "src\Whim.CommandPalette.Tests\Whim.CommandPalette.Tests.csproj", "{867E9712-E9FC-4C27-8014-1FD660236BBB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -198,6 +202,38 @@ Global
{C16D9C8A-A36E-430C-AB84-700919BC7259}.Release|x64.Build.0 = Release|Any CPU
{C16D9C8A-A36E-430C-AB84-700919BC7259}.Release|x86.ActiveCfg = Release|Any CPU
{C16D9C8A-A36E-430C-AB84-700919BC7259}.Release|x86.Build.0 = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|arm64.ActiveCfg = Debug|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|arm64.Build.0 = Debug|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|x64.ActiveCfg = Debug|x64
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|x64.Build.0 = Debug|x64
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|x86.ActiveCfg = Debug|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Debug|x86.Build.0 = Debug|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|Any CPU.Build.0 = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|arm64.ActiveCfg = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|arm64.Build.0 = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|x64.ActiveCfg = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|x64.Build.0 = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|x86.ActiveCfg = Release|Any CPU
{05AB1322-761E-4C82-BF41-9613CA563C5A}.Release|x86.Build.0 = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|arm64.ActiveCfg = Debug|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|arm64.Build.0 = Debug|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|x64.ActiveCfg = Debug|x64
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|x64.Build.0 = Debug|x64
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|x86.ActiveCfg = Debug|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Debug|x86.Build.0 = Debug|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|Any CPU.Build.0 = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|arm64.ActiveCfg = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|arm64.Build.0 = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|x64.ActiveCfg = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|x64.Build.0 = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|x86.ActiveCfg = Release|Any CPU
{867E9712-E9FC-4C27-8014-1FD660236BBB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
23 changes: 23 additions & 0 deletions src/Whim.CommandPalette.Tests/MatchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Moq;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Xunit;

namespace Whim.CommandPalette.Tests;

public class MatchTests
{
[Fact]
public void Match_NoKeybind()
{
Match match = new(new Mock<ICommand>().Object);

Assert.Null(match.Keys);
}

[Fact]
public void Match_Keybind()
{
Match match = new(new Mock<ICommand>().Object, new Keybind(KeyModifiers.LWin, VIRTUAL_KEY.VK_A));
Assert.Equal("LWin + A", match.Keys);
}
}
92 changes: 92 additions & 0 deletions src/Whim.CommandPalette.Tests/MostOftenUsedMatcherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using FluentAssertions;
using Moq;
using System.Collections.Generic;
using System.Linq;
using Xunit;

namespace Whim.CommandPalette.Tests;

public class MostOftenUsedMatcherTests
{
private readonly List<Match> _items = new();

private readonly MostOftenUsedMatcher _matcher = new();

private void CreateMatch(string commandName, uint count)
{
Mock<ICommand> commandMock = new();
commandMock.Setup(c => c.Identifier).Returns(commandName);
commandMock.Setup(c => c.Title).Returns(commandName);

Match match = new(commandMock.Object, new Mock<IKeybind>().Object);
_items.Add(match);

for (int i = 0; i < count; i++)
{
_matcher.OnMatchExecuted(match);
}
}

private static IList<HighlightedTextSegment> CreateHighlightedText(params HighlightedTextSegment[] segments)
{
List<HighlightedTextSegment> results = new();

foreach (HighlightedTextSegment segment in segments)
{
results.Add(segment);
}

return results;
}

public MostOftenUsedMatcherTests()
{
CreateMatch("foo", 1);
CreateMatch("bar", 2);
CreateMatch("baz", 3);
CreateMatch("qux", 4);
CreateMatch("quux", 5);
CreateMatch("corge", 6);
CreateMatch("grault", 7);
CreateMatch("garply", 8);
CreateMatch("waldo", 9);
CreateMatch("uxui", 10);
}

[Fact]
public void Match_Returns_Most_Often_Used_Items()
{
IEnumerable<PaletteItem> matches = _matcher.Match("", _items);

List<(string MatchCommand, IList<HighlightedTextSegment> Title)> expectedItems = new()
{
new("waldo", CreateHighlightedText(new HighlightedTextSegment("waldo", false))),
new("garply", CreateHighlightedText(new HighlightedTextSegment("garply", false))),
new("grault", CreateHighlightedText(new HighlightedTextSegment("grault", false))),
new("corge", CreateHighlightedText(new HighlightedTextSegment("corge", false))),
new("quux", CreateHighlightedText(new HighlightedTextSegment("quux", false))),
new("qux", CreateHighlightedText(new HighlightedTextSegment("qux", false))),
new("baz", CreateHighlightedText(new HighlightedTextSegment("baz", false))),
new("bar", CreateHighlightedText(new HighlightedTextSegment("bar", false))),
new("foo", CreateHighlightedText(new HighlightedTextSegment("foo", false))),
new("uxui", CreateHighlightedText(new HighlightedTextSegment("uxui", false))),
};

matches.Select(m => (m.Match.Command.Identifier, m.Title.Segments)).Should().BeEquivalentTo(expectedItems);
}

[Fact]
public void Match_Returns_Most_Often_Used_Items_With_Highlighted_Text()
{
IEnumerable<PaletteItem> matches = _matcher.Match("ux", _items);

List<(string MatchCommand, IList<HighlightedTextSegment> Title)> expectedItems = new()
{
("uxui", CreateHighlightedText(new HighlightedTextSegment("ux", true), new HighlightedTextSegment("ui", false))),
("quux", CreateHighlightedText(new HighlightedTextSegment("qu", false), new HighlightedTextSegment("ux", true))),
("qux", CreateHighlightedText(new HighlightedTextSegment("q", false), new HighlightedTextSegment("ux", true))),
};

matches.Select(m => (m.Match.Command.Identifier, m.Title.Segments)).Should().BeEquivalentTo(expectedItems);
}
}
89 changes: 89 additions & 0 deletions src/Whim.CommandPalette.Tests/MostRecentlyUsedMatcherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using FluentAssertions;
using Moq;
using System.Collections.Generic;
using System.Linq;
using Xunit;

namespace Whim.CommandPalette.Tests;

public class MostRecentlyUsedMatcherTests
{
private readonly List<Match> _items = new();

private readonly MostRecentlyUsedMatcher _matcher = new();

private void CreateMatch(string commandName)
{
Mock<ICommand> commandMock = new();
commandMock.Setup(c => c.Identifier).Returns(commandName);
commandMock.Setup(c => c.Title).Returns(commandName);

Match match = new(commandMock.Object, new Mock<IKeybind>().Object);
_items.Add(match);

_matcher.OnMatchExecuted(match);
}

private static IList<HighlightedTextSegment> CreateHighlightedText(params HighlightedTextSegment[] segments)
{
List<HighlightedTextSegment> results = new();

foreach (HighlightedTextSegment segment in segments)
{
results.Add(segment);
}

return results;
}

public MostRecentlyUsedMatcherTests()
{
CreateMatch("foo");
CreateMatch("bar");
CreateMatch("baz");
CreateMatch("qux");
CreateMatch("quux");
CreateMatch("corge");
CreateMatch("grault");
CreateMatch("garply");
CreateMatch("waldo");
CreateMatch("uxui");
}

[Fact]
public void Match_Returns_Most_Often_Used_Items()
{
IEnumerable<PaletteItem> matches = _matcher.Match("", _items);

List<(string MatchCommand, IList<HighlightedTextSegment> Title)> expectedItems = new()
{
new("uxui", CreateHighlightedText(new HighlightedTextSegment("uxui", false))),
new("waldo", CreateHighlightedText(new HighlightedTextSegment("waldo", false))),
new("garply", CreateHighlightedText(new HighlightedTextSegment("garply", false))),
new("grault", CreateHighlightedText(new HighlightedTextSegment("grault", false))),
new("corge", CreateHighlightedText(new HighlightedTextSegment("corge", false))),
new("quux", CreateHighlightedText(new HighlightedTextSegment("quux", false))),
new("qux", CreateHighlightedText(new HighlightedTextSegment("qux", false))),
new("baz", CreateHighlightedText(new HighlightedTextSegment("baz", false))),
new("bar", CreateHighlightedText(new HighlightedTextSegment("bar", false))),
new("foo", CreateHighlightedText(new HighlightedTextSegment("foo", false))),
};

matches.Select(m => (m.Match.Command.Identifier, m.Title.Segments)).Should().BeEquivalentTo(expectedItems);
}

[Fact]
public void Match_Returns_Most_Often_Used_Items_With_Highlighted_Text()
{
IEnumerable<PaletteItem> matches = _matcher.Match("ux", _items);

List<(string MatchCommand, IList<HighlightedTextSegment> Title)> expectedItems = new()
{
("uxui", CreateHighlightedText(new HighlightedTextSegment("ux", true), new HighlightedTextSegment("ui", false))),
("quux", CreateHighlightedText(new HighlightedTextSegment("qu", false), new HighlightedTextSegment("ux", true))),
("qux", CreateHighlightedText(new HighlightedTextSegment("q", false), new HighlightedTextSegment("ux", true))),
};

matches.Select(m => (m.Match.Command.Identifier, m.Title.Segments)).Should().BeEquivalentTo(expectedItems);
}
}
31 changes: 31 additions & 0 deletions src/Whim.CommandPalette.Tests/Whim.CommandPalette.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>

<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Whim.CommandPalette\Whim.CommandPalette.csproj" />
</ItemGroup>

</Project>
14 changes: 14 additions & 0 deletions src/Whim.CommandPalette/CommandPaletteConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Whim.CommandPalette;

public class CommandPaletteConfig
{
/// <summary>
/// The title of the command palette window.
/// </summary>
internal const string Title = "Whim Command Palette";

/// <summary>
/// The matcher to use when filtering for commands.
/// </summary>
public ICommandPaletteMatcher Matcher { get; set; } = new MostRecentlyUsedMatcher();
}
55 changes: 55 additions & 0 deletions src/Whim.CommandPalette/CommandPalettePlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Collections.Generic;

namespace Whim.CommandPalette;

public class CommandPalettePlugin : IPlugin
{
private readonly IConfigContext _configContext;
private CommandPaletteWindow? _commandPaletteWindow;
public CommandPaletteConfig Config { get; }

public CommandPalettePlugin(IConfigContext configContext, CommandPaletteConfig commandPaletteConfig)
{
_configContext = configContext;
Config = commandPaletteConfig;
}

public void PreInitialize()
{
_configContext.FilterManager.IgnoreTitleMatch(CommandPaletteConfig.Title);
}

public void PostInitialize()
{
// The window must be created on the UI thread (so don't do it in the constructor).
_commandPaletteWindow = new CommandPaletteWindow(_configContext, this);
}

/// <summary>
/// Activate the command palette window.
/// </summary>
/// <param name="items"></param>
public void Activate(IEnumerable<(ICommand, IKeybind?)>? items = null)
{
_commandPaletteWindow?.Activate(
items,
_configContext.MonitorManager.FocusedMonitor
);
}

/// <summary>
/// Hide the command palette.
/// </summary>
public void Hide()
{
_commandPaletteWindow?.Hide();
}

/// <summary>
/// Toggle the visibility of the command palette.
/// </summary>
public void Toggle()
{
_commandPaletteWindow?.Toggle();
}
}
29 changes: 29 additions & 0 deletions src/Whim.CommandPalette/CommandPaletteWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Window
x:Class="Whim.CommandPalette.CommandPaletteWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Whim.CommandPalette"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">

<Grid Background="{ThemeResource AcrylicBackgroundFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<TextBox
x:Name="TextEntry"
KeyDown="TextEntry_KeyDown"
PlaceholderText="Start typing..."
TextChanged="TextEntry_TextChanged" />

<ListView
x:Name="ListViewItems"
Grid.Row="1"
IsTabStop="False"
ItemClick="CommandListItems_ItemClick"
SelectionMode="Single" />
</Grid>
</Window>
Loading

0 comments on commit b483dbe

Please sign in to comment.