From b483dbe4d83ba4852eff1546a71e05d930c20bcc Mon Sep 17 00:00:00 2001 From: Isaac Daly Date: Sat, 28 May 2022 21:50:05 +1000 Subject: [PATCH] Add a command palette (#80) 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. --- Whim.sln | 36 +++ src/Whim.CommandPalette.Tests/MatchTests.cs | 23 ++ .../MostOftenUsedMatcherTests.cs | 92 +++++++ .../MostRecentlyUsedMatcherTests.cs | 89 ++++++ .../Whim.CommandPalette.Tests.csproj | 31 +++ .../CommandPaletteConfig.cs | 14 + .../CommandPalettePlugin.cs | 55 ++++ .../CommandPaletteWindow.xaml | 29 ++ .../CommandPaletteWindow.xaml.cs | 260 ++++++++++++++++++ .../ICommandPaletteMatcher.cs | 23 ++ src/Whim.CommandPalette/Model.cs | 54 ++++ .../MostOftenUsedMatcher.cs | 93 +++++++ .../MostRecentlyUsedMatcher.cs | 86 ++++++ src/Whim.CommandPalette/PaletteRow.xaml | 59 ++++ src/Whim.CommandPalette/PaletteRow.xaml.cs | 80 ++++++ .../Whim.CommandPalette.csproj | 48 ++++ .../FocusIndicatorException.cs | 14 - .../FocusIndicatorWindow.xaml.cs | 18 +- src/Whim.Runner/Whim.Runner.csproj | 8 + src/Whim.Tests/KeyModifiersTests.cs | 39 +++ src/Whim.Tests/KeybindTests.cs | 39 +++ src/Whim/Commands/KeyModifiers.cs | 57 ++++ src/Whim/Commands/Keybind.cs | 21 +- src/Whim/Commands/VirtualKeyExtensions.cs | 20 ++ src/Whim/Native/InitializeWindowException.cs | 12 + src/Whim/Native/WindowExtensions.cs | 34 +++ src/Whim/Window/IWindow.cs | 5 + src/Whim/Window/Window.cs | 7 + 28 files changed, 1315 insertions(+), 31 deletions(-) create mode 100644 src/Whim.CommandPalette.Tests/MatchTests.cs create mode 100644 src/Whim.CommandPalette.Tests/MostOftenUsedMatcherTests.cs create mode 100644 src/Whim.CommandPalette.Tests/MostRecentlyUsedMatcherTests.cs create mode 100644 src/Whim.CommandPalette.Tests/Whim.CommandPalette.Tests.csproj create mode 100644 src/Whim.CommandPalette/CommandPaletteConfig.cs create mode 100644 src/Whim.CommandPalette/CommandPalettePlugin.cs create mode 100644 src/Whim.CommandPalette/CommandPaletteWindow.xaml create mode 100644 src/Whim.CommandPalette/CommandPaletteWindow.xaml.cs create mode 100644 src/Whim.CommandPalette/ICommandPaletteMatcher.cs create mode 100644 src/Whim.CommandPalette/Model.cs create mode 100644 src/Whim.CommandPalette/MostOftenUsedMatcher.cs create mode 100644 src/Whim.CommandPalette/MostRecentlyUsedMatcher.cs create mode 100644 src/Whim.CommandPalette/PaletteRow.xaml create mode 100644 src/Whim.CommandPalette/PaletteRow.xaml.cs create mode 100644 src/Whim.CommandPalette/Whim.CommandPalette.csproj delete mode 100644 src/Whim.FocusIndicator/FocusIndicatorException.cs create mode 100644 src/Whim.Tests/KeyModifiersTests.cs create mode 100644 src/Whim.Tests/KeybindTests.cs create mode 100644 src/Whim/Commands/VirtualKeyExtensions.cs create mode 100644 src/Whim/Native/InitializeWindowException.cs diff --git a/Whim.sln b/Whim.sln index fe8f4b0b5..864423db0 100644 --- a/Whim.sln +++ b/Whim.sln @@ -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 @@ -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 diff --git a/src/Whim.CommandPalette.Tests/MatchTests.cs b/src/Whim.CommandPalette.Tests/MatchTests.cs new file mode 100644 index 000000000..2efb8c3e7 --- /dev/null +++ b/src/Whim.CommandPalette.Tests/MatchTests.cs @@ -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().Object); + + Assert.Null(match.Keys); + } + + [Fact] + public void Match_Keybind() + { + Match match = new(new Mock().Object, new Keybind(KeyModifiers.LWin, VIRTUAL_KEY.VK_A)); + Assert.Equal("LWin + A", match.Keys); + } +} diff --git a/src/Whim.CommandPalette.Tests/MostOftenUsedMatcherTests.cs b/src/Whim.CommandPalette.Tests/MostOftenUsedMatcherTests.cs new file mode 100644 index 000000000..704c87789 --- /dev/null +++ b/src/Whim.CommandPalette.Tests/MostOftenUsedMatcherTests.cs @@ -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 _items = new(); + + private readonly MostOftenUsedMatcher _matcher = new(); + + private void CreateMatch(string commandName, uint count) + { + Mock commandMock = new(); + commandMock.Setup(c => c.Identifier).Returns(commandName); + commandMock.Setup(c => c.Title).Returns(commandName); + + Match match = new(commandMock.Object, new Mock().Object); + _items.Add(match); + + for (int i = 0; i < count; i++) + { + _matcher.OnMatchExecuted(match); + } + } + + private static IList CreateHighlightedText(params HighlightedTextSegment[] segments) + { + List 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 matches = _matcher.Match("", _items); + + List<(string MatchCommand, IList 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 matches = _matcher.Match("ux", _items); + + List<(string MatchCommand, IList 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); + } +} diff --git a/src/Whim.CommandPalette.Tests/MostRecentlyUsedMatcherTests.cs b/src/Whim.CommandPalette.Tests/MostRecentlyUsedMatcherTests.cs new file mode 100644 index 000000000..77d67d7cc --- /dev/null +++ b/src/Whim.CommandPalette.Tests/MostRecentlyUsedMatcherTests.cs @@ -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 _items = new(); + + private readonly MostRecentlyUsedMatcher _matcher = new(); + + private void CreateMatch(string commandName) + { + Mock commandMock = new(); + commandMock.Setup(c => c.Identifier).Returns(commandName); + commandMock.Setup(c => c.Title).Returns(commandName); + + Match match = new(commandMock.Object, new Mock().Object); + _items.Add(match); + + _matcher.OnMatchExecuted(match); + } + + private static IList CreateHighlightedText(params HighlightedTextSegment[] segments) + { + List 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 matches = _matcher.Match("", _items); + + List<(string MatchCommand, IList 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 matches = _matcher.Match("ux", _items); + + List<(string MatchCommand, IList 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); + } +} diff --git a/src/Whim.CommandPalette.Tests/Whim.CommandPalette.Tests.csproj b/src/Whim.CommandPalette.Tests/Whim.CommandPalette.Tests.csproj new file mode 100644 index 000000000..6ea8682e0 --- /dev/null +++ b/src/Whim.CommandPalette.Tests/Whim.CommandPalette.Tests.csproj @@ -0,0 +1,31 @@ + + + + net6.0-windows10.0.19041.0 + enable + + false + + AnyCPU;x64 + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Whim.CommandPalette/CommandPaletteConfig.cs b/src/Whim.CommandPalette/CommandPaletteConfig.cs new file mode 100644 index 000000000..6dd304c8b --- /dev/null +++ b/src/Whim.CommandPalette/CommandPaletteConfig.cs @@ -0,0 +1,14 @@ +namespace Whim.CommandPalette; + +public class CommandPaletteConfig +{ + /// + /// The title of the command palette window. + /// + internal const string Title = "Whim Command Palette"; + + /// + /// The matcher to use when filtering for commands. + /// + public ICommandPaletteMatcher Matcher { get; set; } = new MostRecentlyUsedMatcher(); +} diff --git a/src/Whim.CommandPalette/CommandPalettePlugin.cs b/src/Whim.CommandPalette/CommandPalettePlugin.cs new file mode 100644 index 000000000..960ce1674 --- /dev/null +++ b/src/Whim.CommandPalette/CommandPalettePlugin.cs @@ -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); + } + + /// + /// Activate the command palette window. + /// + /// + public void Activate(IEnumerable<(ICommand, IKeybind?)>? items = null) + { + _commandPaletteWindow?.Activate( + items, + _configContext.MonitorManager.FocusedMonitor + ); + } + + /// + /// Hide the command palette. + /// + public void Hide() + { + _commandPaletteWindow?.Hide(); + } + + /// + /// Toggle the visibility of the command palette. + /// + public void Toggle() + { + _commandPaletteWindow?.Toggle(); + } +} diff --git a/src/Whim.CommandPalette/CommandPaletteWindow.xaml b/src/Whim.CommandPalette/CommandPaletteWindow.xaml new file mode 100644 index 000000000..3b0213dd9 --- /dev/null +++ b/src/Whim.CommandPalette/CommandPaletteWindow.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/src/Whim.CommandPalette/CommandPaletteWindow.xaml.cs b/src/Whim.CommandPalette/CommandPaletteWindow.xaml.cs new file mode 100644 index 000000000..d842f4aaf --- /dev/null +++ b/src/Whim.CommandPalette/CommandPaletteWindow.xaml.cs @@ -0,0 +1,260 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Whim.CommandPalette; + +public sealed partial class CommandPaletteWindow : Microsoft.UI.Xaml.Window +{ + private readonly IConfigContext _configContext; + private readonly CommandPalettePlugin _plugin; + + private readonly IWindow _window; + private IMonitor? _monitor; + public bool IsVisible => _monitor != null; + + private readonly ObservableCollection _paletteRows = new(); + private readonly List _unusedRows = new(); + + /// + /// The current commands from which is derived. + /// + private readonly List _allCommands = new(); + + public CommandPaletteWindow(IConfigContext configContext, CommandPalettePlugin plugin) + { + _configContext = configContext; + _plugin = plugin; + _window = this.InitializeBorderlessWindow("Whim.CommandPalette", "CommandPaletteWindow", _configContext); + + Title = CommandPaletteConfig.Title; + ListViewItems.ItemsSource = _paletteRows; + + // Populate the commands to reduce the first render time. + Populate(); + UpdateMatches(); + } + + /// + /// Populate with all the current commands. + /// + private void Populate(IEnumerable<(ICommand, IKeybind?)>? items = null) + { + Logger.Debug($"Populating the current list of all commands."); + int idx = 0; + foreach ((ICommand command, IKeybind? keybind) in items ?? _configContext.CommandManager) + { + if (!command.CanExecute()) + { + continue; + } + + if (idx < _allCommands.Count) + { + if (_allCommands[idx].Command != command) + { + _allCommands[idx] = new Match(command, keybind); + } + } + else + { + _allCommands.Add(new Match(command, keybind)); + } + + idx++; + } + + for (; idx < _allCommands.Count; idx++) + { + _allCommands.RemoveAt(_allCommands.Count - 1); + } + } + + /// + /// Activate the command palette. + /// + /// + /// The items to activate the command palette with. + /// These items will be passed to the to determine the matches. + /// For example, when the query is empty, typically all items will be matched and be displayed. + /// + /// The monitor to display the command palette on. + public void Activate(IEnumerable<(ICommand, IKeybind?)>? items = null, IMonitor? monitor = null) + { + Logger.Debug("Activating command palette"); + + monitor ??= _configContext.MonitorManager.FocusedMonitor; + if (monitor == _monitor) + { + return; + } + _monitor = monitor; + TextEntry.Text = ""; + + Populate(items); + UpdateMatches(); + + int width = 680; + int height = 680; + + ILocation windowLocation = new Location( + x: monitor.X + (monitor.Width / 2) - (width / 2), + y: monitor.Y + (height / 4), + width: width, + height: height + ); + + base.Activate(); + TextEntry.Focus(FocusState.Programmatic); + Win32Helper.SetWindowPos( + new WindowLocation(_window, windowLocation, WindowState.Normal), + _window.Handle + ); + _window.FocusForceForeground(); + } + + /// + /// Hide the command palette. Wipe the query text and clear the matches. + /// + public void Hide() + { + Logger.Debug("Hiding command palette"); + _window.Hide(); + _monitor = null; + TextEntry.Text = ""; + _allCommands.Clear(); + } + + /// + /// Toggle the visibility of the command palette. + /// + public void Toggle() + { + Logger.Debug("Toggling command palette"); + if (IsVisible) + { + Hide(); + } + else + { + Activate(); + } + } + + /// + /// Handler for when the user presses down a key. + /// + /// + /// + private void TextEntry_KeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e) + { + Logger.Debug("Command palette key down: {0}", e.Key.ToString()); + int selectedIndex = ListViewItems.SelectedIndex; + switch (e.Key) + { + case Windows.System.VirtualKey.Down: + // Go down the command palette's list. + ListViewItems.SelectedIndex = (selectedIndex + 1) % _paletteRows.Count; + ListViewItems.ScrollIntoView(ListViewItems.SelectedItem); + break; + + case Windows.System.VirtualKey.Up: + // Go up the command palette's list. + ListViewItems.SelectedIndex = (selectedIndex + _paletteRows.Count - 1) % _paletteRows.Count; + ListViewItems.ScrollIntoView(ListViewItems.SelectedItem); + break; + + case Windows.System.VirtualKey.Enter: + ExecuteCommand(); + break; + + case Windows.System.VirtualKey.Escape: + Hide(); + break; + default: + break; + } + } + + private void TextEntry_TextChanged(object sender, TextChangedEventArgs e) + { + UpdateMatches(); + } + + /// + /// Update the matches shown to the user. + /// Effort has been made to reduce the amount of time spent executing this method. + /// It can likely be improved (help is welcomed). + /// + private void UpdateMatches() + { + Logger.Debug("Updating command palette matches"); + string query = TextEntry.Text; + int idx = 0; + + foreach (PaletteItem item in _plugin.Config.Matcher.Match(query, _allCommands)) + { + Logger.Verbose($"Matched {item.Match.Command.Title}"); + if (idx < _paletteRows.Count) + { + // Update the existing row. + _paletteRows[idx].Update(item); + } + else if (_unusedRows.Count > 0) + { + // Restoring the unused row. + PaletteRow row = _unusedRows[^1]; + row.Update(item); + + _paletteRows.Add(row); + + _unusedRows.RemoveAt(_unusedRows.Count - 1); + } + else + { + // Add a new row. + PaletteRow row = new(item); + _paletteRows.Add(row); + row.Initialize(); + } + idx++; + + Logger.Verbose($"Finished updating {item.Match.Command.Title}"); + } + + // If there are more items than we have space for, remove the last ones. + int count = _paletteRows.Count; + for (; idx < count; idx++) + { + _unusedRows.Add(_paletteRows[^1]); + _paletteRows.RemoveAt(_paletteRows.Count - 1); + } + + Logger.Verbose($"Command palette match count: {_paletteRows.Count}"); + + ListViewItems.SelectedIndex = _paletteRows.Count > 0 ? 0 : -1; + } + + private void CommandListItems_ItemClick(object sender, ItemClickEventArgs e) + { + Logger.Debug("Command palette item clicked"); + ListViewItems.SelectedItem = e.ClickedItem; + ExecuteCommand(); + } + + private void ExecuteCommand() + { + Logger.Debug("Executing command"); + if (ListViewItems.SelectedIndex < 0) + { + Hide(); + } + + Match match = _paletteRows[ListViewItems.SelectedIndex].Model.Match; + + match.Command.TryExecute(); + _plugin.Config.Matcher.OnMatchExecuted(match); + Hide(); + } +} diff --git a/src/Whim.CommandPalette/ICommandPaletteMatcher.cs b/src/Whim.CommandPalette/ICommandPaletteMatcher.cs new file mode 100644 index 000000000..be4afbbb7 --- /dev/null +++ b/src/Whim.CommandPalette/ICommandPaletteMatcher.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Whim.CommandPalette; + +/// +/// A matcher is used by the command palette to find commands that match a given input. +/// +public interface ICommandPaletteMatcher +{ + /// + /// Matcher returns an ordered list of filtered matches for the . + /// + public IEnumerable Match( + string query, + IEnumerable items + ); + + /// + /// Called when a match has been executed. This is used by the + /// implementation to update relevant internal state. + /// + public void OnMatchExecuted(Match match); +} diff --git a/src/Whim.CommandPalette/Model.cs b/src/Whim.CommandPalette/Model.cs new file mode 100644 index 000000000..0266239d9 --- /dev/null +++ b/src/Whim.CommandPalette/Model.cs @@ -0,0 +1,54 @@ +using Microsoft.UI.Text; +using Microsoft.UI.Xaml.Documents; +using System.Collections.Generic; +using Windows.UI.Text; + +namespace Whim.CommandPalette; + +/// +/// A single segment of text, which can be highlighted. +/// +/// +/// +public record struct HighlightedTextSegment(string Text, bool IsHighlighted) +{ + public Run ToRun() + { + string matchText = Text; + FontWeight fontWeight = IsHighlighted ? FontWeights.Bold : FontWeights.Normal; + return new() { Text = matchText, FontWeight = fontWeight }; + } +} + +/// +/// The segments which make up the highlighted text. +/// +public record HighlightedText +{ + public IList Segments { get; } = new List(); +} + +/// +/// A command and associated keybind. +/// +public class Match +{ + public ICommand Command { get; } + public string? Keys { get; } + + public Match(ICommand command, IKeybind? keybind = null) + { + Command = command; + Keys = keybind?.ToString(); + } + + public override int GetHashCode() => Command.GetHashCode(); +} + +/// +/// An item stored in the command palette, consisting of a match and associated highlighted title +/// text. +/// +/// +/// +public record PaletteItem(Match Match, HighlightedText Title); diff --git a/src/Whim.CommandPalette/MostOftenUsedMatcher.cs b/src/Whim.CommandPalette/MostOftenUsedMatcher.cs new file mode 100644 index 000000000..bdaacc8b5 --- /dev/null +++ b/src/Whim.CommandPalette/MostOftenUsedMatcher.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; + +namespace Whim.CommandPalette; + +/// +/// MostOftenUsedMatcher will return matches in the order of the most often used. +/// +public class MostOftenUsedMatcher : ICommandPaletteMatcher +{ + private record struct MatchData(PaletteItem MatchItem, uint Count); + + private readonly Dictionary _commandExecutionCount = new(); + + public IEnumerable Match( + string query, + IEnumerable items + ) + { + query = query.Trim(); + List filteredItems = new(); + foreach (Match match in items) + { + int startIdx = match.Command.Title.IndexOf(query, StringComparison.OrdinalIgnoreCase); + if (startIdx == -1) + { + continue; + } + + // Add the match to the list of matches, and highlight the matching text. + HighlightedText highlightedTitle = new(); + string title = match.Command.Title; + + // If the query is empty, add the entire title as a single segment. + if (query.Length == 0) + { + highlightedTitle.Segments.Add(new HighlightedTextSegment(title, false)); + } + else + { + // Add the text from the start of the title to the start of the query. + if (startIdx != 0) + { + highlightedTitle.Segments.Add( + new HighlightedTextSegment( + title[..startIdx], + false + ) + ); + } + + // Add the highlighted query text. + highlightedTitle.Segments.Add( + new HighlightedTextSegment( + title.Substring(startIdx, query.Length), + true + ) + ); + + // Add the text from the end of the query to the end of the title. + if (startIdx + query.Length != title.Length) + { + highlightedTitle.Segments.Add( + new HighlightedTextSegment( + title[(startIdx + query.Length)..], + false + ) + ); + } + } + + // Get the number of times the command has been executed. + _commandExecutionCount.TryGetValue(match.Command.Identifier, out uint count); + filteredItems.Add(new MatchData(new PaletteItem(match, highlightedTitle), count)); + } + + // Sort the filtered items by the number of times the command has been executed. + filteredItems.Sort((a, b) => -a.Count.CompareTo(b.Count)); + + // Return the filtered items. + foreach (MatchData data in filteredItems) + { + yield return data.MatchItem; + } + } + + public void OnMatchExecuted(Match match) + { + string id = match.Command.Identifier; + _commandExecutionCount.TryGetValue(id, out uint count); + _commandExecutionCount[id] = count + 1; + } +} diff --git a/src/Whim.CommandPalette/MostRecentlyUsedMatcher.cs b/src/Whim.CommandPalette/MostRecentlyUsedMatcher.cs new file mode 100644 index 000000000..f8f05916c --- /dev/null +++ b/src/Whim.CommandPalette/MostRecentlyUsedMatcher.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; + +namespace Whim.CommandPalette; + +public class MostRecentlyUsedMatcher : ICommandPaletteMatcher +{ + private record struct MatchData(PaletteItem MatchItem, long LastUsed); + + private readonly Dictionary _commandExecutionTime = new(); + + public IEnumerable Match(string query, IEnumerable items) + { + query = query.Trim(); + List filteredItems = new(); + foreach (Match match in items) + { + int startIdx = match.Command.Title.IndexOf(query, StringComparison.OrdinalIgnoreCase); + if (startIdx == -1) + { + continue; + } + + // Add the match to the list of matches, and highlight the matching text. + HighlightedText highlightedTitle = new(); + string title = match.Command.Title; + + // If the query is empty, add the entire title as a single segment. + if (query.Length == 0) + { + highlightedTitle.Segments.Add(new HighlightedTextSegment(title, false)); + } + else + { + // Add the text from the start of the title to the start of the query. + if (startIdx != 0) + { + highlightedTitle.Segments.Add( + new HighlightedTextSegment( + title[..startIdx], + false + ) + ); + } + + // Add the highlighted query text. + highlightedTitle.Segments.Add( + new HighlightedTextSegment( + title.Substring(startIdx, query.Length), + true + ) + ); + + // Add the text from the end of the query to the end of the title. + if (startIdx + query.Length != title.Length) + { + highlightedTitle.Segments.Add( + new HighlightedTextSegment( + title[(startIdx + query.Length)..], + false + ) + ); + } + } + + // Get the time the command was last executed. + _commandExecutionTime.TryGetValue(match.Command.Identifier, out long time); + filteredItems.Add(new MatchData(new PaletteItem(match, highlightedTitle), time)); + } + + // Sort the filtered items by the last time the command was executed. + filteredItems.Sort((a, b) => -a.LastUsed.CompareTo(b.LastUsed)); + + // Return the filtered items. + foreach (MatchData data in filteredItems) + { + yield return data.MatchItem; + } + } + + public void OnMatchExecuted(Match match) + { + string id = match.Command.Identifier; + _commandExecutionTime[id] = DateTime.Now.Ticks; + } +} diff --git a/src/Whim.CommandPalette/PaletteRow.xaml b/src/Whim.CommandPalette/PaletteRow.xaml new file mode 100644 index 000000000..a42a0e6df --- /dev/null +++ b/src/Whim.CommandPalette/PaletteRow.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Whim.CommandPalette/PaletteRow.xaml.cs b/src/Whim.CommandPalette/PaletteRow.xaml.cs new file mode 100644 index 000000000..9b50a8bcf --- /dev/null +++ b/src/Whim.CommandPalette/PaletteRow.xaml.cs @@ -0,0 +1,80 @@ +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; +using System.Collections.Generic; + +namespace Whim.CommandPalette; + +/// +/// A palette row is a single command title, and an optional associated keybind. +/// +public sealed partial class PaletteRow : UserControl +{ + public PaletteItem Model { get; private set; } + + public PaletteRow(PaletteItem item) + { + Model = item; + UIElementExtensions.InitializeComponent(this, "Whim.CommandPalette", "PaletteRow"); + } + + public void Initialize() + { + SetTitle(); + SetKeybinds(); + } + + public void Update(PaletteItem item) + { + Logger.Debug("Updating with a new item"); + Model = item; + SetTitle(); + } + + /// + /// Update the title based on the model's title segments. + /// Efforts have been made to reduce the amount of time spent in this method. + /// + private void SetTitle() + { + Logger.Debug("Setting title"); + InlineCollection inlines = CommandTitle.Inlines; + IList segments = Model.Title.Segments; + + int idx; + for (idx = 0; idx < segments.Count; idx++) + { + HighlightedTextSegment seg = segments[idx]; + Run run = seg.ToRun(); + + if (idx < inlines.Count) + { + // Only update the run if it's different. + if (run != inlines[idx]) + { + inlines[idx] = run; + } + } + else + { + // Add the run, because there's no space. + inlines.Add(run); + } + } + + // Remove excess runs. + int inlinesCount = inlines.Count; + for (; idx < inlinesCount; idx++) + { + inlines.RemoveAt(inlines.Count - 1); + } + } + + private void SetKeybinds() + { + Logger.Debug("Setting keybinds"); + if (Model.Match.Keys != null) + { + CommandKeybind.Text = Model.Match.Keys; + } + } +} diff --git a/src/Whim.CommandPalette/Whim.CommandPalette.csproj b/src/Whim.CommandPalette/Whim.CommandPalette.csproj new file mode 100644 index 000000000..8ac33443c --- /dev/null +++ b/src/Whim.CommandPalette/Whim.CommandPalette.csproj @@ -0,0 +1,48 @@ + + + net6.0-windows10.0.19041.0 + enable + win10-x64;win10-arm64 + x64;arm64 + true + All + All + None + All + All + All + All + All + All + All + All + All + true + + + + + + + + + + + + + + + + + + + MSBuild:Compile + + + + + + MSBuild:Compile + + + diff --git a/src/Whim.FocusIndicator/FocusIndicatorException.cs b/src/Whim.FocusIndicator/FocusIndicatorException.cs deleted file mode 100644 index 36ba5823a..000000000 --- a/src/Whim.FocusIndicator/FocusIndicatorException.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Whim.FocusIndicator; - -[Serializable] -public class FocusIndicatorException : Exception -{ - public FocusIndicatorException() { } - public FocusIndicatorException(string message) : base(message) { } - public FocusIndicatorException(string message, Exception inner) : base(message, inner) { } - protected FocusIndicatorException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } -} \ No newline at end of file diff --git a/src/Whim.FocusIndicator/FocusIndicatorWindow.xaml.cs b/src/Whim.FocusIndicator/FocusIndicatorWindow.xaml.cs index 448e2ff72..394caf1fe 100644 --- a/src/Whim.FocusIndicator/FocusIndicatorWindow.xaml.cs +++ b/src/Whim.FocusIndicator/FocusIndicatorWindow.xaml.cs @@ -1,6 +1,4 @@ -using Windows.Win32.Foundation; - -namespace Whim.FocusIndicator; +namespace Whim.FocusIndicator; /// /// An empty window that can be used on its own or navigated to within a Frame. @@ -13,21 +11,9 @@ public sealed partial class FocusIndicatorWindow : Microsoft.UI.Xaml.Window public FocusIndicatorWindow(IConfigContext configContext, FocusIndicatorConfig focusIndicatorConfig) { FocusIndicatorConfig = focusIndicatorConfig; - UIElementExtensions.InitializeComponent(this, "Whim.FocusIndicator", "FocusIndicatorWindow"); + _window = this.InitializeBorderlessWindow("Whim.FocusIndicator", "FocusIndicatorWindow", configContext); Title = FocusIndicatorConfig.Title; - - HWND hwnd = new(WinRT.Interop.WindowNative.GetWindowHandle(this)); - IWindow? window = Window.CreateWindow(this.GetHandle(), configContext); - if (window == null) - { - throw new FocusIndicatorException("Window was unexpectedly null"); - } - _window = window; - - Win32Helper.HideCaptionButtons(hwnd); - Win32Helper.SetWindowCorners(hwnd); - this.SetIsShownInSwitchers(false); } /// diff --git a/src/Whim.Runner/Whim.Runner.csproj b/src/Whim.Runner/Whim.Runner.csproj index b9b203e87..0852c1940 100644 --- a/src/Whim.Runner/Whim.Runner.csproj +++ b/src/Whim.Runner/Whim.Runner.csproj @@ -111,6 +111,14 @@ + + + + + + + + diff --git a/src/Whim.Tests/KeyModifiersTests.cs b/src/Whim.Tests/KeyModifiersTests.cs new file mode 100644 index 000000000..29fddf0ca --- /dev/null +++ b/src/Whim.Tests/KeyModifiersTests.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Xunit; + +namespace Whim.Tests; + +public class KeyModifiersTests +{ + [Theory] + [InlineData(KeyModifiers.None, new string[] { })] + [InlineData(KeyModifiers.LControl, new string[] { "LCtrl" })] + [InlineData(KeyModifiers.RControl, new string[] { "RCtrl" })] + [InlineData(KeyModifiers.LShift, new string[] { "LShift" })] + [InlineData(KeyModifiers.RShift, new string[] { "RShift" })] + [InlineData(KeyModifiers.LAlt, new string[] { "LAlt" })] + [InlineData(KeyModifiers.RAlt, new string[] { "RAlt" })] + [InlineData(KeyModifiers.LWin, new string[] { "LWin" })] + [InlineData(KeyModifiers.RWin, new string[] { "RWin" })] + [InlineData(KeyModifiers.LControl | KeyModifiers.LShift, new string[] { "LCtrl", "LShift" })] + [InlineData(KeyModifiers.LControl | KeyModifiers.LAlt, new string[] { "LCtrl", "LAlt" })] + [InlineData(KeyModifiers.LControl | KeyModifiers.LWin, new string[] { "LWin", "LCtrl" })] + [InlineData(KeyModifiers.LControl | KeyModifiers.LShift | KeyModifiers.LAlt, new string[] { "LCtrl", "LShift", "LAlt" })] + [InlineData( + KeyModifiers.LWin | + KeyModifiers.RWin | + KeyModifiers.LControl | + KeyModifiers.RControl | + KeyModifiers.LAlt | + KeyModifiers.RAlt | + KeyModifiers.LShift | + KeyModifiers.RShift, + new string[] { "LWin", "RWin", "LCtrl", "RCtrl", "LShift", "RShift", "LAlt", "RAlt" } + )] + public void GetParts_ReturnsCorrectParts(KeyModifiers modifiers, string[] expected) + { + IEnumerable parts = modifiers.GetParts(); + + Assert.Equal(expected, parts); + } +} diff --git a/src/Whim.Tests/KeybindTests.cs b/src/Whim.Tests/KeybindTests.cs new file mode 100644 index 000000000..36a1650c0 --- /dev/null +++ b/src/Whim.Tests/KeybindTests.cs @@ -0,0 +1,39 @@ +using Windows.Win32.UI.Input.KeyboardAndMouse; +using Xunit; + +namespace Whim.Tests; + +public class KeybindTests +{ + [Theory] + [InlineData(KeyModifiers.None, VIRTUAL_KEY.VK_A, "A")] + [InlineData(KeyModifiers.LShift, VIRTUAL_KEY.VK_A, "LShift + A")] + [InlineData(KeyModifiers.LControl, VIRTUAL_KEY.VK_A, "LCtrl + A")] + [InlineData(KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, "LAlt + A")] + [InlineData(KeyModifiers.LShift | KeyModifiers.LControl, VIRTUAL_KEY.VK_A, "LCtrl + LShift + A")] + [InlineData(KeyModifiers.LControl | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, "LCtrl + LAlt + A")] + [InlineData(KeyModifiers.LShift | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, "LShift + LAlt + A")] + [InlineData(KeyModifiers.LControl | KeyModifiers.LShift | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, "LCtrl + LShift + LAlt + A")] + [InlineData(KeyModifiers.LWin | KeyModifiers.LControl | KeyModifiers.LShift | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, "LWin + LCtrl + LShift + LAlt + A")] + public void Keybind_ToString_ReturnsCorrectString(KeyModifiers modifiers, VIRTUAL_KEY key, string expected) + { + Keybind keybind = new(modifiers, key); + Assert.Equal(expected, keybind.ToString()); + } + + [Theory] + [InlineData(KeyModifiers.None, VIRTUAL_KEY.VK_A, new string[] { "A" })] + [InlineData(KeyModifiers.LShift, VIRTUAL_KEY.VK_A, new string[] { "LShift", "A" })] + [InlineData(KeyModifiers.LControl, VIRTUAL_KEY.VK_A, new string[] { "LCtrl", "A" })] + [InlineData(KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, new string[] { "LAlt", "A" })] + [InlineData(KeyModifiers.LShift | KeyModifiers.LControl, VIRTUAL_KEY.VK_A, new string[] { "LCtrl", "LShift", "A" })] + [InlineData(KeyModifiers.LControl | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, new string[] { "LCtrl", "LAlt", "A" })] + [InlineData(KeyModifiers.LShift | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, new string[] { "LShift", "LAlt", "A" })] + [InlineData(KeyModifiers.LControl | KeyModifiers.LShift | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, new string[] { "LCtrl", "LShift", "LAlt", "A" })] + [InlineData(KeyModifiers.LWin | KeyModifiers.LControl | KeyModifiers.LShift | KeyModifiers.LAlt, VIRTUAL_KEY.VK_A, new string[] { "LWin", "LCtrl", "LShift", "LAlt", "A" })] + public void Keybind_AllKeys_ReturnsCorrect(KeyModifiers modifiers, VIRTUAL_KEY key, string[] expected) + { + Keybind keybind = new(modifiers, key); + Assert.Equal(expected, keybind.AllKeys); + } +} diff --git a/src/Whim/Commands/KeyModifiers.cs b/src/Whim/Commands/KeyModifiers.cs index 280d8a02c..31bff64d5 100644 --- a/src/Whim/Commands/KeyModifiers.cs +++ b/src/Whim/Commands/KeyModifiers.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace Whim; @@ -19,3 +20,59 @@ public enum KeyModifiers LWin = 64, RWin = 128, } + +public static class KeyModifiersExtensions +{ + /// + /// Get an of names, ordered + /// by how keybindings are normally shown. + /// + /// + /// + public static IEnumerable GetParts(this KeyModifiers modifiers) + { + List parts = new(); + + if (modifiers.HasFlag(KeyModifiers.LWin)) + { + parts.Add("LWin"); + } + + if (modifiers.HasFlag(KeyModifiers.RWin)) + { + parts.Add("RWin"); + } + + if (modifiers.HasFlag(KeyModifiers.LControl)) + { + parts.Add("LCtrl"); + } + + if (modifiers.HasFlag(KeyModifiers.RControl)) + { + parts.Add("RCtrl"); + } + + if (modifiers.HasFlag(KeyModifiers.LShift)) + { + parts.Add("LShift"); + } + + if (modifiers.HasFlag(KeyModifiers.RShift)) + { + parts.Add("RShift"); + } + + if (modifiers.HasFlag(KeyModifiers.LAlt)) + { + parts.Add("LAlt"); + } + + if (modifiers.HasFlag(KeyModifiers.RAlt)) + { + parts.Add("RAlt"); + } + + return parts; + } +} diff --git a/src/Whim/Commands/Keybind.cs b/src/Whim/Commands/Keybind.cs index 4c718ba2f..85b16fb7b 100644 --- a/src/Whim/Commands/Keybind.cs +++ b/src/Whim/Commands/Keybind.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using Windows.Win32.UI.Input.KeyboardAndMouse; namespace Whim; @@ -8,10 +10,27 @@ public class Keybind : IKeybind public KeyModifiers Modifiers { get; } public VIRTUAL_KEY Key { get; } + /// + /// The keys which make up this keybind. + /// + public ReadOnlyCollection AllKeys { get; } + + /// + /// Saved representation of the keybind as a string. + /// + private readonly string _allKeysStr; + public Keybind(KeyModifiers modifiers, VIRTUAL_KEY key) { Modifiers = modifiers; Key = key; + + List allKeys = new(); + allKeys.AddRange(Modifiers.GetParts()); + allKeys.Add(Key.GetKeyString()); + + AllKeys = allKeys.AsReadOnly(); + _allKeysStr = string.Join(" + ", AllKeys); } public override bool Equals(object? obj) @@ -28,5 +47,5 @@ public override bool Equals(object? obj) public override int GetHashCode() => HashCode.Combine(Modifiers, Key); - public override string ToString() => $"{Modifiers} + {Key}"; + public override string ToString() => _allKeysStr; } diff --git a/src/Whim/Commands/VirtualKeyExtensions.cs b/src/Whim/Commands/VirtualKeyExtensions.cs new file mode 100644 index 000000000..a450925a0 --- /dev/null +++ b/src/Whim/Commands/VirtualKeyExtensions.cs @@ -0,0 +1,20 @@ +using Windows.Win32.UI.Input.KeyboardAndMouse; + +namespace Whim; + +public static class VirtualKeyExtensions +{ + /// + /// Return the as a string. + /// + /// + /// + public static string GetKeyString(this VIRTUAL_KEY key) + { + string keyString = key.ToString(); + keyString = keyString.Replace("VK_", ""); + + // Return the keybinding, capitalizing the first letter. + return string.Concat(keyString[0].ToString().ToUpper(), keyString[1..].ToLower()); + } +} diff --git a/src/Whim/Native/InitializeWindowException.cs b/src/Whim/Native/InitializeWindowException.cs new file mode 100644 index 000000000..74c7ef5f7 --- /dev/null +++ b/src/Whim/Native/InitializeWindowException.cs @@ -0,0 +1,12 @@ +namespace Whim; + +[System.Serializable] +public class InitializeWindowException : System.Exception +{ + public InitializeWindowException() { } + public InitializeWindowException(string message) : base(message) { } + public InitializeWindowException(string message, System.Exception inner) : base(message, inner) { } + protected InitializeWindowException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } +} diff --git a/src/Whim/Native/WindowExtensions.cs b/src/Whim/Native/WindowExtensions.cs index eca82272f..da01d1e47 100644 --- a/src/Whim/Native/WindowExtensions.cs +++ b/src/Whim/Native/WindowExtensions.cs @@ -47,4 +47,38 @@ public static void SetIsShownInSwitchers(this Microsoft.UI.Xaml.Window window, b { window.GetAppWindow().IsShownInSwitchers = show; } + + /// + /// Initializes the given as a borderless window. + /// + /// + /// + /// + /// + /// + /// + /// When an cannot be created from the handle of the given + /// . + /// + public static IWindow InitializeBorderlessWindow( + this Microsoft.UI.Xaml.Window uiWindow, + string componentNamespace, + string componentPath, + IConfigContext configContext + ) + { + UIElementExtensions.InitializeComponent(uiWindow, componentNamespace, componentPath); + + HWND hwnd = new(WinRT.Interop.WindowNative.GetWindowHandle(uiWindow)); + IWindow? window = Window.CreateWindow(GetHandle(uiWindow), configContext); + if (window == null) + { + throw new InitializeWindowException("Window was unexpectedly null"); + } + + Win32Helper.HideCaptionButtons(hwnd); + Win32Helper.SetWindowCorners(hwnd); + + return window; + } } diff --git a/src/Whim/Window/IWindow.cs b/src/Whim/Window/IWindow.cs index f36d880f7..5d3946bed 100644 --- a/src/Whim/Window/IWindow.cs +++ b/src/Whim/Window/IWindow.cs @@ -62,6 +62,11 @@ public interface IWindow /// public void Focus(); + /// + /// Forces the window to the foreground and to be focused. + /// + public void FocusForceForeground(); + /// /// Hides this window. /// diff --git a/src/Whim/Window/Window.cs b/src/Whim/Window/Window.cs index 408f6d744..00922a93f 100644 --- a/src/Whim/Window/Window.cs +++ b/src/Whim/Window/Window.cs @@ -71,6 +71,13 @@ public void Focus() _configContext.WindowManager.TriggerWindowFocused(new WindowEventArgs(this)); } + public void FocusForceForeground() + { + Logger.Debug(ToString()); + PInvoke.SetForegroundWindow(Handle); + _configContext.WindowManager.TriggerWindowFocused(new WindowEventArgs(this)); + } + public void Hide() { Logger.Debug(ToString());