2025-07-09 14:53:47 -05:00
|
|
|
// 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 CommunityToolkit.Mvvm.Messaging;
|
2025-07-15 12:21:44 -05:00
|
|
|
using Microsoft.CmdPal.Core.ViewModels;
|
|
|
|
|
using Microsoft.CmdPal.Core.ViewModels.Messages;
|
2025-07-28 18:46:16 -05:00
|
|
|
using Microsoft.CmdPal.UI.Messages;
|
2025-07-09 14:53:47 -05:00
|
|
|
using Microsoft.UI.Input;
|
|
|
|
|
using Microsoft.UI.Xaml;
|
|
|
|
|
using Microsoft.UI.Xaml.Controls;
|
|
|
|
|
using Microsoft.UI.Xaml.Input;
|
|
|
|
|
using Windows.System;
|
|
|
|
|
using Windows.UI.Core;
|
|
|
|
|
|
|
|
|
|
namespace Microsoft.CmdPal.UI.Controls;
|
|
|
|
|
|
|
|
|
|
public sealed partial class ContextMenu : UserControl,
|
|
|
|
|
IRecipient<OpenContextMenuMessage>,
|
|
|
|
|
IRecipient<UpdateCommandBarMessage>,
|
|
|
|
|
IRecipient<TryCommandKeybindingMessage>
|
|
|
|
|
{
|
|
|
|
|
public ContextMenuViewModel ViewModel { get; } = new();
|
|
|
|
|
|
|
|
|
|
public ContextMenu()
|
|
|
|
|
{
|
|
|
|
|
this.InitializeComponent();
|
|
|
|
|
|
|
|
|
|
// RegisterAll isn't AOT compatible
|
|
|
|
|
WeakReferenceMessenger.Default.Register<OpenContextMenuMessage>(this);
|
|
|
|
|
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
|
|
|
|
|
WeakReferenceMessenger.Default.Register<TryCommandKeybindingMessage>(this);
|
|
|
|
|
|
2025-08-18 06:07:28 -05:00
|
|
|
if (ViewModel is not null)
|
2025-07-09 14:53:47 -05:00
|
|
|
{
|
|
|
|
|
ViewModel.PropertyChanged += ViewModel_PropertyChanged;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Receive(OpenContextMenuMessage message)
|
|
|
|
|
{
|
2025-07-28 18:46:16 -05:00
|
|
|
ViewModel.FilterOnTop = message.ContextMenuFilterLocation == ContextMenuFilterLocation.Top;
|
|
|
|
|
ViewModel.ResetContextMenu();
|
|
|
|
|
|
2025-07-09 14:53:47 -05:00
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Receive(UpdateCommandBarMessage message)
|
|
|
|
|
{
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Receive(TryCommandKeybindingMessage msg)
|
|
|
|
|
{
|
|
|
|
|
var result = ViewModel?.CheckKeybinding(msg.Ctrl, msg.Alt, msg.Shift, msg.Win, msg.Key);
|
|
|
|
|
|
|
|
|
|
if (result == ContextKeybindingResult.Hide)
|
|
|
|
|
{
|
|
|
|
|
msg.Handled = true;
|
|
|
|
|
WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
else if (result == ContextKeybindingResult.KeepOpen)
|
|
|
|
|
{
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
msg.Handled = true;
|
|
|
|
|
}
|
|
|
|
|
else if (result == ContextKeybindingResult.Unhandled)
|
|
|
|
|
{
|
|
|
|
|
msg.Handled = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void CommandsDropdown_ItemClick(object sender, ItemClickEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (e.ClickedItem is CommandContextItemViewModel item)
|
|
|
|
|
{
|
|
|
|
|
if (InvokeCommand(item) == ContextKeybindingResult.Hide)
|
|
|
|
|
{
|
|
|
|
|
WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-28 20:17:27 -05:00
|
|
|
private void CommandsDropdown_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
|
2025-07-09 14:53:47 -05:00
|
|
|
{
|
|
|
|
|
if (e.Handled)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
|
|
|
|
|
|
|
|
|
|
if (result == ContextKeybindingResult.Hide)
|
|
|
|
|
{
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
else if (result == ContextKeybindingResult.KeepOpen)
|
|
|
|
|
{
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
}
|
|
|
|
|
else if (result == ContextKeybindingResult.Unhandled)
|
|
|
|
|
{
|
|
|
|
|
e.Handled = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
var prop = e.PropertyName;
|
|
|
|
|
|
|
|
|
|
if (prop == nameof(ContextMenuViewModel.FilteredItems))
|
|
|
|
|
{
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ContextFilterBox_TextChanged(object sender, TextChangedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
ViewModel?.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 (InvokeCommand(item) == ContextKeybindingResult.Hide)
|
|
|
|
|
{
|
|
|
|
|
WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (e.Key == VirtualKey.Escape ||
|
|
|
|
|
(e.Key == VirtualKey.Left && altPressed))
|
|
|
|
|
{
|
|
|
|
|
if (ViewModel.CanPopContextStack())
|
|
|
|
|
{
|
|
|
|
|
ViewModel.PopContextStack();
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>();
|
|
|
|
|
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
|
|
|
|
|
UpdateUiForStackChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (e.Key == VirtualKey.Up)
|
|
|
|
|
{
|
2025-07-16 06:25:24 -05:00
|
|
|
NavigateUp();
|
|
|
|
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
}
|
|
|
|
|
else if (e.Key == VirtualKey.Down)
|
|
|
|
|
{
|
|
|
|
|
NavigateDown();
|
|
|
|
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
}
|
2025-07-28 20:17:27 -05:00
|
|
|
|
|
|
|
|
CommandsDropdown_PreviewKeyDown(sender, e);
|
2025-07-16 06:25:24 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void NavigateUp()
|
|
|
|
|
{
|
|
|
|
|
var newIndex = CommandsDropdown.SelectedIndex;
|
|
|
|
|
|
|
|
|
|
if (CommandsDropdown.SelectedIndex > 0)
|
|
|
|
|
{
|
|
|
|
|
newIndex--;
|
|
|
|
|
|
|
|
|
|
while (
|
|
|
|
|
newIndex >= 0 &&
|
|
|
|
|
IsSeparator(CommandsDropdown.Items[newIndex]) &&
|
|
|
|
|
newIndex != CommandsDropdown.SelectedIndex)
|
2025-07-09 14:53:47 -05:00
|
|
|
{
|
2025-07-16 06:25:24 -05:00
|
|
|
newIndex--;
|
2025-07-09 14:53:47 -05:00
|
|
|
}
|
2025-07-16 06:25:24 -05:00
|
|
|
|
|
|
|
|
if (newIndex < 0)
|
2025-07-09 14:53:47 -05:00
|
|
|
{
|
2025-07-16 06:25:24 -05:00
|
|
|
newIndex = CommandsDropdown.Items.Count - 1;
|
|
|
|
|
|
|
|
|
|
while (
|
|
|
|
|
newIndex >= 0 &&
|
|
|
|
|
IsSeparator(CommandsDropdown.Items[newIndex]) &&
|
|
|
|
|
newIndex != CommandsDropdown.SelectedIndex)
|
|
|
|
|
{
|
|
|
|
|
newIndex--;
|
|
|
|
|
}
|
2025-07-09 14:53:47 -05:00
|
|
|
}
|
2025-07-16 06:25:24 -05:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
newIndex = CommandsDropdown.Items.Count - 1;
|
|
|
|
|
}
|
2025-07-09 14:53:47 -05:00
|
|
|
|
2025-07-16 06:25:24 -05:00
|
|
|
CommandsDropdown.SelectedIndex = newIndex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void NavigateDown()
|
|
|
|
|
{
|
|
|
|
|
var newIndex = CommandsDropdown.SelectedIndex;
|
|
|
|
|
|
|
|
|
|
if (CommandsDropdown.SelectedIndex == CommandsDropdown.Items.Count - 1)
|
|
|
|
|
{
|
|
|
|
|
newIndex = 0;
|
2025-07-09 14:53:47 -05:00
|
|
|
}
|
2025-07-16 06:25:24 -05:00
|
|
|
else
|
2025-07-09 14:53:47 -05:00
|
|
|
{
|
2025-07-16 06:25:24 -05:00
|
|
|
newIndex++;
|
|
|
|
|
|
|
|
|
|
while (
|
|
|
|
|
newIndex < CommandsDropdown.Items.Count &&
|
|
|
|
|
IsSeparator(CommandsDropdown.Items[newIndex]) &&
|
|
|
|
|
newIndex != CommandsDropdown.SelectedIndex)
|
2025-07-09 14:53:47 -05:00
|
|
|
{
|
2025-07-16 06:25:24 -05:00
|
|
|
newIndex++;
|
2025-07-09 14:53:47 -05:00
|
|
|
}
|
2025-07-16 06:25:24 -05:00
|
|
|
|
|
|
|
|
if (newIndex >= CommandsDropdown.Items.Count)
|
2025-07-09 14:53:47 -05:00
|
|
|
{
|
2025-07-16 06:25:24 -05:00
|
|
|
newIndex = 0;
|
2025-07-09 14:53:47 -05:00
|
|
|
|
2025-07-16 06:25:24 -05:00
|
|
|
while (
|
|
|
|
|
newIndex < CommandsDropdown.Items.Count &&
|
|
|
|
|
IsSeparator(CommandsDropdown.Items[newIndex]) &&
|
|
|
|
|
newIndex != CommandsDropdown.SelectedIndex)
|
|
|
|
|
{
|
|
|
|
|
newIndex++;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-09 14:53:47 -05:00
|
|
|
}
|
2025-07-16 06:25:24 -05:00
|
|
|
|
|
|
|
|
CommandsDropdown.SelectedIndex = newIndex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool IsSeparator(object item)
|
|
|
|
|
{
|
CmdPal: Filters for DynamicListPage? Yes, please. (#40783)
Closes: #40382
## To-do list
- [x] Add support for "single-select" filters to DynamicListPage
- [x] Filters can contain icons
- [x] Filter list can contain separators
- [x] Update Windows Services built-in extension to support filtering by
all, started, stopped, and pending services
- [x] Update SampleExtension dynamic list sample to filter.
## Example of filters in use
```C#
internal sealed partial class ServicesListPage : DynamicListPage
{
public ServicesListPage()
{
Icon = Icons.ServicesIcon;
Name = "Windows Services";
var filters = new ServiceFilters();
filters.PropChanged += Filters_PropChanged;
Filters = filters;
}
private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged();
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged();
public override IListItem[] GetItems()
{
// ServiceHelper.Search knows how to filter based on the CurrentFilterIds provided
var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterIds).ToArray();
return items;
}
}
public partial class ServiceFilters : Filters
{
public ServiceFilters()
{
// This would be a default selection. Not providing this will cause the filter
// control to display the "Filter" placeholder text.
CurrentFilterIds = ["all"];
}
public override IFilterItem[] GetFilters()
{
return [
new Filter() { Id = "all", Name = "All Services" },
new Separator(),
new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon },
new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon },
new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon },
];
}
}
```
## Current example of behavior
https://github.com/user-attachments/assets/2e325763-ad3a-4445-bbe2-a840df08d0b3
---------
Co-authored-by: Mike Griese <migrie@microsoft.com>
2025-08-21 05:40:09 -05:00
|
|
|
return item is SeparatorViewModel;
|
2025-07-09 14:53:47 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void UpdateUiForStackChange()
|
|
|
|
|
{
|
|
|
|
|
ContextFilterBox.Text = string.Empty;
|
|
|
|
|
ViewModel?.SetSearchText(string.Empty);
|
|
|
|
|
CommandsDropdown.SelectedIndex = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Manually focuses our search box. This needs to be called after we're actually
|
|
|
|
|
/// In the UI tree - if we're in a Flyout, that's not until Opened()
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal void FocusSearchBox()
|
|
|
|
|
{
|
|
|
|
|
ContextFilterBox.Focus(FocusState.Programmatic);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private ContextKeybindingResult InvokeCommand(CommandItemViewModel command) => ViewModel.InvokeCommand(command);
|
|
|
|
|
}
|