diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs index a476fba179..06f55ccf02 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs @@ -4,18 +4,14 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; namespace Microsoft.CmdPal.UI.ViewModels; public partial class CommandBarViewModel : ObservableObject, - IRecipient, - IRecipient + IRecipient { public ICommandBarContext? SelectedItem { @@ -53,20 +49,17 @@ public partial class CommandBarViewModel : ObservableObject, public partial PageViewModel? CurrentPage { get; set; } [ObservableProperty] - public partial ObservableCollection ContextCommands { get; set; } = []; + public partial ObservableCollection ContextMenuStack { get; set; } = []; - private Dictionary? _contextKeybindings; + public ContextMenuStackViewModel? ContextMenu => ContextMenuStack.LastOrDefault(); public CommandBarViewModel() { WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); } public void Receive(UpdateCommandBarMessage message) => SelectedItem = message.ViewModel; - public void Receive(UpdateItemKeybindingsMessage message) => _contextKeybindings = message.Keys; - private void SetSelectedItem(ICommandBarContext? value) { if (value != null) @@ -111,7 +104,10 @@ public partial class CommandBarViewModel : ObservableObject, if (SelectedItem.MoreCommands.Count() > 1) { ShouldShowContextMenu = true; - ContextCommands = [.. SelectedItem.AllCommands.Where(c => c.ShouldBeVisible)]; + + ContextMenuStack.Clear(); + ContextMenuStack.Add(new ContextMenuStackViewModel(SelectedItem)); + OnPropertyChanged(nameof(ContextMenu)); } else { @@ -125,43 +121,80 @@ public partial class CommandBarViewModel : ObservableObject, // InvokeItemCommand is what this will be in Xaml due to source generator // this comes in when an item in the list is tapped - [RelayCommand] - private void InvokeItem(CommandContextItemViewModel item) => - WeakReferenceMessenger.Default.Send(new(item.Command.Model, item.Model)); + // [RelayCommand] + public ContextKeybindingResult InvokeItem(CommandContextItemViewModel item) => + PerformCommand(item); // this comes in when the primary button is tapped public void InvokePrimaryCommand() { - if (PrimaryCommand != null) - { - WeakReferenceMessenger.Default.Send(new(PrimaryCommand.Command.Model, PrimaryCommand.Model)); - } + PerformCommand(SecondaryCommand); } // this comes in when the secondary button is tapped public void InvokeSecondaryCommand() { - if (SecondaryCommand != null) + PerformCommand(SecondaryCommand); + } + + public ContextKeybindingResult CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + { + var matchedItem = ContextMenu?.CheckKeybinding(ctrl, alt, shift, win, key); + return matchedItem != null ? PerformCommand(matchedItem) : ContextKeybindingResult.Unhandled; + } + + private ContextKeybindingResult PerformCommand(CommandItemViewModel? command) + { + if (command == null) { - WeakReferenceMessenger.Default.Send(new(SecondaryCommand.Command.Model, SecondaryCommand.Model)); + return ContextKeybindingResult.Unhandled; + } + + if (command.HasMoreCommands) + { + ContextMenuStack.Add(new ContextMenuStackViewModel(command)); + OnPropertyChanging(nameof(ContextMenu)); + OnPropertyChanged(nameof(ContextMenu)); + return ContextKeybindingResult.KeepOpen; + } + else + { + WeakReferenceMessenger.Default.Send(new(command.Command.Model, command.Model)); + return ContextKeybindingResult.Hide; } } - public bool CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + public bool CanPopContextStack() { - if (_contextKeybindings != null) + return ContextMenuStack.Count > 1; + } + + public void PopContextStack() + { + if (ContextMenuStack.Count > 1) { - // Does the pressed key match any of the keybindings? - var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); - if (_contextKeybindings.TryGetValue(pressedKeyChord, out var item)) - { - // TODO GH #245: This is a bit of a hack, but we need to make sure that the keybindings are updated before we send the message - // so that the correct item is activated. - WeakReferenceMessenger.Default.Send(new(item)); - return true; - } + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); } - return false; + OnPropertyChanging(nameof(ContextMenu)); + OnPropertyChanged(nameof(ContextMenu)); + } + + public void ClearContextStack() + { + while (ContextMenuStack.Count > 1) + { + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); + } + + OnPropertyChanging(nameof(ContextMenu)); + OnPropertyChanged(nameof(ContextMenu)); } } + +public enum ContextKeybindingResult +{ + Unhandled, + Hide, + KeepOpen, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index 8634b63278..24dd9e1788 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -48,7 +48,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public List MoreCommands { get; private set; } = []; - IEnumerable ICommandBarContext.MoreCommands => MoreCommands; + IEnumerable IContextMenuContext.MoreCommands => MoreCommands; public bool HasMoreCommands => MoreCommands.Count > 0; @@ -187,23 +187,26 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa // use Initialize straight up MoreCommands.ForEach(contextItem => { - contextItem.InitializeProperties(); + contextItem.SlowInitializeProperties(); }); - _defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext) + if (!string.IsNullOrEmpty(model.Command.Name)) { - _itemTitle = Name, - Subtitle = Subtitle, - Command = Command, + _defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext) + { + _itemTitle = Name, + Subtitle = Subtitle, + Command = Command, - // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever - }; + // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever + }; - // Only set the icon on the context item for us if our command didn't - // have its own icon - if (!Command.HasIcon) - { - _defaultCommandContextItem._listItemIcon = _listItemIcon; + // Only set the icon on the context item for us if our command didn't + // have its own icon + if (!Command.HasIcon) + { + _defaultCommandContextItem._listItemIcon = _listItemIcon; + } } Initialized |= InitializedState.SelectionInitialized; @@ -398,23 +401,6 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa base.SafeCleanup(); Initialized |= InitializedState.CleanedUp; } - - /// - /// Generates a mapping of key -> command item for this particular item's - /// MoreCommands. (This won't include the primary Command, but it will - /// include the secondary one). This map can be used to quickly check if a - /// shortcut key was pressed - /// - /// a dictionary of KeyChord -> Context commands, for all commands - /// that have a shortcut key set. - internal Dictionary Keybindings() - { - return MoreCommands - .Where(c => c.HasRequestedShortcut) - .ToDictionary( - c => c.RequestedShortcut ?? new KeyChord(0, 0, 0), - c => c); - } } [Flags] diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs new file mode 100644 index 0000000000..2b16bd8f47 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContextMenuStackViewModel : ObservableObject +{ + [ObservableProperty] + public partial ObservableCollection FilteredItems { get; set; } + + private readonly IContextMenuContext _context; + private string _lastSearchText = string.Empty; + + // private Dictionary? _contextKeybindings; + public ContextMenuStackViewModel(IContextMenuContext context) + { + _context = context; + FilteredItems = [.. context.AllCommands]; + } + + public void SetSearchText(string searchText) + { + if (searchText == _lastSearchText) + { + return; + } + + _lastSearchText = searchText; + + var commands = _context.AllCommands.Where(c => c.ShouldBeVisible); + if (string.IsNullOrEmpty(searchText)) + { + ListHelpers.InPlaceUpdateList(FilteredItems, commands); + return; + } + + var newResults = ListHelpers.FilterList(commands, searchText, ScoreContextCommand); + ListHelpers.InPlaceUpdateList(FilteredItems, newResults); + } + + private static int ScoreContextCommand(string query, CommandContextItemViewModel item) + { + if (string.IsNullOrEmpty(query) || string.IsNullOrWhiteSpace(query)) + { + return 1; + } + + if (string.IsNullOrEmpty(item.Title)) + { + return 0; + } + + var nameMatch = StringMatcher.FuzzySearch(query, item.Title); + + var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle); + + return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + } + + public CommandContextItemViewModel? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + { + var keybindings = _context.Keybindings(); + if (keybindings != null) + { + // Does the pressed key match any of the keybindings? + var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); + if (keybindings.TryGetValue(pressedKeyChord, out var item)) + { + return item; + } + } + + return null; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index bcafb0235e..83dc4018f9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -344,8 +344,6 @@ public partial class ListViewModel : PageViewModel, IDisposable { WeakReferenceMessenger.Default.Send(new(item)); - WeakReferenceMessenger.Default.Send(new(item.Keybindings())); - if (ShowDetails && item.HasDetails) { WeakReferenceMessenger.Default.Send(new(item.Details)); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateItemKeybindingsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TryCommandKeybindingMessage.cs similarity index 59% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateItemKeybindingsMessage.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TryCommandKeybindingMessage.cs index 2054d3d8fd..3df48ec3a0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateItemKeybindingsMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TryCommandKeybindingMessage.cs @@ -2,8 +2,11 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CommandPalette.Extensions; +using Windows.System; namespace Microsoft.CmdPal.UI.ViewModels.Messages; -public record UpdateItemKeybindingsMessage(Dictionary? Keys); +public record TryCommandKeybindingMessage(bool Ctrl, bool Alt, bool Shift, bool Win, VirtualKey Key) +{ + public bool Handled { get; set; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs index 0a540c7408..929b5995c5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.ComponentModel; +using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.UI.ViewModels.Messages; @@ -13,22 +14,42 @@ public record UpdateCommandBarMessage(ICommandBarContext? ViewModel) { } -// Represents everything the command bar needs to know about to show command -// buttons at the bottom. -// -// This is implemented by both ListItemViewModel and ContentPageViewModel, -// the two things with sub-commands. -public interface ICommandBarContext : INotifyPropertyChanged +public interface IContextMenuContext : INotifyPropertyChanged { public IEnumerable MoreCommands { get; } public bool HasMoreCommands { get; } + public List AllCommands { get; } + + /// + /// Generates a mapping of key -> command item for this particular item's + /// MoreCommands. (This won't include the primary Command, but it will + /// include the secondary one). This map can be used to quickly check if a + /// shortcut key was pressed + /// + /// a dictionary of KeyChord -> Context commands, for all commands + /// that have a shortcut key set. + public Dictionary Keybindings() + { + return MoreCommands + .Where(c => c.HasRequestedShortcut) + .ToDictionary( + c => c.RequestedShortcut ?? new KeyChord(0, 0, 0), + c => c); + } +} + +// Represents everything the command bar needs to know about to show command +// buttons at the bottom. +// +// This is implemented by both ListItemViewModel and ContentPageViewModel, +// the two things with sub-commands. +public interface ICommandBarContext : IContextMenuContext +{ public string SecondaryCommandName { get; } public CommandItemViewModel? PrimaryCommand { get; } public CommandItemViewModel? SecondaryCommand { get; } - - public List AllCommands { get; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml index 896ee11d0d..203009f763 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml @@ -225,27 +225,42 @@ ToolTipService.ToolTip="Ctrl+K" Visibility="{x:Bind ViewModel.ShouldShowContextMenu, Mode=OneWay}"> - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs index e8ca659097..f079f4b513 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs @@ -18,9 +18,10 @@ namespace Microsoft.CmdPal.UI.Controls; public sealed partial class CommandBar : UserControl, IRecipient, + IRecipient, ICurrentPageAware { - public CommandBarViewModel ViewModel { get; set; } = new(); + public CommandBarViewModel ViewModel { get; } = new(); public PageViewModel? CurrentPageViewModel { @@ -38,6 +39,9 @@ public sealed partial class CommandBar : UserControl, // RegisterAll isn't AOT compatible WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + + ViewModel.PropertyChanged += ViewModel_PropertyChanged; } public void Receive(OpenContextMenuMessage message) @@ -52,8 +56,41 @@ public sealed partial class CommandBar : UserControl, ShowMode = FlyoutShowMode.Standard, }; MoreCommandsButton.Flyout.ShowAt(MoreCommandsButton, options); - CommandsDropdown.SelectedIndex = 0; - CommandsDropdown.Focus(FocusState.Programmatic); + UpdateUiForStackChange(); + } + + public void Receive(TryCommandKeybindingMessage msg) + { + if (!ViewModel.ShouldShowContextMenu) + { + return; + } + + var result = ViewModel?.CheckKeybinding(msg.Ctrl, msg.Alt, msg.Shift, msg.Win, msg.Key); + + if (result == ContextKeybindingResult.Hide) + { + msg.Handled = true; + } + else if (result == ContextKeybindingResult.KeepOpen) + { + if (!MoreCommandsButton.Flyout.IsOpen) + { + var options = new FlyoutShowOptions + { + ShowMode = FlyoutShowMode.Standard, + }; + MoreCommandsButton.Flyout.ShowAt(MoreCommandsButton, options); + } + + UpdateUiForStackChange(); + + msg.Handled = true; + } + else if (result == ContextKeybindingResult.Unhandled) + { + msg.Handled = false; + } } [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS has a tendency to delete XAML bound methods over-aggressively")] @@ -88,8 +125,14 @@ public sealed partial class CommandBar : UserControl, { if (e.ClickedItem is CommandContextItemViewModel item) { - ViewModel?.InvokeItemCommand.Execute(item); - MoreCommandsButton.Flyout.Hide(); + if (ViewModel?.InvokeItem(item) == ContextKeybindingResult.Hide) + { + MoreCommandsButton.Flyout.Hide(); + } + else + { + UpdateUiForStackChange(); + } } } @@ -106,9 +149,136 @@ public sealed partial class CommandBar : UserControl, var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); - if (ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key) ?? false) + var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + + if (result == ContextKeybindingResult.Hide) + { + e.Handled = true; + MoreCommandsButton.Flyout.Hide(); + WeakReferenceMessenger.Default.Send(); + } + else if (result == ContextKeybindingResult.KeepOpen) { e.Handled = true; } + else if (result == ContextKeybindingResult.Unhandled) + { + e.Handled = false; + } + } + + private void Flyout_Opened(object sender, object e) + { + UpdateUiForStackChange(); + } + + private void Flyout_Closing(FlyoutBase sender, FlyoutBaseClosingEventArgs args) + { + ViewModel?.ClearContextStack(); + WeakReferenceMessenger.Default.Send(); + } + + private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var prop = e.PropertyName; + if (prop == nameof(ViewModel.ContextMenu)) + { + UpdateUiForStackChange(); + } + } + + private void ContextFilterBox_TextChanged(object sender, TextChangedEventArgs e) + { + ViewModel.ContextMenu?.SetSearchText(ContextFilterBox.Text); + + if (CommandsDropdown.SelectedIndex == -1) + { + CommandsDropdown.SelectedIndex = 0; + } + } + + private void ContextFilterBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + if (e.Key == VirtualKey.Enter) + { + if (CommandsDropdown.SelectedItem is CommandContextItemViewModel item) + { + if (ViewModel?.InvokeItem(item) == ContextKeybindingResult.Hide) + { + MoreCommandsButton.Flyout.Hide(); + WeakReferenceMessenger.Default.Send(); + } + else + { + UpdateUiForStackChange(); + } + + e.Handled = true; + } + } + else if (e.Key == VirtualKey.Escape || + (e.Key == VirtualKey.Left && altPressed)) + { + if (ViewModel.CanPopContextStack()) + { + ViewModel.PopContextStack(); + UpdateUiForStackChange(); + } + else + { + MoreCommandsButton.Flyout.Hide(); + WeakReferenceMessenger.Default.Send(); + } + + e.Handled = true; + } + + CommandsDropdown_KeyDown(sender, e); + } + + private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Up) + { + // navigate previous + if (CommandsDropdown.SelectedIndex > 0) + { + CommandsDropdown.SelectedIndex--; + } + else + { + CommandsDropdown.SelectedIndex = CommandsDropdown.Items.Count - 1; + } + + e.Handled = true; + } + else if (e.Key == VirtualKey.Down) + { + // navigate next + if (CommandsDropdown.SelectedIndex < CommandsDropdown.Items.Count - 1) + { + CommandsDropdown.SelectedIndex++; + } + else + { + CommandsDropdown.SelectedIndex = 0; + } + + e.Handled = true; + } + } + + private void UpdateUiForStackChange() + { + ContextFilterBox.Text = string.Empty; + ViewModel.ContextMenu?.SetSearchText(string.Empty); + CommandsDropdown.SelectedIndex = 0; + ContextFilterBox.Focus(FocusState.Programmatic); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index d939381fb0..c868e3dd5e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -8,8 +8,6 @@ using CommunityToolkit.WinUI; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.Views; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.UI.Dispatching; using Microsoft.UI.Input; using Microsoft.UI.Xaml; @@ -23,7 +21,6 @@ namespace Microsoft.CmdPal.UI.Controls; public sealed partial class SearchBar : UserControl, IRecipient, IRecipient, - IRecipient, ICurrentPageAware { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -34,8 +31,6 @@ public sealed partial class SearchBar : UserControl, private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private bool _isBackspaceHeld; - private Dictionary? _keyBindings; - public PageViewModel? CurrentPageViewModel { get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); @@ -74,7 +69,6 @@ public sealed partial class SearchBar : UserControl, this.InitializeComponent(); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); } public void ClearSearch() @@ -173,17 +167,14 @@ public sealed partial class SearchBar : UserControl, WeakReferenceMessenger.Default.Send(new()); } - if (_keyBindings != null) + if (!e.Handled) { - // Does the pressed key match any of the keybindings? - var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrlPressed, altPressed, shiftPressed, winPressed, (int)e.Key, 0); - if (_keyBindings.TryGetValue(pressedKeyChord, out var item)) - { - // TODO GH #245: This is a bit of a hack, but we need to make sure that the keybindings are updated before we send the message - // so that the correct item is activated. - WeakReferenceMessenger.Default.Send(new(item)); - e.Handled = true; - } + // The CommandBar is responsible for handling all the item keybindings, + // since the bound context item may need to then show another + // context menu + TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + WeakReferenceMessenger.Default.Send(msg); + e.Handled = msg.Handled; } } @@ -302,10 +293,5 @@ public sealed partial class SearchBar : UserControl, public void Receive(GoHomeMessage message) => ClearSearch(); - public void Receive(FocusSearchBoxMessage message) => this.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); - - public void Receive(UpdateItemKeybindingsMessage message) - { - _keyBindings = message.Keys; - } + public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index bc0999ca02..869b048dbd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -187,8 +187,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, WeakReferenceMessenger.Default.Send(new(null)); - WeakReferenceMessenger.Default.Send(new(null)); - var isMainPage = command is MainListPage; // Construct our ViewModel of the appropriate type and pass it the UI Thread context. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index 51b33f8ede..6c63eeff16 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -394,6 +394,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Behavior + + Search commands... + Show system tray icon diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs index 373a1f7891..2fc1218bd7 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; namespace SamplePagesExtension; @@ -76,7 +77,136 @@ public partial class EvilSamplesPage : ListPage { Body = "This is a test for GH#512. If it doesn't appear immediately, it's likely InvokeCommand is happening on the UI thread.", }, - } + }, + + // More edge cases than truly evil + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "anonymous command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 + { + Title = "I'm a second command", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new CommandContextItem("nested...") + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "noop command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 + { + Title = "I'm a second command", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new CommandContextItem(new NoOpCommand()) + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "noop secondary command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem(new NoOpCommand()) + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + ]; public EvilSamplesPage() diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs index 954a79ce04..3cf987e417 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs @@ -69,62 +69,47 @@ internal sealed partial class SampleListPage : ListPage }, new ListItem( - new AnonymousCommand(() => - { - var t = new ToastStatusMessage(new StatusMessage() - { - Message = "Primary command invoked", - State = MessageState.Info, - }); - t.Show(); - }) - { - Result = CommandResult.KeepOpen(), - Icon = new IconInfo("\uE712"), - }) + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 { Title = "You can add context menu items too. Press Ctrl+k", Subtitle = "Try pressing Ctrl+1 with me selected", - Icon = new IconInfo("\uE712"), + Icon = new IconInfo("\uE712"), // "More" dots MoreCommands = [ new CommandContextItem( - new AnonymousCommand(() => - { - var t = new ToastStatusMessage(new StatusMessage() - { - Message = "Secondary command invoked", - State = MessageState.Warning, - }); - t.Show(); - }) - { - Name = "Secondary command", - Icon = new IconInfo("\uF147"), // Dial 2 - Result = CommandResult.KeepOpen(), - }) + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 { Title = "I'm a second command", RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), }, new CommandContextItem( - new AnonymousCommand(() => - { - var t = new ToastStatusMessage(new StatusMessage() - { - Message = "Third command invoked", - State = MessageState.Error, - }); - t.Show(); - }) - { - Name = "Do it", - Icon = new IconInfo("\uF148"), // dial 3 - Result = CommandResult.KeepOpen(), - }) + new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3 { - Title = "A third command too", + Title = "We can go deeper...", Icon = new IconInfo("\uF148"), RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], } ], }, @@ -183,7 +168,6 @@ internal sealed partial class SampleListPage : ListPage { Title = "Get the name of the Foreground window", }, - ]; } } diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs new file mode 100644 index 0000000000..dfbeb5225a --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class ToastCommand(string message, MessageState state = MessageState.Info) : InvokableCommand +{ + public override ICommandResult Invoke() + { + var t = new ToastStatusMessage(new StatusMessage() + { + Message = message, + State = state, + }); + t.Show(); + + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs index 91d715b509..ffd20643aa 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs @@ -61,4 +61,9 @@ public partial class ListItem : CommandItem, IListItem : base(command) { } + + public ListItem() + : base() + { + } }