Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs
Sam Rueby b8a0163419 CmdPal: Arrow keys move logical grid pages (#43870)
<!-- 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

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [X] Closes: #41939
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [X] **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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Before

![Before](https://github.com/user-attachments/assets/49853e8d-9113-425c-8230-e49fb9b8d640)

After

![After](https://github.com/user-attachments/assets/a4597fe6-6503-4502-99cf-350425f5ef51)

I noticed the double "active" line around the items when the ListPage is
focused. I was unable to find where that is defined. Ideally, the
black-border would go away.

I tested with AOT turned on.

The behavior accounts for suggestions. If the SearchBar is focused and
there is a suggestion, right-arrow will [continue] to complete the
suggestion.
2025-12-08 12:13:33 -06:00

774 lines
27 KiB
C#

// 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 System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Commands;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
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;
public sealed partial class ListPage : Page,
IRecipient<NavigateNextCommand>,
IRecipient<NavigatePreviousCommand>,
IRecipient<NavigateLeftCommand>,
IRecipient<NavigateRightCommand>,
IRecipient<NavigatePageDownCommand>,
IRecipient<NavigatePageUpCommand>,
IRecipient<ActivateSelectedListItemMessage>,
IRecipient<ActivateSecondaryCommandMessage>
{
private InputSource _lastInputSource;
internal ListViewModel? ViewModel
{
get => (ListViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
// Using a DependencyProperty as the backing store for ViewModel. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged));
private ListViewBase ItemView
{
get
{
return ViewModel?.IsGridView == true ? ItemsGrid : ItemsList;
}
}
public ListPage()
{
this.InitializeComponent();
this.NavigationCacheMode = NavigationCacheMode.Disabled;
this.ItemView.Loaded += Items_Loaded;
this.ItemView.PreviewKeyDown += Items_PreviewKeyDown;
this.ItemView.PointerPressed += Items_PointerPressed;
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is not AsyncNavigationRequest navigationRequest)
{
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
}
if (navigationRequest.TargetViewModel is not ListViewModel listViewModel)
{
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ListViewModel)}");
}
ViewModel = listViewModel;
if (e.NavigationMode == NavigationMode.Back
|| (e.NavigationMode == NavigationMode.New && ItemView.Items.Count > 0))
{
// Upon navigating _back_ to this page, immediately select the
// first item in the list
ItemView.SelectedIndex = 0;
}
// RegisterAll isn't AOT compatible
WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Register<NavigateLeftCommand>(this);
WeakReferenceMessenger.Default.Register<NavigateRightCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this);
base.OnNavigatedTo(e);
}
protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
base.OnNavigatingFrom(e);
WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateRightCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Unregister<ActivateSelectedListItemMessage>(this);
WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this);
if (ViewModel is not null)
{
ViewModel.PropertyChanged -= ViewModel_PropertyChanged;
ViewModel.ItemsUpdated -= Page_ItemsUpdated;
}
if (e.NavigationMode != NavigationMode.New)
{
ViewModel?.SafeCleanup();
CleanupHelper.Cleanup(this);
}
// Clean-up event listeners
ViewModel = null;
GC.Collect();
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void Items_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is ListItemViewModel item)
{
if (_lastInputSource == InputSource.Keyboard)
{
ViewModel?.InvokeItemCommand.Execute(item);
return;
}
var settings = App.Current.Services.GetService<SettingsModel>()!;
if (settings.SingleClickActivates)
{
ViewModel?.InvokeItemCommand.Execute(item);
}
else
{
ViewModel?.UpdateSelectedItemCommand.Execute(item);
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
}
}
private void Items_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
{
if (ItemView.SelectedItem is ListItemViewModel vm)
{
var settings = App.Current.Services.GetService<SettingsModel>()!;
if (!settings.SingleClickActivates)
{
ViewModel?.InvokeItemCommand.Execute(vm);
}
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
var vm = ViewModel;
var li = ItemView.SelectedItem as ListItemViewModel;
_ = Task.Run(() =>
{
vm?.UpdateSelectedItemCommand.Execute(li);
});
// There's mysterious behavior here, where the selection seemingly
// changes to _nothing_ when we're backspacing to a single character.
// And at that point, seemingly the item that's getting removed is not
// a member of FilteredItems. Very bizarre.
//
// Might be able to fix in the future by stashing the removed item
// here, then in Page_ItemsUpdated trying to select that cached item if
// it's in the list (otherwise, clear the cache), but that seems
// aggressively BODGY for something that mostly just works today.
if (ItemView.SelectedItem is not null)
{
ItemView.ScrollIntoView(ItemView.SelectedItem);
// Automation notification for screen readers
var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView);
if (listViewPeer is not null && li is not null)
{
var notificationText = li.Title;
UIHelper.AnnounceActionForAccessibility(
ItemsList,
notificationText,
"CommandPaletteSelectedItemChanged");
}
}
}
private void Items_RightTapped(object sender, RightTappedRoutedEventArgs e)
{
if (e.OriginalSource is FrameworkElement element &&
element.DataContext is ListItemViewModel item)
{
if (ItemView.SelectedItem != item)
{
ItemView.SelectedItem = item;
}
ViewModel?.UpdateSelectedItemCommand.Execute(item);
var pos = e.GetPosition(element);
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
}
}
private void Items_Loaded(object sender, RoutedEventArgs e)
{
// Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid)
var listViewScrollViewer = FindScrollViewer(this.ItemView);
if (listViewScrollViewer is not null)
{
listViewScrollViewer.ViewChanged += ListViewScrollViewer_ViewChanged;
}
}
private void ListViewScrollViewer_ViewChanged(object? sender, ScrollViewerViewChangedEventArgs e)
{
var scrollView = sender as ScrollViewer;
if (scrollView is null)
{
return;
}
// When we get to the bottom, request more from the extension, if they
// have more to give us.
// We're checking when we get to 80% of the scroll height, to give the
// extension a bit of a heads-up before the user actually gets there.
if (scrollView.VerticalOffset >= (scrollView.ScrollableHeight * .8))
{
ViewModel?.LoadMoreIfNeeded();
}
}
public void Receive(NavigateNextCommand message)
{
// Note: We may want to just have the notion of a 'SelectedCommand' in our VM
// And then have these commands manipulate that state being bound to the UI instead
// We may want to see how other non-list UIs need to behave to make this decision
// At least it's decoupled from the SearchBox now :)
if (ViewModel?.IsGridView == true)
{
// For grid views, use spatial navigation (down)
HandleGridArrowNavigation(VirtualKey.Down);
}
else
{
// For list views, use simple linear navigation
if (ItemView.SelectedIndex < ItemView.Items.Count - 1)
{
ItemView.SelectedIndex++;
}
else
{
ItemView.SelectedIndex = 0;
}
}
}
public void Receive(NavigatePreviousCommand message)
{
if (ViewModel?.IsGridView == true)
{
// For grid views, use spatial navigation (up)
HandleGridArrowNavigation(VirtualKey.Up);
}
else
{
// For list views, use simple linear navigation
if (ItemView.SelectedIndex > 0)
{
ItemView.SelectedIndex--;
}
else
{
ItemView.SelectedIndex = ItemView.Items.Count - 1;
}
}
}
public void Receive(NavigateLeftCommand message)
{
// For grid views, use spatial navigation. For list views, just move up.
if (ViewModel?.IsGridView == true)
{
HandleGridArrowNavigation(VirtualKey.Left);
}
else
{
// In list view, left arrow doesn't navigate
// This maintains consistency with the SearchBar behavior
}
}
public void Receive(NavigateRightCommand message)
{
// For grid views, use spatial navigation. For list views, just move down.
if (ViewModel?.IsGridView == true)
{
HandleGridArrowNavigation(VirtualKey.Right);
}
else
{
// In list view, right arrow doesn't navigate
// This maintains consistency with the SearchBar behavior
}
}
public void Receive(ActivateSelectedListItemMessage message)
{
if (ViewModel?.ShowEmptyContent ?? false)
{
ViewModel?.InvokeItemCommand.Execute(null);
}
else if (ItemView.SelectedItem is ListItemViewModel item)
{
ViewModel?.InvokeItemCommand.Execute(item);
}
}
public void Receive(ActivateSecondaryCommandMessage message)
{
if (ViewModel?.ShowEmptyContent ?? false)
{
ViewModel?.InvokeSecondaryCommandCommand.Execute(null);
}
else if (ItemView.SelectedItem is ListItemViewModel item)
{
ViewModel?.InvokeSecondaryCommandCommand.Execute(item);
}
}
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)
{
if (e.OldValue is ListViewModel old)
{
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
old.ItemsUpdated -= @this.Page_ItemsUpdated;
}
if (e.NewValue is ListViewModel page)
{
page.PropertyChanged += @this.ViewModel_PropertyChanged;
page.ItemsUpdated += @this.Page_ItemsUpdated;
}
else if (e.NewValue is null)
{
Logger.LogDebug("cleared view model");
}
}
}
// Called after we've finished updating the whole list for either a
// GetItems or a change in the filter.
private void Page_ItemsUpdated(ListViewModel sender, object args)
{
// If for some reason, we don't have a selected item, fix that.
//
// It's important to do this here, because once there's no selection
// (which can happen as the list updates) we won't get an
// ItemView_SelectionChanged again to give us another chance to change
// the selection from null -> something. Better to just update the
// selection once, at the end of all the updating.
if (ItemView.SelectedItem is null)
{
ItemView.SelectedIndex = 0;
}
// Always reset the selected item when the top-level list page changes
// its items
if (!sender.IsNested)
{
ItemView.SelectedIndex = 0;
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var prop = e.PropertyName;
if (prop == nameof(ViewModel.FilteredItems))
{
Debug.WriteLine($"ViewModel.FilteredItems {ItemView.SelectedItem}");
}
}
private static ScrollViewer? FindScrollViewer(DependencyObject parent)
{
if (parent is ScrollViewer viewer)
{
return viewer;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
var result = FindScrollViewer(child);
if (result is not null)
{
return result;
}
}
return null;
}
// Find a logical neighbor in the requested direction using containers' positions.
private void HandleGridArrowNavigation(VirtualKey key)
{
if (ItemView.Items.Count == 0)
{
// No items, goodbye.
return;
}
var currentIndex = ItemView.SelectedIndex;
if (currentIndex < 0)
{
// -1 is a valid value (no item currently selected)
currentIndex = 0;
ItemView.SelectedIndex = 0;
}
try
{
// Try to compute using container positions; if not available, fall back to simple +/-1.
var currentContainer = ItemView.ContainerFromIndex(currentIndex) as FrameworkElement;
if (currentContainer is not null && currentContainer.ActualWidth != 0 && currentContainer.ActualHeight != 0)
{
// Use center of current container as reference
var curPoint = currentContainer.TransformToVisual(ItemView).TransformPoint(new Point(0, 0));
var curCenterX = curPoint.X + (currentContainer.ActualWidth / 2.0);
var curCenterY = curPoint.Y + (currentContainer.ActualHeight / 2.0);
var bestScore = double.MaxValue;
var bestIndex = currentIndex;
for (var i = 0; i < ItemView.Items.Count; i++)
{
if (i == currentIndex)
{
continue;
}
if (ItemView.ContainerFromIndex(i) is FrameworkElement c && c.ActualWidth > 0 && c.ActualHeight > 0)
{
var p = c.TransformToVisual(ItemView).TransformPoint(new Point(0, 0));
var centerX = p.X + (c.ActualWidth / 2.0);
var centerY = p.Y + (c.ActualHeight / 2.0);
var dx = centerX - curCenterX;
var dy = centerY - curCenterY;
var candidate = false;
var score = double.MaxValue;
switch (key)
{
case VirtualKey.Left:
if (dx < 0)
{
candidate = true;
score = Math.Abs(dy) + (Math.Abs(dx) * 0.7);
}
break;
case VirtualKey.Right:
if (dx > 0)
{
candidate = true;
score = Math.Abs(dy) + (Math.Abs(dx) * 0.7);
}
break;
case VirtualKey.Up:
if (dy < 0)
{
candidate = true;
score = Math.Abs(dx) + (Math.Abs(dy) * 0.7);
}
break;
case VirtualKey.Down:
if (dy > 0)
{
candidate = true;
score = Math.Abs(dx) + (Math.Abs(dy) * 0.7);
}
break;
}
if (candidate && score < bestScore)
{
bestScore = score;
bestIndex = i;
}
}
}
if (bestIndex != currentIndex)
{
ItemView.SelectedIndex = bestIndex;
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
return;
}
}
catch
{
// ignore transform errors and fall back
}
// fallback linear behavior
var fallback = key switch
{
VirtualKey.Left => Math.Max(0, currentIndex - 1),
VirtualKey.Right => Math.Min(ItemView.Items.Count - 1, currentIndex + 1),
VirtualKey.Up => Math.Max(0, currentIndex - 1),
VirtualKey.Down => Math.Min(ItemView.Items.Count - 1, currentIndex + 1),
_ => currentIndex,
};
if (fallback != currentIndex)
{
ItemView.SelectedIndex = fallback;
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
}
private void Items_OnContextRequested(UIElement sender, ContextRequestedEventArgs e)
{
var (item, element) = e.OriginalSource switch
{
// caused by keyboard shortcut (e.g. Context menu key or Shift+F10)
SelectorItem selectorItem => (ItemView.ItemFromContainer(selectorItem) as ListItemViewModel, selectorItem),
// caused by right-click on the ListViewItem
FrameworkElement { DataContext: ListItemViewModel itemViewModel } frameworkElement => (itemViewModel, frameworkElement),
_ => (null, null),
};
if (item is null || element is null)
{
return;
}
if (ItemView.SelectedItem != item)
{
ItemView.SelectedItem = item;
}
if (!e.TryGetPosition(element, out var pos))
{
pos = new(0, element.ActualHeight);
}
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
e.Handled = true;
}
private void Items_OnContextCanceled(UIElement sender, RoutedEventArgs e)
{
_ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>());
}
private void Items_PointerPressed(object sender, PointerRoutedEventArgs e) => _lastInputSource = InputSource.Pointer;
private void Items_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
// Track keyboard as the last input source for activation logic.
if (e.Key is VirtualKey.Enter or VirtualKey.Space)
{
_lastInputSource = InputSource.Keyboard;
return;
}
// Handle arrow navigation when we're showing a grid.
if (ViewModel?.IsGridView == true)
{
switch (e.Key)
{
case VirtualKey.Left:
case VirtualKey.Right:
case VirtualKey.Up:
case VirtualKey.Down:
_lastInputSource = InputSource.Keyboard;
HandleGridArrowNavigation(e.Key);
e.Handled = true;
break;
}
}
}
private enum InputSource
{
None,
Keyboard,
Pointer,
}
}