From d197af3da9bfa4b3c1d6f84e15cadf0df12e1e95 Mon Sep 17 00:00:00 2001 From: Sam Rueby Date: Tue, 28 Oct 2025 13:56:17 -0400 Subject: [PATCH] CmdPal's search bar now accepts page up/down keyboard strokes. (#41886) ## Summary of the Pull Request The page up/down keys now function while the search box is focused. ## PR Checklist - [ X ] Closes: #41877 - [ ] **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 Previously, the page up/down keys only performed any action while an item in the list was focused. The page up/down keys did not have any effect while the search box was focused, however the up/down arrows do have effect. This PR enables the page up/down keys while the search box is focused. There is a caveat here. The page up/down behavior is not consistent. I do not see a way to tell the ListView to perform its native page up/down function. Instead, I manually calculate roughly which item to scroll-to. Because of this, the amount of scroll between when the search box is focused and when an item in the ListView is focused is not consistent. ## Validation Steps Performed ![pageupdown](https://github.com/user-attachments/assets/b30f6e4e-03de-45bd-8570-0b06850bef24) In this GIF: 1. CmdPal appears 2. SearchBar focused, down/up arrow keys. 3. SearchBar focused, page down/up keys. 4. Tab to item in ListView 5. ListView item focused down/up arrow keys. 6. ListView item focused page down/up keys. 7. SearchBar focused 8. Filter "abc" 9. SearchBar focused page down/up keys. --- .../Messages/NavigatePageDownCommand.cs | 12 ++ .../Messages/NavigatePageUpCommand.cs | 12 ++ .../Controls/SearchBar.xaml.cs | 10 ++ .../ExtViews/ListPage.xaml.cs | 155 +++++++++++++++++- 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs new file mode 100644 index 0000000000..6c11394382 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs @@ -0,0 +1,12 @@ +// 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. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +/// +/// Used to navigate down one page in the page when pressing the PageDown key in the SearchBox. +/// +public record NavigatePageDownCommand +{ +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs new file mode 100644 index 0000000000..1985c07438 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs @@ -0,0 +1,12 @@ +// 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. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +/// +/// Used to navigate up one page in the page when pressing the PageUp key in the SearchBox. +/// +public record NavigatePageUpCommand +{ +} 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 5337a126c0..f5bac4a286 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -216,6 +216,16 @@ public sealed partial class SearchBar : UserControl, e.Handled = true; } + else if (e.Key == VirtualKey.PageDown) + { + WeakReferenceMessenger.Default.Send(); + e.Handled = true; + } + else if (e.Key == VirtualKey.PageUp) + { + WeakReferenceMessenger.Default.Send(); + e.Handled = true; + } if (InSuggestion) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index 55b2f368ba..a28ae3e133 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -18,6 +18,7 @@ using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; +using Windows.Foundation; using Windows.System; namespace Microsoft.CmdPal.UI; @@ -25,6 +26,8 @@ namespace Microsoft.CmdPal.UI; public sealed partial class ListPage : Page, IRecipient, IRecipient, + IRecipient, + IRecipient, IRecipient, IRecipient { @@ -82,6 +85,8 @@ public sealed partial class ListPage : Page, // RegisterAll isn't AOT compatible WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -94,6 +99,8 @@ public sealed partial class ListPage : Page, WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); @@ -181,9 +188,9 @@ public sealed partial class ListPage : Page, var notificationText = li.Title; UIHelper.AnnounceActionForAccessibility( - ItemsList, - notificationText, - "CommandPaletteSelectedItemChanged"); + ItemsList, + notificationText, + "CommandPaletteSelectedItemChanged"); } } } @@ -296,6 +303,142 @@ public sealed partial class ListPage : Page, } } + public void Receive(NavigatePageDownCommand message) + { + var indexes = CalculateTargetIndexPageUpDownScrollTo(true); + if (indexes is null) + { + return; + } + + if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) + { + ItemView.SelectedIndex = indexes.Value.TargetIndex; + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + } + + public void Receive(NavigatePageUpCommand message) + { + var indexes = CalculateTargetIndexPageUpDownScrollTo(false); + if (indexes is null) + { + return; + } + + if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) + { + ItemView.SelectedIndex = indexes.Value.TargetIndex; + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + } + + /// + /// Calculates the item index to target when performing a page up or page down + /// navigation. The calculation attempts to estimate how many items fit into + /// the visible viewport by measuring actual container heights currently visible + /// within the internal ScrollViewer. If measurements are not available a + /// fallback estimate is used. + /// + /// True to calculate a page-down target, false for page-up. + /// + /// A tuple containing the current index and the calculated target index, or null + /// if a valid calculation could not be performed (for example, missing ScrollViewer). + /// + private (int CurrentIndex, int TargetIndex)? CalculateTargetIndexPageUpDownScrollTo(bool isPageDown) + { + var scroll = FindScrollViewer(ItemView); + if (scroll is null) + { + return null; + } + + var viewportHeight = scroll.ViewportHeight; + if (viewportHeight <= 0) + { + return null; + } + + var currentIndex = ItemView.SelectedIndex < 0 ? 0 : ItemView.SelectedIndex; + var itemCount = ItemView.Items.Count; + + // Compute visible item heights within the ScrollViewer viewport + const int firstVisibleIndexNotFound = -1; + var firstVisibleIndex = firstVisibleIndexNotFound; + var visibleHeights = new List(itemCount); + + for (var i = 0; i < itemCount; i++) + { + if (ItemView.ContainerFromIndex(i) is FrameworkElement container) + { + try + { + var transform = container.TransformToVisual(scroll); + var topLeft = transform.TransformPoint(new Point(0, 0)); + var bottom = topLeft.Y + container.ActualHeight; + + // If any part of the container is inside the viewport, consider it visible + if (topLeft.Y >= 0 && bottom <= viewportHeight) + { + if (firstVisibleIndex == firstVisibleIndexNotFound) + { + firstVisibleIndex = i; + } + + visibleHeights.Add(container.ActualHeight > 0 ? container.ActualHeight : 0); + } + } + catch + { + // ignore transform errors and continue + } + } + } + + var itemsPerPage = 0; + + // Calculate how many items fit in the viewport based on their actual heights + if (visibleHeights.Count > 0) + { + double accumulated = 0; + for (var i = 0; i < visibleHeights.Count; i++) + { + accumulated += visibleHeights[i] <= 0 ? 1 : visibleHeights[i]; + itemsPerPage++; + if (accumulated >= viewportHeight) + { + break; + } + } + } + else + { + // fallback: estimate using first measured container height + double itemHeight = 0; + for (var i = currentIndex; i < itemCount; i++) + { + if (ItemView.ContainerFromIndex(i) is FrameworkElement { ActualHeight: > 0 } c) + { + itemHeight = c.ActualHeight; + break; + } + } + + if (itemHeight <= 0) + { + itemHeight = 1; + } + + itemsPerPage = Math.Max(1, (int)Math.Floor(viewportHeight / itemHeight)); + } + + var targetIndex = isPageDown + ? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage)) + : Math.Max(0, currentIndex - Math.Max(1, itemsPerPage)); + + return (currentIndex, targetIndex); + } + private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is ListPage @this) @@ -351,11 +494,11 @@ public sealed partial class ListPage : Page, } } - private ScrollViewer? FindScrollViewer(DependencyObject parent) + private static ScrollViewer? FindScrollViewer(DependencyObject parent) { - if (parent is ScrollViewer) + if (parent is ScrollViewer viewer) { - return (ScrollViewer)parent; + return viewer; } for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)