CmdPal: Replace FiltersDropDown ComboBox with searchable dropdown (#45747)

## Summary of the Pull Request

Replaces the ComboBox-based filter control with a DropDownButton and
Flyout containing a searchable TextBox and ListView.

- Add type-to-search: typing while button is focused opens the flyout
and filters items by name
- Designed to match appearance of the context menu
- Add keyboard navigation: `Up`/`Down `moves selection from search box,
`Enter` confirms, `Escape` clears search text (or closes if empty), `F4`
opens the dropdown
- Add `Alt+F` shortcut on ShellPage to toggle filter focus
- Style flyout to match ContextMenu (item padding, separators, search
box appearance)
- Show "No results" empty state when search matches nothing
- After confirming selection, return focus to the main search box
- Add accessibility
- Update `FilterTemplateSelector` to support both ComboBoxItem and
ListViewItem containers
- Guard against infinite loop in navigation when only separators exist

## Pictures? Moving!



https://github.com/user-attachments/assets/60e232ae-8cee-4759-a9a7-d7edbf78719e

<img width="315" height="212" alt="image"
src="https://github.com/user-attachments/assets/b6e1a895-064c-47e1-9184-26dbb46fdf05"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41648
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2026-03-04 20:05:22 +01:00
committed by GitHub
parent 86860df314
commit d20ae940d5
9 changed files with 621 additions and 121 deletions

View File

@@ -5,16 +5,15 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection;
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;
@@ -101,13 +100,9 @@ public sealed partial class ContextMenu : UserControl,
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 mods = KeyModifiers.GetCurrent();
var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
var result = ViewModel?.CheckKeybinding(mods.Ctrl, mods.Alt, mods.Shift, mods.Win, e.Key);
if (result == ContextKeybindingResult.Hide)
{
@@ -165,11 +160,7 @@ public sealed partial class ContextMenu : UserControl,
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);
var modifiers = KeyModifiers.GetCurrent();
if (e.Key == VirtualKey.Enter)
{
@@ -186,7 +177,7 @@ public sealed partial class ContextMenu : UserControl,
}
}
else if (e.Key == VirtualKey.Escape ||
(e.Key == VirtualKey.Left && altPressed))
(e.Key == VirtualKey.Left && modifiers.Alt))
{
if (ViewModel.CanPopContextStack())
{

View File

@@ -22,20 +22,9 @@
Default="{StaticResource FilterItemViewModelTemplate}"
Separator="{StaticResource SeparatorViewModelTemplate}" />
<Style
x:Name="ComboBoxStyle"
BasedOn="{StaticResource DefaultComboBoxStyle}"
TargetType="ComboBox">
<Style.Setters>
<Setter Property="Visibility" Value="Collapsed" />
<Setter Property="Margin" Value="0,0,12,0" />
<Setter Property="Padding" Value="16,4" />
</Style.Setters>
</Style>
<!-- Template for the filter items -->
<DataTemplate x:Key="FilterItemViewModelTemplate" x:DataType="viewModels:FilterItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
<Grid Padding="12,8,12,8" AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -58,34 +47,100 @@
<DataTemplate x:Key="SeparatorViewModelTemplate" x:DataType="viewModels:SeparatorViewModel">
<Rectangle
Height="1"
Margin="-16,-12,-12,-12"
Margin="0,2,0,2"
Fill="{ThemeResource MenuFlyoutSeparatorBackground}" />
</DataTemplate>
</ResourceDictionary>
</UserControl.Resources>
<ComboBox
Name="FiltersComboBox"
x:Uid="FiltersComboBox"
<DropDownButton
x:Name="FilterDropDownButton"
x:Uid="FiltersDropDown"
MinWidth="200"
Margin="0,0,12,0"
Padding="16,5,8,5"
VerticalAlignment="Center"
ItemTemplateSelector="{StaticResource FilterTemplateSelector}"
ItemsSource="{x:Bind ViewModel.Filters, Mode=OneWay}"
PlaceholderText="Filters"
PreviewKeyDown="FiltersComboBox_PreviewKeyDown"
SelectedValue="{x:Bind ViewModel.CurrentFilter, Mode=OneWay}"
SelectionChanged="FiltersComboBox_SelectionChanged"
Style="{StaticResource ComboBoxStyle}"
HorizontalContentAlignment="Left"
PreviewKeyDown="FilterDropDownButton_PreviewKeyDown"
Visibility="{x:Bind ViewModel.ShouldShowFilters, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Collapsed}">
<ComboBox.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultComboBoxItemStyle}" TargetType="ComboBoxItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
</Style>
</ComboBox.ItemContainerStyle>
<ComboBox.ItemContainerTransitions>
<TransitionCollection />
</ComboBox.ItemContainerTransitions>
</ComboBox>
<DropDownButton.Content>
<StackPanel Orientation="Horizontal">
<cpcontrols:IconBox
x:Name="SelectedFilterIcon"
Width="16"
Margin="0,0,8,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}"
Visibility="Collapsed" />
<TextBlock x:Name="SelectedFilterText" VerticalAlignment="Center" />
</StackPanel>
</DropDownButton.Content>
<DropDownButton.Flyout>
<Flyout
x:Name="FilterFlyout"
Closed="FilterFlyout_Closed"
Opened="FilterFlyout_Opened"
Placement="BottomEdgeAlignedRight">
<Flyout.FlyoutPresenterStyle>
<Style BasedOn="{StaticResource DefaultFlyoutPresenterStyle}" TargetType="FlyoutPresenter">
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
</Style>
</Flyout.FlyoutPresenterStyle>
<Grid MinWidth="200">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBox
x:Name="FilterSearchBox"
x:Uid="FilterSearchBox"
Margin="0"
Padding="10,7,6,8"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
BorderThickness="0,0,0,2"
CornerRadius="8,8,0,0"
PreviewKeyDown="FilterSearchBox_PreviewKeyDown"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="FilterSearchBox_TextChanged" />
<Border
Grid.Row="1"
BorderBrush="{ThemeResource MenuFlyoutSeparatorBackground}"
BorderThickness="0,0,0,1" />
<ListView
x:Name="FilterListView"
x:Uid="FilterListView"
Grid.Row="2"
MaxHeight="300"
Margin="0,4,0,4"
IsItemClickEnabled="True"
ItemClick="FilterListView_ItemClick"
ItemTemplateSelector="{StaticResource FilterTemplateSelector}"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
<TextBlock
x:Name="NoResultsText"
x:Uid="FiltersDropDown_NoResults"
Grid.Row="2"
Margin="0,16"
HorizontalAlignment="Center"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="Collapsed" />
</Grid>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
</UserControl>

View File

@@ -2,11 +2,17 @@
// 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;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.Foundation;
using Windows.System;
namespace Microsoft.CmdPal.UI.Controls;
@@ -14,6 +20,11 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class FiltersDropDown : UserControl,
ICurrentPageAware
{
private bool _isDropDownOpen;
private string? _pendingSearchText;
private IFilterItemViewModel[] _allItems = [];
private FilterItemViewModel? _lastSelectedFilter;
public PageViewModel? CurrentPageViewModel
{
get => (PageViewModel?)GetValue(CurrentPageViewModelProperty);
@@ -62,11 +73,146 @@ public sealed partial class FiltersDropDown : UserControl,
}
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null));
DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null, OnViewModelChanged));
/// <summary>
/// Gets a value indicating whether the dropdown is currently open or the button has keyboard focus.
/// </summary>
public bool IsActive => _isDropDownOpen ||
FilterDropDownButton.FocusState != FocusState.Unfocused;
/// <summary>
/// Gets a value indicating whether the filter control is visible (has filters to show).
/// </summary>
public bool IsFilterVisible => ViewModel?.ShouldShowFilters ?? false;
private static readonly string _defaultFilterText = ResourceLoaderInstance.GetString("FiltersDropDown_DefaultText");
public FiltersDropDown()
{
this.InitializeComponent();
SelectedFilterText.Text = _defaultFilterText;
FilterDropDownButton.AddHandler(
CharacterReceivedEvent,
new TypedEventHandler<UIElement, CharacterReceivedRoutedEventArgs>(FilterDropDownButton_CharacterReceived),
true);
}
private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FiltersDropDown @this)
{
return;
}
if (e.OldValue is FiltersViewModel oldVm)
{
oldVm.PropertyChanged -= @this.ViewModel_PropertyChanged;
}
if (e.NewValue is FiltersViewModel newVm)
{
newVm.PropertyChanged += @this.ViewModel_PropertyChanged;
}
@this.OnFiltersChanged();
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(FiltersViewModel.Filters)
or nameof(FiltersViewModel.CurrentFilter)
or nameof(FiltersViewModel.ShouldShowFilters))
{
OnFiltersChanged();
}
}
private void OnFiltersChanged()
{
_allItems = ViewModel?.Filters ?? [];
UpdateFilteredList();
UpdateSelectedFilterDisplay();
}
private void UpdateSelectedFilterDisplay()
{
if (ViewModel?.CurrentFilter is FilterItemViewModel filter)
{
SelectedFilterText.Text = filter.Name;
SelectedFilterIcon.SourceKey = filter.Icon;
SelectedFilterIcon.Visibility = Visibility.Visible;
}
else
{
SelectedFilterText.Text = _defaultFilterText;
SelectedFilterIcon.SourceKey = null;
SelectedFilterIcon.Visibility = Visibility.Collapsed;
}
}
private void UpdateFilteredList()
{
if (FilterListView == null)
{
return;
}
var searchText = FilterSearchBox?.Text?.Trim() ?? string.Empty;
IFilterItemViewModel[] filtered;
if (string.IsNullOrEmpty(searchText))
{
filtered = _allItems;
}
else
{
var list = new List<IFilterItemViewModel>();
foreach (var item in _allItems)
{
if (item is FilterItemViewModel filterItem &&
filterItem.Name.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) > -1)
{
list.Add(item);
}
}
filtered = list.ToArray();
}
FilterListView.ItemsSource = filtered;
var hasResults = filtered.Length > 0;
FilterListView.Visibility = hasResults ? Visibility.Visible : Visibility.Collapsed;
NoResultsText.Visibility = hasResults ? Visibility.Collapsed : Visibility.Visible;
// Restore selection to current filter if present
if (_lastSelectedFilter != null && Array.IndexOf(filtered, _lastSelectedFilter) >= 0)
{
FilterListView.SelectedItem = _lastSelectedFilter;
}
else if (ViewModel?.CurrentFilter != null && Array.IndexOf(filtered, ViewModel.CurrentFilter) >= 0)
{
FilterListView.SelectedItem = ViewModel.CurrentFilter;
}
else if (hasResults)
{
// Select the first non-separator item
IFilterItemViewModel? first = null;
foreach (var item in filtered)
{
if (item is not SeparatorViewModel)
{
first = item;
break;
}
}
if (first != null)
{
FilterListView.SelectedItem = first;
}
}
}
// Used to handle the case when a ListPage's `Filters` may have changed
@@ -83,55 +229,239 @@ public sealed partial class FiltersDropDown : UserControl,
}
}
private void FiltersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
private void FilterDropDownButton_CharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs args)
{
if (CurrentPageViewModel is ListViewModel listViewModel &&
FiltersComboBox.SelectedItem is FilterItemViewModel filterItem)
// Redirect printable (non-space) characters to open flyout and type into search
if (!char.IsControl(args.Character) && args.Character != ' ')
{
listViewModel.UpdateCurrentFilter(filterItem.Id);
OpenFlyoutAndType(args.Character.ToString());
args.Handled = true;
}
}
private void FiltersComboBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
private void FilterDropDownButton_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Up)
{
NavigateUp();
var modifiers = KeyModifiers.GetCurrent();
e.Handled = true;
}
else if (e.Key == VirtualKey.Down)
switch (e.Key)
{
NavigateDown();
case VirtualKey.Down when modifiers.OnlyAlt:
goto case VirtualKey.F4;
e.Handled = true;
case VirtualKey.Down or VirtualKey.Up:
{
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
if (e.Key == VirtualKey.Down)
{
NavigateDown();
}
else
{
NavigateUp();
}
e.Handled = true;
break;
}
case VirtualKey.F4:
{
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
e.Handled = true;
break;
}
}
}
private void FilterSearchBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
var modifiers = KeyModifiers.GetCurrent();
switch (e.Key)
{
case VirtualKey.Down:
NavigateDown();
e.Handled = true;
break;
case VirtualKey.Up:
NavigateUp();
e.Handled = true;
break;
case VirtualKey.Enter:
SelectCurrentAndClose();
e.Handled = true;
break;
case VirtualKey.Escape:
if (!string.IsNullOrEmpty(FilterSearchBox.Text))
{
FilterSearchBox.Text = string.Empty;
}
else
{
CloseDropDownAndFocusSearch();
}
e.Handled = true;
break;
case VirtualKey.F when modifiers.Alt:
CloseDropDownAndFocusSearch();
e.Handled = true;
break;
}
}
private void FilterSearchBox_TextChanged(object sender, TextChangedEventArgs e) =>
UpdateFilteredList();
private void FilterListView_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is FilterItemViewModel filterItem)
{
SelectFilter(filterItem);
CloseDropDownAndFocusSearch();
}
}
private void FilterFlyout_Opened(object sender, object e)
{
_isDropDownOpen = true;
FilterSearchBox.Text = _pendingSearchText ?? string.Empty;
FilterSearchBox.SelectionStart = FilterSearchBox.Text.Length;
_pendingSearchText = null;
UpdateFilteredList();
FilterSearchBox.Focus(FocusState.Programmatic);
}
private void FilterFlyout_Closed(object sender, object e)
{
_isDropDownOpen = false;
_pendingSearchText = null;
FilterSearchBox.Text = string.Empty;
}
private void OpenFlyoutAndType(string text)
{
_pendingSearchText = (_pendingSearchText ?? string.Empty) + text;
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
else
{
FilterSearchBox.Text = _pendingSearchText;
FilterSearchBox.SelectionStart = FilterSearchBox.Text.Length;
FilterSearchBox.Focus(FocusState.Programmatic);
_pendingSearchText = null;
}
}
/// <summary>
/// Opens the filter dropdown flyout.
/// </summary>
public void OpenDropDown()
{
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
}
/// <summary>
/// Closes the filter dropdown flyout and returns focus to the main search box.
/// </summary>
public void CloseDropDownAndFocusSearch()
{
if (_isDropDownOpen)
{
FilterFlyout.Hide();
}
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
/// <summary>
/// Closes the filter dropdown flyout.
/// </summary>
public void CloseDropDown()
{
if (_isDropDownOpen)
{
FilterFlyout.Hide();
}
FilterDropDownButton.Focus(FocusState.Programmatic);
}
/// <summary>
/// Moves focus to this control (the dropdown button).
/// </summary>
public void FocusControl()
{
FilterDropDownButton.Focus(FocusState.Programmatic);
}
private void SelectCurrentAndClose()
{
if (FilterListView.SelectedItem is FilterItemViewModel filterItem)
{
SelectFilter(filterItem);
}
CloseDropDownAndFocusSearch();
}
private void SelectFilter(FilterItemViewModel filterItem)
{
_lastSelectedFilter = filterItem;
if (CurrentPageViewModel is ListViewModel listViewModel)
{
listViewModel.UpdateCurrentFilter(filterItem.Id);
}
// Update display immediately (UpdateCurrentFilter is async)
SelectedFilterText.Text = filterItem.Name;
SelectedFilterIcon.SourceKey = filterItem.Icon;
SelectedFilterIcon.Visibility = Visibility.Visible;
}
private void NavigateUp()
{
var newIndex = FiltersComboBox.SelectedIndex;
if (FilterListView.ItemsSource is not IFilterItemViewModel[] items || items.Length == 0)
{
return;
}
if (FiltersComboBox.SelectedIndex > 0)
if (!HasSelectableItem(items))
{
return;
}
var newIndex = FilterListView.SelectedIndex;
if (newIndex > 0)
{
newIndex--;
while (
newIndex >= 0 &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
while (newIndex >= 0 && IsSeparator(items[newIndex]))
{
newIndex--;
}
if (newIndex < 0)
{
newIndex = FiltersComboBox.Items.Count - 1;
while (
newIndex >= 0 &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
newIndex = items.Length - 1;
while (newIndex >= 0 && IsSeparator(items[newIndex]))
{
newIndex--;
}
@@ -139,17 +469,35 @@ public sealed partial class FiltersDropDown : UserControl,
}
else
{
newIndex = FiltersComboBox.Items.Count - 1;
newIndex = items.Length - 1;
while (newIndex >= 0 && IsSeparator(items[newIndex]))
{
newIndex--;
}
}
FiltersComboBox.SelectedIndex = newIndex;
if (newIndex >= 0)
{
FilterListView.SelectedIndex = newIndex;
FilterListView.ScrollIntoView(FilterListView.SelectedItem);
}
}
private void NavigateDown()
{
var newIndex = FiltersComboBox.SelectedIndex;
if (FilterListView.ItemsSource is not IFilterItemViewModel[] items || items.Length == 0)
{
return;
}
if (FiltersComboBox.SelectedIndex == FiltersComboBox.Items.Count - 1)
if (!HasSelectableItem(items))
{
return;
}
var newIndex = FilterListView.SelectedIndex;
if (newIndex >= items.Length - 1)
{
newIndex = 0;
}
@@ -157,33 +505,40 @@ public sealed partial class FiltersDropDown : UserControl,
{
newIndex++;
while (
newIndex < FiltersComboBox.Items.Count &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
while (newIndex < items.Length && IsSeparator(items[newIndex]))
{
newIndex++;
}
if (newIndex >= FiltersComboBox.Items.Count)
if (newIndex >= items.Length)
{
newIndex = 0;
while (
newIndex < FiltersComboBox.Items.Count &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
while (newIndex < items.Length && IsSeparator(items[newIndex]))
{
newIndex++;
}
}
}
FiltersComboBox.SelectedIndex = newIndex;
if (newIndex < items.Length)
{
FilterListView.SelectedIndex = newIndex;
FilterListView.ScrollIntoView(FilterListView.SelectedItem);
}
}
private bool IsSeparator(object item)
private static bool IsSeparator(object item) => item is SeparatorViewModel;
private static bool HasSelectableItem(IFilterItemViewModel[] items)
{
return item is SeparatorViewModel;
foreach (var item in items)
{
if (!IsSeparator(item))
{
return true;
}
}
return false;
}
}

View File

@@ -5,17 +5,16 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.Views;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using CoreVirtualKeyStates = Windows.UI.Core.CoreVirtualKeyStates;
using VirtualKey = Windows.System.VirtualKey;
namespace Microsoft.CmdPal.UI.Controls;
@@ -125,8 +124,7 @@ public sealed partial class SearchBar : UserControl,
return;
}
var ctrlPressed = (InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control) & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down;
if (ctrlPressed && e.Key == VirtualKey.I)
if (KeyModifiers.GetCurrent().Ctrl && e.Key == VirtualKey.I)
{
// Today you learned that Ctrl+I in a TextBox will insert a tab
// We don't want that, so we'll suppress it, this way it can be used for other purposes

View File

@@ -16,21 +16,38 @@ internal sealed partial class FilterTemplateSelector : DataTemplateSelector
public DataTemplate? Separator { get; set; }
[DynamicDependency(DynamicallyAccessedMemberTypes.All, "Microsoft.UI.Xaml.Controls.ComboBoxItem", "Microsoft.WinUI")]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, "Microsoft.UI.Xaml.Controls.ListViewItem", "Microsoft.WinUI")]
protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject)
{
DataTemplate? dataTemplate = Default;
if (dependencyObject is ComboBoxItem comboBoxItem)
{
comboBoxItem.IsEnabled = true;
var isSeparator = item is SeparatorViewModel;
if (item is SeparatorViewModel)
{
comboBoxItem.IsEnabled = false;
comboBoxItem.AllowFocusWhenDisabled = false;
comboBoxItem.AllowFocusOnInteraction = false;
dataTemplate = Separator;
}
switch (dependencyObject)
{
case ComboBoxItem comboBoxItem:
comboBoxItem.IsEnabled = !isSeparator;
if (isSeparator)
{
comboBoxItem.AllowFocusWhenDisabled = false;
comboBoxItem.AllowFocusOnInteraction = false;
}
break;
case ListViewItem listViewItem:
listViewItem.IsEnabled = !isSeparator;
if (isSeparator)
{
listViewItem.MinHeight = 0;
listViewItem.IsHitTestVisible = false;
}
break;
}
if (isSeparator)
{
dataTemplate = Separator;
}
return dataTemplate;

View File

@@ -0,0 +1,50 @@
// 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.UI.Input;
using Windows.System;
using Windows.UI.Core;
namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// Snapshot of the current keyboard modifier state (Ctrl, Alt, Shift, Win).
/// </summary>
internal readonly struct KeyModifiers
{
public bool Ctrl { get; }
public bool Alt { get; }
public bool Shift { get; }
public bool Win { get; }
private KeyModifiers(bool ctrl, bool alt, bool shift, bool win)
{
Ctrl = ctrl;
Alt = alt;
Shift = shift;
Win = win;
}
/// <summary>
/// Gets a snapshot of the modifier keys currently held down.
/// </summary>
public static KeyModifiers GetCurrent()
{
var ctrl = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
var alt = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
var shift = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
var win = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
return new KeyModifiers(ctrl, alt, shift, win);
}
public bool OnlyAlt => Alt && !Ctrl && !Shift && !Win;
public bool OnlyCtrl => Ctrl && !Alt && !Shift && !Win;
public bool None => !Ctrl && !Alt && !Shift && !Win;
}

View File

@@ -504,6 +504,23 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
});
}
private void ToggleFilterFocus()
{
if (!FiltersDropDown.IsFilterVisible)
{
return;
}
if (FiltersDropDown.IsActive)
{
FiltersDropDown.CloseDropDownAndFocusSearch();
}
else
{
FiltersDropDown.OpenDropDown();
}
}
private void BackButton_Clicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
@@ -688,34 +705,32 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private static void ShellPage_OnPreviewKeyDown(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);
var modifiers = KeyModifiers.GetCurrent();
var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed;
var onlyCtrl = !altPressed && ctrlPressed && !shiftPressed && !winPressed;
switch (e.Key)
{
case VirtualKey.Left when onlyAlt: // Alt+Left arrow
case VirtualKey.Left when modifiers.OnlyAlt: // Alt+Left arrow
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
e.Handled = true;
break;
case VirtualKey.Home when onlyAlt: // Alt+Home
case VirtualKey.Home when modifiers.OnlyAlt: // Alt+Home
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(WithAnimation: false));
e.Handled = true;
break;
case (VirtualKey)188 when onlyCtrl: // Ctrl+,
case (VirtualKey)188 when modifiers.OnlyCtrl: // Ctrl+,
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(new());
e.Handled = true;
break;
case VirtualKey.F when modifiers.OnlyAlt: // Alt+F: toggle filter focus
((ShellPage)sender).ToggleFilterFocus();
e.Handled = true;
break;
default:
{
// 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);
TryCommandKeybindingMessage msg = new(modifiers.Ctrl, modifiers.Alt, modifiers.Shift, modifiers.Win, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
break;
@@ -725,8 +740,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private static void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
{
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
if (ctrlPressed && e.Key == VirtualKey.Enter)
var mods = KeyModifiers.GetCurrent();
if (mods.Ctrl && e.Key == VirtualKey.Enter)
{
// ctrl+enter
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
@@ -737,7 +752,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
e.Handled = true;
}
else if (ctrlPressed && e.Key == VirtualKey.K)
else if (mods.Ctrl && e.Key == VirtualKey.K)
{
// ctrl+k
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));

View File

@@ -16,7 +16,6 @@ using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Navigation;
using Windows.System;
using Windows.UI.Core;
using WinUIEx;
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
using TitleBar = Microsoft.UI.Xaml.Controls.TitleBar;
@@ -251,8 +250,7 @@ public sealed partial class SettingsWindow : WindowEx,
break;
case VirtualKey.Left:
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
if (altPressed)
if (KeyModifiers.GetCurrent().Alt)
{
TryGoBack();
}

View File

@@ -928,4 +928,25 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Unpin from dock</value>
<comment>Command name for unpinning an item from the dock</comment>
</data>
<data name="FiltersDropDown.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Filters</value>
</data>
<data name="FiltersDropDown.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Filters (Alt+F)</value>
</data>
<data name="FiltersDropDown_DefaultText" xml:space="preserve">
<value>Filters</value>
</data>
<data name="FilterSearchBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Search filters</value>
</data>
<data name="FilterSearchBox.PlaceholderText" xml:space="preserve">
<value>Search filters...</value>
</data>
<data name="FilterListView.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Filter options</value>
</data>
<data name="FiltersDropDown_NoResults.Text" xml:space="preserve">
<value>No results</value>
</data>
</root>