From d20ae940d5329465b35bc56d4da0ce0965f221a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Wed, 4 Mar 2026 20:05:22 +0100 Subject: [PATCH] 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 image ## PR Checklist - [x] Closes: #41648 - [ ] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../Controls/ContextMenu.xaml.cs | 19 +- .../Controls/FiltersDropDown.xaml | 121 +++-- .../Controls/FiltersDropDown.xaml.cs | 441 ++++++++++++++++-- .../Controls/SearchBar.xaml.cs | 6 +- .../Converters/FilterTemplateSelector.cs | 37 +- .../Helpers/KeyModifiers.cs | 50 ++ .../Pages/ShellPage.xaml.cs | 43 +- .../Settings/SettingsWindow.xaml.cs | 4 +- .../Strings/en-us/Resources.resw | 21 + 9 files changed, 621 insertions(+), 121 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/KeyModifiers.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs index 29839eb514..817734f134 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs @@ -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()) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml index be8579f082..fb9f0c6cd9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml @@ -22,20 +22,9 @@ Default="{StaticResource FilterItemViewModelTemplate}" Separator="{StaticResource SeparatorViewModelTemplate}" /> - - - + @@ -58,34 +47,100 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs index 78566b5273..9ec6dd9ddb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs @@ -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)); + + /// + /// Gets a value indicating whether the dropdown is currently open or the button has keyboard focus. + /// + public bool IsActive => _isDropDownOpen || + FilterDropDownButton.FocusState != FocusState.Unfocused; + + /// + /// Gets a value indicating whether the filter control is visible (has filters to show). + /// + 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(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(); + 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; + } + } + + /// + /// Opens the filter dropdown flyout. + /// + public void OpenDropDown() + { + if (!_isDropDownOpen) + { + FilterFlyout.ShowAt(FilterDropDownButton); + } + } + + /// + /// Closes the filter dropdown flyout and returns focus to the main search box. + /// + public void CloseDropDownAndFocusSearch() + { + if (_isDropDownOpen) + { + FilterFlyout.Hide(); + } + + WeakReferenceMessenger.Default.Send(); + } + + /// + /// Closes the filter dropdown flyout. + /// + public void CloseDropDown() + { + if (_isDropDownOpen) + { + FilterFlyout.Hide(); + } + + FilterDropDownButton.Focus(FocusState.Programmatic); + } + + /// + /// Moves focus to this control (the dropdown button). + /// + 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; } } 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 6ad173af35..ea9d362285 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -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 diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs index cf71d3f3af..fcc3c8de58 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs @@ -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; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/KeyModifiers.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/KeyModifiers.cs new file mode 100644 index 0000000000..707ff03aa4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/KeyModifiers.cs @@ -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; + +/// +/// Snapshot of the current keyboard modifier state (Ctrl, Alt, Shift, Win). +/// +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; + } + + /// + /// Gets a snapshot of the modifier keys currently held down. + /// + 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; +} 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 bf89f1e056..0c821dda5c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -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(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(new()); e.Handled = true; break; - case VirtualKey.Home when onlyAlt: // Alt+Home + case VirtualKey.Home when modifiers.OnlyAlt: // Alt+Home WeakReferenceMessenger.Default.Send(new(WithAnimation: false)); e.Handled = true; break; - case (VirtualKey)188 when onlyCtrl: // Ctrl+, + case (VirtualKey)188 when modifiers.OnlyCtrl: // Ctrl+, WeakReferenceMessenger.Default.Send(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(); @@ -737,7 +752,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, WeakReferenceMessenger.Default.Send(); e.Handled = true; } - else if (ctrlPressed && e.Key == VirtualKey.K) + else if (mods.Ctrl && e.Key == VirtualKey.K) { // ctrl+k WeakReferenceMessenger.Default.Send(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs index feae8902b6..de3664641d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs @@ -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(); } 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 262d16a483..879504bb97 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 @@ -928,4 +928,25 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Unpin from dock Command name for unpinning an item from the dock + + Filters + + + Filters (Alt+F) + + + Filters + + + Search filters + + + Search filters... + + + Filter options + + + No results + \ No newline at end of file