Add support for filterable, nested context menus (#38776)

_targets #38573_

At first I just wanted to add support for nested context menus.

But then I also had to add a search box, so the focus wouldn't get weird.

End result:

![nested-menus-001](https://github.com/user-attachments/assets/4e8f1ec8-4b09-4095-9b81-caf7abde8aea)

This gets rid of the need to have the search box and the command bar both track item keybindings - now it's just in the command bar.

Closes #38299
Closes #38442
This commit is contained in:
Mike Griese
2025-04-24 13:32:07 -05:00
committed by GitHub
parent 195ff24a85
commit f63fcfd91c
15 changed files with 607 additions and 170 deletions

View File

@@ -225,27 +225,42 @@
ToolTipService.ToolTip="Ctrl+K"
Visibility="{x:Bind ViewModel.ShouldShowContextMenu, Mode=OneWay}">
<Button.Flyout>
<Flyout Placement="TopEdgeAlignedRight">
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
Margin="-16,-12,-16,-12"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplate="{StaticResource ContextMenuViewModelTemplate}"
ItemsSource="{x:Bind ViewModel.ContextCommands, Mode=OneWay}"
KeyDown="CommandsDropdown_KeyDown"
SelectionMode="None">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,7,12,7" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
<Flyout
Closing="Flyout_Closing"
Opened="Flyout_Opened"
Placement="TopEdgeAlignedRight">
<StackPanel>
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
Margin="-16,-12,-16,-12"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplate="{StaticResource ContextMenuViewModelTemplate}"
ItemsSource="{x:Bind ViewModel.ContextMenu.FilteredItems, Mode=OneWay}"
KeyDown="CommandsDropdown_KeyDown"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,7,12,7" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
<TextBox
x:Name="ContextFilterBox"
x:Uid="ContextFilterBox"
Margin="-12,12,-12,-12"
KeyDown="ContextFilterBox_KeyDown"
PreviewKeyDown="ContextFilterBox_PreviewKeyDown"
TextChanged="ContextFilterBox_TextChanged" />
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>

View File

@@ -18,9 +18,10 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class CommandBar : UserControl,
IRecipient<OpenContextMenuMessage>,
IRecipient<TryCommandKeybindingMessage>,
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<OpenContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<TryCommandKeybindingMessage>(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<FocusSearchBoxMessage>();
}
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<FocusSearchBoxMessage>();
}
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<FocusSearchBoxMessage>();
}
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<FocusSearchBoxMessage>();
}
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);
}
}

View File

@@ -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<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateItemKeybindingsMessage>,
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<KeyChord, CommandContextItemViewModel>? _keyBindings;
public PageViewModel? CurrentPageViewModel
{
get => (PageViewModel?)GetValue(CurrentPageViewModelProperty);
@@ -74,7 +69,6 @@ public sealed partial class SearchBar : UserControl,
this.InitializeComponent();
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateItemKeybindingsMessage>(this);
}
public void ClearSearch()
@@ -173,17 +167,14 @@ public sealed partial class SearchBar : UserControl,
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(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<PerformCommandMessage>(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);
}

View File

@@ -187,8 +187,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
WeakReferenceMessenger.Default.Send<UpdateItemKeybindingsMessage>(new(null));
var isMainPage = command is MainListPage;
// Construct our ViewModel of the appropriate type and pass it the UI Thread context.

View File

@@ -394,6 +394,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="BehaviorSettingsHeader.Text" xml:space="preserve">
<value>Behavior</value>
</data>
<data name="ContextFilterBox.PlaceholderText" xml:space="preserve">
<value>Search commands...</value>
</data>
<data name="Settings_GeneralPage_ShowSystemTrayIcon_SettingsCard.Header" xml:space="preserve">
<value>Show system tray icon</value>
</data>