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