CmdPal's search bar now accepts page up/down keyboard strokes. (#41886)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
The page up/down keys now function while the search box is focused.

<!-- Please review the items on the PR checklist before submitting-->
## 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

<!-- 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
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.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## 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.
This commit is contained in:
Sam Rueby
2025-10-28 13:56:17 -04:00
committed by GitHub
parent a4791cc493
commit d197af3da9
4 changed files with 183 additions and 6 deletions

View File

@@ -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<NavigateNextCommand>,
IRecipient<NavigatePreviousCommand>,
IRecipient<NavigatePageDownCommand>,
IRecipient<NavigatePageUpCommand>,
IRecipient<ActivateSelectedListItemMessage>,
IRecipient<ActivateSecondaryCommandMessage>
{
@@ -82,6 +85,8 @@ public sealed partial class ListPage : Page,
// RegisterAll isn't AOT compatible
WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this);
@@ -94,6 +99,8 @@ public sealed partial class ListPage : Page,
WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Unregister<ActivateSelectedListItemMessage>(this);
WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(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);
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="isPageDown">True to calculate a page-down target, false for page-up.</param>
/// <returns>
/// 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).
/// </returns>
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<double>(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++)