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++)