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

@@ -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<UpdateCommandBarMessage>,
IRecipient<UpdateItemKeybindingsMessage>
IRecipient<UpdateCommandBarMessage>
{
public ICommandBarContext? SelectedItem
{
@@ -53,20 +49,17 @@ public partial class CommandBarViewModel : ObservableObject,
public partial PageViewModel? CurrentPage { get; set; }
[ObservableProperty]
public partial ObservableCollection<CommandContextItemViewModel> ContextCommands { get; set; } = [];
public partial ObservableCollection<ContextMenuStackViewModel> ContextMenuStack { get; set; } = [];
private Dictionary<KeyChord, CommandContextItemViewModel>? _contextKeybindings;
public ContextMenuStackViewModel? ContextMenu => ContextMenuStack.LastOrDefault();
public CommandBarViewModel()
{
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateItemKeybindingsMessage>(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<PerformCommandMessage>(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<PerformCommandMessage>(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<PerformCommandMessage>(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<PerformCommandMessage>(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<PerformCommandMessage>(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,
}