diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index 39180123ff..bc41c68fce 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -1524,6 +1524,7 @@ rgh
rgn
rgs
rguid
+rhk
RIDEV
RIGHTSCROLLBAR
riid
@@ -1630,6 +1631,7 @@ SHGDNF
SHGFI
SHIL
shinfo
+shk
shlwapi
shobjidl
SHORTCUTATLEAST
diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index 83289fa102..f4e3e1ba38 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -60,6 +60,8 @@
"PowerToys.FancyZonesEditorCommon.dll",
"PowerToys.FancyZonesModuleInterface.dll",
"PowerToys.FancyZones.exe",
+ "FancyZonesCLI.exe",
+ "FancyZonesCLI.dll",
"PowerToys.GcodePreviewHandler.dll",
"PowerToys.GcodePreviewHandler.exe",
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c2bfe8d6f4..950e4e9b93 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -73,10 +73,10 @@
-
-
-
-
+
+
+
+
@@ -116,6 +116,7 @@
+
diff --git a/PowerToys.slnx b/PowerToys.slnx
index a79340db47..7c880497f8 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -370,6 +370,10 @@
+
+
+
+
diff --git a/src/common/utils/registry.h b/src/common/utils/registry.h
index 059589352d..c9770bbea3 100644
--- a/src/common/utils/registry.h
+++ b/src/common/utils/registry.h
@@ -16,9 +16,54 @@
namespace registry
{
+ namespace detail
+ {
+ struct on_exit
+ {
+ std::function f;
+
+ on_exit(std::function f) :
+ f{ std::move(f) } {}
+ ~on_exit() { f(); }
+ };
+
+ template
+ struct overloaded : Ts...
+ {
+ using Ts::operator()...;
+ };
+
+ template
+ overloaded(Ts...) -> overloaded;
+
+ inline const wchar_t* getScopeName(HKEY scope)
+ {
+ if (scope == HKEY_LOCAL_MACHINE)
+ {
+ return L"HKLM";
+ }
+ else if (scope == HKEY_CURRENT_USER)
+ {
+ return L"HKCU";
+ }
+ else if (scope == HKEY_CLASSES_ROOT)
+ {
+ return L"HKCR";
+ }
+ else
+ {
+ return L"HK??";
+ }
+ }
+ }
+
namespace install_scope
{
const wchar_t INSTALL_SCOPE_REG_KEY[] = L"Software\\Classes\\powertoys\\";
+ const wchar_t UNINSTALL_REG_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall";
+
+ // Bundle UpgradeCode from PowerToys.wxs (with braces as stored in registry)
+ const wchar_t BUNDLE_UPGRADE_CODE[] = L"{6341382D-C0A9-4238-9188-BE9607E3FAB2}";
enum class InstallScope
{
@@ -26,8 +71,67 @@ namespace registry
PerUser,
};
+ // Helper function to find PowerToys bundle in Windows Uninstall registry by BundleUpgradeCode
+ inline bool find_powertoys_bundle_in_uninstall_registry(HKEY rootKey)
+ {
+ HKEY uninstallKey{};
+ if (RegOpenKeyExW(rootKey, UNINSTALL_REG_KEY, 0, KEY_READ, &uninstallKey) != ERROR_SUCCESS)
+ {
+ return false;
+ }
+ detail::on_exit closeUninstallKey{ [uninstallKey] { RegCloseKey(uninstallKey); } };
+
+ DWORD index = 0;
+ wchar_t subKeyName[256];
+
+ // Enumerate all subkeys under Uninstall
+ while (RegEnumKeyW(uninstallKey, index++, subKeyName, 256) == ERROR_SUCCESS)
+ {
+ HKEY productKey{};
+ if (RegOpenKeyExW(uninstallKey, subKeyName, 0, KEY_READ, &productKey) != ERROR_SUCCESS)
+ {
+ continue;
+ }
+ detail::on_exit closeProductKey{ [productKey] { RegCloseKey(productKey); } };
+
+ // Check BundleUpgradeCode value (specific to WiX Bundle installations)
+ wchar_t bundleUpgradeCode[256]{};
+ DWORD bundleUpgradeCodeSize = sizeof(bundleUpgradeCode);
+
+ if (RegQueryValueExW(productKey, L"BundleUpgradeCode", nullptr, nullptr,
+ reinterpret_cast(bundleUpgradeCode), &bundleUpgradeCodeSize) == ERROR_SUCCESS)
+ {
+ if (_wcsicmp(bundleUpgradeCode, BUNDLE_UPGRADE_CODE) == 0)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
inline const InstallScope get_current_install_scope()
{
+ // 1. Check HKCU Uninstall registry first (user-level bundle)
+ // Note: MSI components are always in HKLM regardless of install scope,
+ // but the Bundle entry will be in HKCU for per-user installations
+ if (find_powertoys_bundle_in_uninstall_registry(HKEY_CURRENT_USER))
+ {
+ Logger::info(L"Found user-level PowerToys bundle via BundleUpgradeCode in HKCU");
+ return InstallScope::PerUser;
+ }
+
+ // 2. Check HKLM Uninstall registry (machine-level bundle)
+ if (find_powertoys_bundle_in_uninstall_registry(HKEY_LOCAL_MACHINE))
+ {
+ Logger::info(L"Found machine-level PowerToys bundle via BundleUpgradeCode in HKLM");
+ return InstallScope::PerMachine;
+ }
+
+ // 3. Fallback to legacy custom registry key detection
+ Logger::info(L"PowerToys bundle not found in Uninstall registry, falling back to legacy detection");
+
// Open HKLM key
HKEY perMachineKey{};
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE,
@@ -45,6 +149,7 @@ namespace registry
&perUserKey) != ERROR_SUCCESS)
{
// both keys are missing
+ Logger::warn(L"No PowerToys installation detected, defaulting to PerMachine");
return InstallScope::PerMachine;
}
else
@@ -96,47 +201,6 @@ namespace registry
template
inline constexpr bool always_false_v = false;
- namespace detail
- {
- struct on_exit
- {
- std::function f;
-
- on_exit(std::function f) :
- f{ std::move(f) } {}
- ~on_exit() { f(); }
- };
-
- template
- struct overloaded : Ts...
- {
- using Ts::operator()...;
- };
-
- template
- overloaded(Ts...) -> overloaded;
-
- inline const wchar_t* getScopeName(HKEY scope)
- {
- if (scope == HKEY_LOCAL_MACHINE)
- {
- return L"HKLM";
- }
- else if (scope == HKEY_CURRENT_USER)
- {
- return L"HKCU";
- }
- else if (scope == HKEY_CLASSES_ROOT)
- {
- return L"HKCR";
- }
- else
- {
- return L"HK??";
- }
- }
- }
-
struct ValueChange
{
using value_t = std::variant;
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.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
index 003a0bfb9e..4118ac64db 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
@@ -12,6 +12,7 @@ using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.Ext.Apps.State;
+using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -44,6 +45,9 @@ public partial class MainListPage : DynamicListPage,
private List>? _filteredItems;
private List>? _filteredApps;
private List>? _fallbackItems;
+
+ // Keep as IEnumerable for deferred execution. Fallback item titles are updated
+ // asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable>? _scoredFallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
@@ -155,42 +159,18 @@ public partial class MainListPage : DynamicListPage,
public override IListItem[] GetItems()
{
- if (string.IsNullOrEmpty(SearchText))
+ lock (_tlcManager.TopLevelCommands)
{
- lock (_tlcManager.TopLevelCommands)
- {
- return _tlcManager
- .TopLevelCommands
- .Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
- .ToArray();
- }
- }
- else
- {
- lock (_tlcManager.TopLevelCommands)
- {
- var limitedApps = new List>();
-
- // Fuzzy matching can produce a lot of results, so we want to limit the
- // number of apps we show at once if it's a large set.
- if (_filteredApps?.Count > 0)
- {
- limitedApps = _filteredApps.OrderByDescending(s => s.Score).Take(_appResultLimit).ToList();
- }
-
- var items = Enumerable.Empty>()
- .Concat(_filteredItems is not null ? _filteredItems : [])
- .Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : [])
- .Concat(limitedApps)
- .OrderByDescending(o => o.Score)
-
- // Add fallback items post-sort so they are always at the end of the list
- // and eventually ordered based on user preference
- .Concat(_fallbackItems is not null ? _fallbackItems.Where(w => !string.IsNullOrEmpty(w.Item.Title)) : [])
- .Select(s => s.Item)
- .ToArray();
- return items;
- }
+ // Either return the top-level commands (no search text), or the merged and
+ // filtered results.
+ return string.IsNullOrEmpty(SearchText)
+ ? _tlcManager.TopLevelCommands.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title)).ToArray()
+ : MainListPageResultFactory.Create(
+ _filteredItems,
+ _scoredFallbackItems?.ToList(),
+ _filteredApps,
+ _fallbackItems,
+ _appResultLimit);
}
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs
new file mode 100644
index 0000000000..f1bddf5197
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs
@@ -0,0 +1,156 @@
+// 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.
+
+#pragma warning disable IDE0007 // Use implicit type
+
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+
+namespace Microsoft.CmdPal.UI.ViewModels.Commands;
+
+internal static class MainListPageResultFactory
+{
+ ///
+ /// Creates a merged and ordered array of results from multiple scored input lists,
+ /// applying an application result limit and filtering fallback items as needed.
+ ///
+ public static IListItem[] Create(
+ IList>? filteredItems,
+ IList>? scoredFallbackItems,
+ IList>? filteredApps,
+ IList>? fallbackItems,
+ int appResultLimit)
+ {
+ if (appResultLimit < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(appResultLimit), "App result limit must be non-negative.");
+ }
+
+ int len1 = filteredItems?.Count ?? 0;
+ int len2 = scoredFallbackItems?.Count ?? 0;
+
+ // Apps are pre-sorted, so we just need to take the top N, limited by appResultLimit.
+ int len3 = Math.Min(filteredApps?.Count ?? 0, appResultLimit);
+
+ // Allocate the exact size of the result array.
+ int totalCount = len1 + len2 + len3 + GetNonEmptyFallbackItemsCount(fallbackItems);
+ var result = new IListItem[totalCount];
+
+ // Three-way stable merge of already-sorted lists.
+ int idx1 = 0, idx2 = 0, idx3 = 0;
+ int writePos = 0;
+
+ // Merge while all three lists have items. To maintain a stable sort, the
+ // priority is: list1 > list2 > list3 when scores are equal.
+ while (idx1 < len1 && idx2 < len2 && idx3 < len3)
+ {
+ // Using null-forgiving operator as we have already checked against lengths.
+ int score1 = filteredItems![idx1].Score;
+ int score2 = scoredFallbackItems![idx2].Score;
+ int score3 = filteredApps![idx3].Score;
+
+ if (score1 >= score2 && score1 >= score3)
+ {
+ result[writePos++] = filteredItems[idx1++].Item;
+ }
+ else if (score2 >= score3)
+ {
+ result[writePos++] = scoredFallbackItems[idx2++].Item;
+ }
+ else
+ {
+ result[writePos++] = filteredApps[idx3++].Item;
+ }
+ }
+
+ // Two-way merges for remaining pairs.
+ while (idx1 < len1 && idx2 < len2)
+ {
+ if (filteredItems![idx1].Score >= scoredFallbackItems![idx2].Score)
+ {
+ result[writePos++] = filteredItems[idx1++].Item;
+ }
+ else
+ {
+ result[writePos++] = scoredFallbackItems[idx2++].Item;
+ }
+ }
+
+ while (idx1 < len1 && idx3 < len3)
+ {
+ if (filteredItems![idx1].Score >= filteredApps![idx3].Score)
+ {
+ result[writePos++] = filteredItems[idx1++].Item;
+ }
+ else
+ {
+ result[writePos++] = filteredApps[idx3++].Item;
+ }
+ }
+
+ while (idx2 < len2 && idx3 < len3)
+ {
+ if (scoredFallbackItems![idx2].Score >= filteredApps![idx3].Score)
+ {
+ result[writePos++] = scoredFallbackItems[idx2++].Item;
+ }
+ else
+ {
+ result[writePos++] = filteredApps[idx3++].Item;
+ }
+ }
+
+ // Drain remaining items from a non-empty list.
+ while (idx1 < len1)
+ {
+ result[writePos++] = filteredItems![idx1++].Item;
+ }
+
+ while (idx2 < len2)
+ {
+ result[writePos++] = scoredFallbackItems![idx2++].Item;
+ }
+
+ while (idx3 < len3)
+ {
+ result[writePos++] = filteredApps![idx3++].Item;
+ }
+
+ // Append filtered fallback items. Fallback items are added post-sort so they are
+ // always at the end of the list and eventually ordered based on user preference.
+ if (fallbackItems is not null)
+ {
+ for (int i = 0; i < fallbackItems.Count; i++)
+ {
+ var item = fallbackItems[i].Item;
+ if (!string.IsNullOrEmpty(item.Title))
+ {
+ result[writePos++] = item;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private static int GetNonEmptyFallbackItemsCount(IList>? fallbackItems)
+ {
+ int fallbackItemsCount = 0;
+
+ if (fallbackItems is not null)
+ {
+ for (int i = 0; i < fallbackItems.Count; i++)
+ {
+ if (!string.IsNullOrEmpty(fallbackItems[i].Item.Title))
+ {
+ fallbackItemsCount++;
+ }
+ }
+ }
+
+ return fallbackItemsCount;
+ }
+}
+#pragma warning restore IDE0007 // Use implicit type
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;
+ }
}
}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs
new file mode 100644
index 0000000000..624fa2da73
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs
@@ -0,0 +1,161 @@
+// 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;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CmdPal.UI.ViewModels.Commands;
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
+
+[TestClass]
+public partial class MainListPageResultFactoryTests
+{
+ private sealed partial class MockListItem : IListItem
+ {
+ public string Title { get; set; } = string.Empty;
+
+ public string Subtitle { get; set; } = string.Empty;
+
+ public ICommand Command => new NoOpCommand();
+
+ public IDetails? Details => null;
+
+ public IIconInfo? Icon => null;
+
+ public string Section => throw new NotImplementedException();
+
+ public ITag[] Tags => throw new NotImplementedException();
+
+ public string TextToSuggest => throw new NotImplementedException();
+
+ public IContextItem[] MoreCommands => throw new NotImplementedException();
+
+#pragma warning disable CS0067 // The event is never used
+ public event TypedEventHandler