diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateLeftCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateLeftCommand.cs
new file mode 100644
index 0000000000..d352b552cf
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateLeftCommand.cs
@@ -0,0 +1,10 @@
+// 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 left in a grid view when pressing the Left arrow key in the SearchBox.
+///
+public record NavigateLeftCommand;
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs
new file mode 100644
index 0000000000..3cfb05913d
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs
@@ -0,0 +1,10 @@
+// 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 right in a grid view when pressing the Right arrow key in the SearchBox.
+///
+public record NavigateRightCommand;
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 169b34a8b0..0d6fd58afa 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs
@@ -208,21 +208,32 @@ public sealed partial class SearchBar : UserControl,
e.Handled = true;
}
+ else if (e.Key == VirtualKey.Left)
+ {
+ // Check if we're in a grid view, and if so, send grid navigation command
+ var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true };
+
+ // Special handling is required if we're in grid view.
+ if (isGridView)
+ {
+ WeakReferenceMessenger.Default.Send();
+ e.Handled = true;
+ }
+ }
else if (e.Key == VirtualKey.Right)
{
// Check if the "replace search text with suggestion" feature from 0.4-0.5 is enabled.
// If it isn't, then only use the suggestion when the caret is at the end of the input.
if (!IsTextToSuggestEnabled)
{
- if (_textToSuggest != null &&
+ if (!string.IsNullOrEmpty(_textToSuggest) &&
FilterBox.SelectionStart == FilterBox.Text.Length)
{
FilterBox.Text = _textToSuggest;
FilterBox.Select(_textToSuggest.Length, 0);
e.Handled = true;
+ return;
}
-
- return;
}
// Here, we're using the "replace search text with suggestion" feature.
@@ -232,6 +243,20 @@ public sealed partial class SearchBar : UserControl,
_lastText = null;
DoFilterBoxUpdate();
}
+
+ // Wouldn't want to perform text completion *and* move the selected item, so only perform this if text suggestion wasn't performed.
+ if (!e.Handled)
+ {
+ // Check if we're in a grid view, and if so, send grid navigation command
+ var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true };
+
+ // Special handling is required if we're in grid view.
+ if (isGridView)
+ {
+ WeakReferenceMessenger.Default.Send();
+ e.Handled = true;
+ }
+ }
}
else if (e.Key == VirtualKey.Down)
{
@@ -274,6 +299,8 @@ public sealed partial class SearchBar : UserControl,
e.Key == VirtualKey.Up ||
e.Key == VirtualKey.Down ||
+ e.Key == VirtualKey.Left ||
+ e.Key == VirtualKey.Right ||
e.Key == VirtualKey.RightMenu ||
e.Key == VirtualKey.LeftMenu ||
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 a28ae3e133..8957f63ea4 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs
@@ -26,6 +26,8 @@ namespace Microsoft.CmdPal.UI;
public sealed partial class ListPage : Page,
IRecipient,
IRecipient,
+ IRecipient,
+ IRecipient,
IRecipient,
IRecipient,
IRecipient,
@@ -85,6 +87,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);
WeakReferenceMessenger.Default.Register(this);
@@ -99,6 +103,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);
WeakReferenceMessenger.Default.Unregister(this);
@@ -257,25 +263,71 @@ public sealed partial class ListPage : Page,
// 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 (ItemView.SelectedIndex < ItemView.Items.Count - 1)
+ if (ViewModel?.IsGridView == true)
{
- ItemView.SelectedIndex++;
+ // For grid views, use spatial navigation (down)
+ HandleGridArrowNavigation(VirtualKey.Down);
}
else
{
- ItemView.SelectedIndex = 0;
+ // 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 (ItemView.SelectedIndex > 0)
+ if (ViewModel?.IsGridView == true)
{
- ItemView.SelectedIndex--;
+ // For grid views, use spatial navigation (up)
+ HandleGridArrowNavigation(VirtualKey.Up);
}
else
{
- ItemView.SelectedIndex = ItemView.Items.Count - 1;
+ // 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
}
}
@@ -514,6 +566,130 @@ public sealed partial class ListPage : Page,
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
@@ -564,9 +740,27 @@ public sealed partial class ListPage : Page,
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;
+ }
}
}