From d515c67def98da77f9335f997bac7520538c2103 Mon Sep 17 00:00:00 2001 From: leileizhang Date: Mon, 8 Dec 2025 13:34:33 +0800 Subject: [PATCH 1/5] Improve install scope detection to prevent mixed user/machine installations (#43931) ## Summary of the Pull Request The old implementation checked `HKLM\Software\Classes\powertoys\InstallScope` first. If this key existed (even as a remnant from incomplete uninstall), it would immediately return `PerMachine` without validating the actual installation. ### Fix - Uses Windows standard Uninstall registry (most reliable source of truth) - Identifies PowerToys Bundle by exact `BundleUpgradeCode` GUID match - MSI component entries (always in HKLM) are automatically ignored since they don't have `BundleUpgradeCode` - Checks HKCU first, then HKLM, properly handling the fact that Bundle location reflects true install scope ## PR Checklist - [x] Closes: #43696 - [ ] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- src/common/utils/registry.h | 146 ++++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 41 deletions(-) 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; From 06fcbdac400d6856f9e771a98828505b83152571 Mon Sep 17 00:00:00 2001 From: Gordon Lam <73506701+yeelam-gordon@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:52:33 +0800 Subject: [PATCH 2/5] Update WinAppSDK to 1.8.3 (#44146) ## Summary of the Pull Request This pull request updates several dependencies to newer versions in the `Directory.Packages.props` file. The main focus is on upgrading the Microsoft Windows App SDK packages to ensure the project uses the latest features and bug fixes. Dependency version updates: * Upgraded `Microsoft.WindowsAppSDK`, `Microsoft.WindowsAppSDK.Foundation`, `Microsoft.WindowsAppSDK.AI`, and `Microsoft.WindowsAppSDK.Runtime` to their latest respective versions, replacing previous 1.8.25* releases with newer builds. --- Directory.Packages.props | 9 +++++---- .../powerrename/PowerRenameUILib/PowerRenameUI.vcxproj | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 26e2c83bcc..3d64052a21 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,10 +73,10 @@ - - - - + + + + @@ -115,6 +115,7 @@ + diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj index 7f19db44b8..de71eb2188 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj @@ -36,6 +36,7 @@ PowerToys.PowerRename.pri win10-x64;win10-arm64 + false From b8a0163419f0800153e40a0e26ba77a4b453c74d Mon Sep 17 00:00:00 2001 From: Sam Rueby Date: Mon, 8 Dec 2025 13:13:33 -0500 Subject: [PATCH 3/5] CmdPal: Arrow keys move logical grid pages (#43870) ## Summary of the Pull Request ## PR Checklist - [X] Closes: #41939 - [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 ## Detailed Description of the Pull Request / Additional comments ## 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. --- .../Messages/NavigateLeftCommand.cs | 10 + .../Messages/NavigateRightCommand.cs | 10 + .../Controls/SearchBar.xaml.cs | 33 ++- .../ExtViews/ListPage.xaml.cs | 206 +++++++++++++++++- 4 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateLeftCommand.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs 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; + } } } From 4710b816b40b70216563e533de53a5db7a41337b Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Mon, 8 Dec 2025 21:01:56 +0000 Subject: [PATCH 4/5] [CmdPal] Optimise MainListPage's results display by merging already-sorted lists (#44126) ## Summary of the Pull Request This PR replaces the current LINQ-based results compilation query of combining, sorting and filtering the four result sources with a 3-way merge operation plus a final append. It provides a performance increase as well as a significant reduction in allocations. ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **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 ## Detailed Description of the Pull Request / Additional comments The existing code: 1. Limits the number of apps returned to a pre-defined maximum. 2. Sorts the apps list. 3. Appends filtered items, scored fallback items and the apps list together. 4. Sorts the three lists based on their score. 5. Appends the non-scored fallback items, with empty items excluded. 6. Selects just the `Item` from each. 7. Creates an array from the enumerable. ```csharp 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(); ``` We can exploit the fact that each of the three 'scored' lists are pre-ordered, and replace the query with a 3-way merge and final append of the non-scored fallback items. By pre-sizing the results array we can avoid all the extra allocations of the LINQ-based solution. ### Proof of pre-ordering In `UpdateSearchText`, each of the lists is defined by calling `ListHelpers.FilterListWithScores`: ```csharp // Produce a list of everything that matches the current filter. _filteredItems = [.. ListHelpers.FilterListWithScores(newFilteredItems ?? [], SearchText, scoreItem)]; ``` ```csharp _scoredFallbackItems = ListHelpers.FilterListWithScores(newFallbacksForScoring ?? [], SearchText, scoreItem); ``` ```csharp var scoredApps = ListHelpers.FilterListWithScores(newApps, SearchText, scoreItem); ... _filteredApps = [.. scoredApps]; ``` In `FilterListWithScores`, the results are ordered by score: ```csharp var scores = items .Select(li => new Scored() { Item = li, Score = scoreFunction(query, li) }) .Where(score => score.Score > 0) .OrderByDescending(score => score.Score); ``` (This also makes the existing `OrderByDescending()` for `_filteredApps` before the LINQ query redundant.) ### K-way merge Since the results are pre-sorted, we can do a direct merge in linear time. This is what the new `MainListPageResultFactory`'s `Create` achieves. As the lists may be different sizes, the routine does a 3-way merge, followed by a 2-way merge and a single list drain to finish. Each element is only visited once. ### Benchmarks A separate benchmark project is [here](https://github.com/daverayment/MainListBench), written with Benchmark.net. The project compares the current LINQ-based solution against: 1. An Array-based algorithm which pre-assigns a results array and still sorts the 3 scored sets of results. This shows a naive non-LINQ solution which is still _O(n log n)_ because of the sort. 2. The k-way merge, which is described above. _O(n)_ for both time and space complexity. 3. A heap merge algorithm, which uses a priority queue instead of tracking each of the lists separately. (This is _O(n log k)_ in terms of time complexity and _O(n + k)_ for space.) Care is taken to ensure stable sorting of items. When preparing the benchmark data, items with identical scores are assigned to confirm each algorithm performs identically to the LINQ `OrderBy` approach, which performs a stable sort. Results show that the merge performs best in terms of both runtime performance and allocations, sometimes by a significant margin. Compared to the LINQ approach, merge runs 400%+ faster and with at most ~20% of the allocations: image image See here for all charts and raw stats from the run: https://docs.google.com/spreadsheets/d/1y2mmWe8dfpbLxF_eqPbEGvaItmqp6HLfSp-rw99hzWg/edit?usp=sharing ### Cons 1. Existing performance is not currently an issue. This could be seen as a premature optimisation. 2. The new code introduces an inherent contract between the results compilation routine and the lists, i.e. that they must be sorted. This PR was really for research and learning more about CmdPal (and a bit of algorithm practice because it's Advent of Code time), so please feel free to reject if you feel the cons outweigh the pros. ## Validation Steps Performed - Added unit tests to exercise the new code, which confirm that the specific ordering is preserved, and the filtering and pre-trimming of the apps list is performed as before. - Existing non-UI unit tests run. NB: I _could not_ run any UI Tests on my system and just got an early bail-out each time. - Manual testing in (non-AOT) Release mode. --- .../Commands/MainListPage.cs | 50 ++---- .../Commands/MainListPageResultFactory.cs | 156 +++++++++++++++++ .../MainListPageResultFactoryTests.cs | 161 ++++++++++++++++++ 3 files changed, 332 insertions(+), 35 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs 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/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? PropChanged; +#pragma warning restore CS0067 // The event is never used + + public override string ToString() => Title; + } + + private static Scored S(string title, int score) + { + return new Scored + { + Score = score, + Item = new MockListItem { Title = title }, + }; + } + + [TestMethod] + public void Merge_PrioritizesListsCorrectly() + { + var filtered = new List> + { + S("F1", 100), + S("F2", 50), + }; + + var scoredFallback = new List> + { + S("SF1", 100), + S("SF2", 60), + }; + + var apps = new List> + { + S("A1", 100), + S("A2", 55), + }; + + // Fallbacks are not scored. + var fallbacks = new List> + { + S("FB1", 0), + S("FB2", 0), + }; + + var result = MainListPageResultFactory.Create( + filtered, + scoredFallback, + apps, + fallbacks, + appResultLimit: 10); + + // Expected order: + // 100: F1, SF1, A1 + // 60: SF2 + // 55: A2 + // 50: F2 + // Then fallbacks in original order: FB1, FB2 + var titles = result.Select(r => r.Title).ToArray(); +#pragma warning disable CA1861 // Avoid constant arrays as arguments + CollectionAssert.AreEqual( + new[] { "F1", "SF1", "A1", "SF2", "A2", "F2", "FB1", "FB2" }, + titles); +#pragma warning restore CA1861 // Avoid constant arrays as arguments + } + + [TestMethod] + public void Merge_AppliesAppLimit() + { + var apps = new List> + { + S("A1", 100), + S("A2", 90), + S("A3", 80), + }; + + var result = MainListPageResultFactory.Create( + null, + null, + apps, + null, + 2); + + Assert.AreEqual(2, result.Length); + Assert.AreEqual("A1", result[0].Title); + Assert.AreEqual("A2", result[1].Title); + } + + [TestMethod] + public void Merge_FiltersEmptyFallbacks() + { + var fallbacks = new List> + { + S("FB1", 0), + S(string.Empty, 0), + S("FB3", 0), + }; + + var result = MainListPageResultFactory.Create( + null, + null, + null, + fallbacks, + appResultLimit: 10); + + Assert.AreEqual(2, result.Length); + Assert.AreEqual("FB1", result[0].Title); + Assert.AreEqual("FB3", result[1].Title); + } + + [TestMethod] + public void Merge_HandlesNullLists() + { + var result = MainListPageResultFactory.Create( + null, + null, + null, + null, + appResultLimit: 10); + + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Length); + } +} From 73e379238bcefa2f10ab7da4791af830467fc3c2 Mon Sep 17 00:00:00 2001 From: leileizhang Date: Tue, 9 Dec 2025 10:13:48 +0800 Subject: [PATCH 5/5] Add FancyZones CLI for command-line layout management (#44078) ## Summary of the Pull Request Adds a new command-line interface (CLI) tool for FancyZones, enabling users and automation scripts to manage window layouts without the GUI. **Commands:** | Command | Aliases | Description | |---------|---------|-------------| | `help` | | Displays general help information for all commands | | `open-editor` | `editor`, `e` | Launch FancyZones layout editor | | `get-monitors` | `monitors`, `m` | List all monitors and their properties | | `get-layouts` | `layouts`, `ls` | List all available layouts with ASCII art preview | | `get-active-layout` | `active`, `a` | Show currently active layout | | `set-layout ` | `set`, `s` | Apply layout by UUID or template name | | `open-settings` | `settings` | Open FancyZones settings page | | `get-hotkeys` | `hotkeys`, `hk` | List all layout hotkeys | | `set-hotkey ` | `shk` | Assign hotkey (0-9) to custom layout | | `remove-hotkey ` | `rhk` | Remove hotkey assignment | **Key Capabilities:** - ASCII art visualization of layouts (grid, focus, priority-grid, canvas) - Support for both template layouts and custom layouts - Monitor-specific layout targeting (`--monitor N` or `--all`) - Real-time notification to FancyZones via Windows messages - Native AOT compilation support for fast startup ### Example Usage ```bash # List all layouts with visual previews FancyZonesCLI.exe ls # Apply "columns" template to all monitors FancyZonesCLI.exe s columns --all # Set custom layout on monitor 2 FancyZonesCLI.exe s {uuid} --monitor 2 # Assign hotkey Win+Ctrl+Alt+3 to a layout FancyZonesCLI.exe shk 3 {uuid} ``` https://github.com/user-attachments/assets/2b141399-a4ca-4f64-8750-f123b7e0fea7 ## PR Checklist - [ ] Closes: #xxx - [ ] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 2 + .pipelines/ESRPSigning_core.json | 2 + PowerToys.slnx | 4 + .../FancyZonesCLI/Commands/EditorCommands.cs | 90 +++ .../FancyZonesCLI/Commands/HotkeyCommands.cs | 98 ++++ .../FancyZonesCLI/Commands/LayoutCommands.cs | 276 +++++++++ .../FancyZonesCLI/Commands/MonitorCommands.cs | 49 ++ .../FancyZonesCLI/FancyZonesCLI.csproj | 32 + .../FancyZonesCLI/FancyZonesData.cs | 142 +++++ .../FancyZonesCLI/FancyZonesPaths.cs | 30 + .../FancyZonesCLI/LayoutVisualizer.cs | 550 ++++++++++++++++++ .../fancyzones/FancyZonesCLI/Logger.cs | 126 ++++ .../fancyzones/FancyZonesCLI/Models.cs | 137 +++++ .../fancyzones/FancyZonesCLI/NativeMethods.cs | 56 ++ .../FancyZonesCLI/NativeMethods.json | 5 + .../FancyZonesCLI/NativeMethods.txt | 4 + .../fancyzones/FancyZonesCLI/Program.cs | 115 ++++ 17 files changed, 1718 insertions(+) create mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj create mode 100644 src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/Logger.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/Models.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/NativeMethods.json create mode 100644 src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt create mode 100644 src/modules/fancyzones/FancyZonesCLI/Program.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index d4be728886..672616c8e7 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1483,6 +1483,7 @@ rgh rgn rgs rguid +rhk RIDEV RIGHTSCROLLBAR riid @@ -1588,6 +1589,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/PowerToys.slnx b/PowerToys.slnx index c946514fb5..1884b2d58b 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -370,6 +370,10 @@ + + + + diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs new file mode 100644 index 0000000000..7bf15dda44 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs @@ -0,0 +1,90 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; + +namespace FancyZonesCLI.Commands; + +/// +/// Editor and Settings commands. +/// +internal static class EditorCommands +{ + public static (int ExitCode, string Output) OpenEditor() + { + var editorExe = "PowerToys.FancyZonesEditor.exe"; + + // Check if editor-parameters.json exists + if (!FancyZonesData.EditorParametersExist()) + { + return (1, "Error: editor-parameters.json not found.\nPlease launch FancyZones Editor using Win+` (Win+Backtick) hotkey first."); + } + + // Check if editor is already running + var existingProcess = Process.GetProcessesByName("PowerToys.FancyZonesEditor").FirstOrDefault(); + if (existingProcess != null) + { + NativeMethods.SetForegroundWindow(existingProcess.MainWindowHandle); + return (0, "FancyZones Editor is already running. Brought window to foreground."); + } + + // Only check same directory as CLI + var editorPath = Path.Combine(AppContext.BaseDirectory, editorExe); + + if (File.Exists(editorPath)) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = editorPath, + UseShellExecute = true, + }); + return (0, "FancyZones Editor launched successfully."); + } + catch (Exception ex) + { + return (1, $"Failed to launch: {ex.Message}"); + } + } + + return (1, $"Error: Could not find {editorExe} in {AppContext.BaseDirectory}"); + } + + public static (int ExitCode, string Output) OpenSettings() + { + try + { + // Find PowerToys.exe in common locations + string powertoysExe = null; + + // Check in the same directory as the CLI (typical for dev builds) + var sameDirPath = Path.Combine(AppContext.BaseDirectory, "PowerToys.exe"); + if (File.Exists(sameDirPath)) + { + powertoysExe = sameDirPath; + } + + if (powertoysExe == null) + { + return (1, "Error: PowerToys.exe not found. Please ensure PowerToys is installed."); + } + + Process.Start(new ProcessStartInfo + { + FileName = powertoysExe, + Arguments = "--open-settings=FancyZones", + UseShellExecute = false, + }); + return (0, "FancyZones Settings opened successfully."); + } + catch (Exception ex) + { + return (1, $"Error: Failed to open FancyZones Settings. {ex.Message}"); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs new file mode 100644 index 0000000000..cfaf93a5d4 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs @@ -0,0 +1,98 @@ +// 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.Globalization; +using System.Linq; + +namespace FancyZonesCLI.Commands; + +/// +/// Hotkey-related commands. +/// +internal static class HotkeyCommands +{ + public static (int ExitCode, string Output) GetHotkeys() + { + var hotkeys = FancyZonesData.ReadLayoutHotkeys(); + if (hotkeys?.Hotkeys == null || hotkeys.Hotkeys.Count == 0) + { + return (0, "No hotkeys configured."); + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("=== Layout Hotkeys ===\n"); + sb.AppendLine("Press Win + Ctrl + Alt + to switch layouts:\n"); + + foreach (var hotkey in hotkeys.Hotkeys.OrderBy(h => h.Key)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" [{hotkey.Key}] => {hotkey.LayoutId}"); + } + + return (0, sb.ToString().TrimEnd()); + } + + public static (int ExitCode, string Output) SetHotkey(int key, string layoutUuid, Action notifyFancyZones, uint wmPrivLayoutHotkeysFileUpdate) + { + if (key < 0 || key > 9) + { + return (1, "Error: Key must be between 0 and 9"); + } + + // Check if this is a custom layout UUID + var customLayouts = FancyZonesData.ReadCustomLayouts(); + var matchedLayout = customLayouts?.Layouts?.FirstOrDefault(l => l.Uuid.Equals(layoutUuid, StringComparison.OrdinalIgnoreCase)); + bool isCustomLayout = matchedLayout != null; + string layoutName = matchedLayout?.Name ?? layoutUuid; + + var hotkeys = FancyZonesData.ReadLayoutHotkeys() ?? new LayoutHotkeys(); + + hotkeys.Hotkeys ??= new List(); + + // Remove existing hotkey for this key + hotkeys.Hotkeys.RemoveAll(h => h.Key == key); + + // Add new hotkey + hotkeys.Hotkeys.Add(new LayoutHotkey { Key = key, LayoutId = layoutUuid }); + + // Save + FancyZonesData.WriteLayoutHotkeys(hotkeys); + + // Notify FancyZones + notifyFancyZones(wmPrivLayoutHotkeysFileUpdate); + + if (isCustomLayout) + { + return (0, $"✓ Hotkey {key} assigned to custom layout '{layoutName}'\n Press Win + Ctrl + Alt + {key} to switch to this layout"); + } + else + { + return (0, $"⚠ Warning: Hotkey {key} assigned to '{layoutUuid}'\n Note: FancyZones hotkeys only work with CUSTOM layouts.\n Template layouts (focus, columns, rows, etc.) cannot be used with hotkeys.\n Create a custom layout in the FancyZones Editor to use this hotkey."); + } + } + + public static (int ExitCode, string Output) RemoveHotkey(int key, Action notifyFancyZones, uint wmPrivLayoutHotkeysFileUpdate) + { + var hotkeys = FancyZonesData.ReadLayoutHotkeys(); + if (hotkeys?.Hotkeys == null) + { + return (0, $"No hotkey assigned to key {key}"); + } + + var removed = hotkeys.Hotkeys.RemoveAll(h => h.Key == key); + if (removed == 0) + { + return (0, $"No hotkey assigned to key {key}"); + } + + // Save + FancyZonesData.WriteLayoutHotkeys(hotkeys); + + // Notify FancyZones + notifyFancyZones(wmPrivLayoutHotkeysFileUpdate); + + return (0, $"Hotkey {key} removed"); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs new file mode 100644 index 0000000000..4400b32d46 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs @@ -0,0 +1,276 @@ +// 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.Globalization; +using System.Linq; +using System.Text.Json; + +namespace FancyZonesCLI.Commands; + +/// +/// Layout-related commands. +/// +internal static class LayoutCommands +{ + public static (int ExitCode, string Output) GetLayouts() + { + var sb = new System.Text.StringBuilder(); + + // Print template layouts + var templatesJson = FancyZonesData.ReadLayoutTemplates(); + if (templatesJson?.Templates != null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Built-in Template Layouts ({templatesJson.Templates.Count} total) ===\n"); + + for (int i = 0; i < templatesJson.Templates.Count; i++) + { + var template = templatesJson.Templates[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[T{i + 1}] {template.Type}"); + sb.Append(CultureInfo.InvariantCulture, $" Zones: {template.ZoneCount}"); + if (template.ShowSpacing && template.Spacing > 0) + { + sb.Append(CultureInfo.InvariantCulture, $", Spacing: {template.Spacing}px"); + } + + sb.AppendLine(); + sb.AppendLine(); + + // Draw visual preview + sb.Append(LayoutVisualizer.DrawTemplateLayout(template)); + + if (i < templatesJson.Templates.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine("\n"); + } + + // Print custom layouts + var customLayouts = FancyZonesData.ReadCustomLayouts(); + if (customLayouts?.Layouts != null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Custom Layouts ({customLayouts.Layouts.Count} total) ==="); + + for (int i = 0; i < customLayouts.Layouts.Count; i++) + { + var layout = customLayouts.Layouts[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[{i + 1}] {layout.Name}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.Uuid}"); + sb.Append(CultureInfo.InvariantCulture, $" Type: {layout.Type}"); + + bool isCanvasLayout = false; + if (layout.Info.ValueKind != JsonValueKind.Undefined && layout.Info.ValueKind != JsonValueKind.Null) + { + if (layout.Type == "grid" && layout.Info.TryGetProperty("rows", out var rows) && layout.Info.TryGetProperty("columns", out var cols)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({rows.GetInt32()}x{cols.GetInt32()} grid)"); + } + else if (layout.Type == "canvas" && layout.Info.TryGetProperty("zones", out var zones)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({zones.GetArrayLength()} zones)"); + isCanvasLayout = true; + } + } + + sb.AppendLine("\n"); + + // Draw visual preview + sb.Append(LayoutVisualizer.DrawCustomLayout(layout)); + + // Add note for canvas layouts + if (isCanvasLayout) + { + sb.AppendLine("\n Note: Canvas layout preview is approximate."); + sb.AppendLine(" Open FancyZones Editor for precise zone boundaries."); + } + + if (i < customLayouts.Layouts.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine("\nUse 'FancyZonesCLI.exe set-layout ' to apply a layout."); + } + + return (0, sb.ToString().TrimEnd()); + } + + public static (int ExitCode, string Output) GetActiveLayout() + { + if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) + { + return (1, $"Error: {error}"); + } + + if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) + { + return (0, "No active layouts found."); + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n=== Active FancyZones Layout(s) ===\n"); + + for (int i = 0; i < appliedLayouts.Layouts.Count; i++) + { + var layout = appliedLayouts.Layouts[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {i + 1}:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Name: {layout.AppliedLayout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.AppliedLayout.Uuid}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Type: {layout.AppliedLayout.Type} ({layout.AppliedLayout.ZoneCount} zones)"); + + if (layout.AppliedLayout.ShowSpacing) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" Spacing: {layout.AppliedLayout.Spacing}px"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.AppliedLayout.SensitivityRadius}px"); + + if (i < appliedLayouts.Layouts.Count - 1) + { + sb.AppendLine(); + } + } + + return (0, sb.ToString().TrimEnd()); + } + + public static (int ExitCode, string Output) SetLayout(string[] args, Action notifyFancyZones, uint wmPrivAppliedLayoutsFileUpdate) + { + Logger.LogInfo($"SetLayout called with args: [{string.Join(", ", args)}]"); + + if (args.Length == 0) + { + return (1, "Error: set-layout requires a UUID parameter"); + } + + string uuid = args[0]; + int? targetMonitor = null; + bool applyToAll = false; + + // Parse options + for (int i = 1; i < args.Length; i++) + { + if (args[i] == "--monitor" && i + 1 < args.Length) + { + if (int.TryParse(args[i + 1], out int monitorNum)) + { + targetMonitor = monitorNum; + i++; // Skip next arg + } + else + { + return (1, $"Error: Invalid monitor number: {args[i + 1]}"); + } + } + else if (args[i] == "--all") + { + applyToAll = true; + } + } + + if (targetMonitor.HasValue && applyToAll) + { + return (1, "Error: Cannot specify both --monitor and --all"); + } + + // Try to find layout in custom layouts first (by UUID) + var customLayouts = FancyZonesData.ReadCustomLayouts(); + var targetCustomLayout = customLayouts?.Layouts?.FirstOrDefault(l => l.Uuid.Equals(uuid, StringComparison.OrdinalIgnoreCase)); + + // If not found in custom layouts, try template layouts (by type name) + TemplateLayout targetTemplate = null; + if (targetCustomLayout == null) + { + var templates = FancyZonesData.ReadLayoutTemplates(); + targetTemplate = templates?.Templates?.FirstOrDefault(t => t.Type.Equals(uuid, StringComparison.OrdinalIgnoreCase)); + } + + if (targetCustomLayout == null && targetTemplate == null) + { + return (1, $"Error: Layout '{uuid}' not found\nTip: For templates, use the type name (e.g., 'focus', 'columns', 'rows', 'grid', 'priority-grid')\n For custom layouts, use the UUID from 'get-layouts'"); + } + + // Read current applied layouts + if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) + { + return (1, $"Error: {error}"); + } + + if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) + { + return (1, "Error: No monitors configured"); + } + + // Determine which monitors to update + List monitorsToUpdate = new List(); + if (applyToAll) + { + for (int i = 0; i < appliedLayouts.Layouts.Count; i++) + { + monitorsToUpdate.Add(i); + } + } + else if (targetMonitor.HasValue) + { + int monitorIndex = targetMonitor.Value - 1; // Convert to 0-based + if (monitorIndex < 0 || monitorIndex >= appliedLayouts.Layouts.Count) + { + return (1, $"Error: Monitor {targetMonitor.Value} not found. Available monitors: 1-{appliedLayouts.Layouts.Count}"); + } + + monitorsToUpdate.Add(monitorIndex); + } + else + { + // Default: first monitor + monitorsToUpdate.Add(0); + } + + // Update selected monitors + foreach (int monitorIndex in monitorsToUpdate) + { + if (targetCustomLayout != null) + { + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Uuid = targetCustomLayout.Uuid; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Type = targetCustomLayout.Type; + } + else if (targetTemplate != null) + { + // For templates, use all-zeros UUID and the template type + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Uuid = "{00000000-0000-0000-0000-000000000000}"; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Type = targetTemplate.Type; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.ZoneCount = targetTemplate.ZoneCount; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.ShowSpacing = targetTemplate.ShowSpacing; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Spacing = targetTemplate.Spacing; + } + } + + // Write back to file + FancyZonesData.WriteAppliedLayouts(appliedLayouts); + Logger.LogInfo($"Applied layouts file updated for {monitorsToUpdate.Count} monitor(s)"); + + // Notify FancyZones to reload + notifyFancyZones(wmPrivAppliedLayoutsFileUpdate); + Logger.LogInfo("FancyZones notified of layout change"); + + string layoutName = targetCustomLayout?.Name ?? targetTemplate?.Type ?? uuid; + if (applyToAll) + { + return (0, $"Layout '{layoutName}' applied to all {monitorsToUpdate.Count} monitors"); + } + else if (targetMonitor.HasValue) + { + return (0, $"Layout '{layoutName}' applied to monitor {targetMonitor.Value}"); + } + else + { + return (0, $"Layout '{layoutName}' applied to monitor 1"); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs new file mode 100644 index 0000000000..f542b901cc --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs @@ -0,0 +1,49 @@ +// 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.Globalization; + +namespace FancyZonesCLI.Commands; + +/// +/// Monitor-related commands. +/// +internal static class MonitorCommands +{ + public static (int ExitCode, string Output) GetMonitors() + { + if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) + { + return (1, $"Error: {error}"); + } + + if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) + { + return (0, "No monitors found."); + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Monitors ({appliedLayouts.Layouts.Count} total) ==="); + sb.AppendLine(); + + for (int i = 0; i < appliedLayouts.Layouts.Count; i++) + { + var layout = appliedLayouts.Layouts[i]; + var monitorNum = i + 1; + + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {monitorNum}:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor: {layout.Device.Monitor}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Instance: {layout.Device.MonitorInstance}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Number: {layout.Device.MonitorNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Serial Number: {layout.Device.SerialNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Virtual Desktop: {layout.Device.VirtualDesktop}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.AppliedLayout.SensitivityRadius}px"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Active Layout: {layout.AppliedLayout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Zone Count: {layout.AppliedLayout.ZoneCount}"); + sb.AppendLine(); + } + + return (0, sb.ToString().TrimEnd()); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj new file mode 100644 index 0000000000..85c2fa30e5 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj @@ -0,0 +1,32 @@ + + + + + + + + PowerToys.FancyZonesCLI + PowerToys FancyZones Command Line Interface + PowerToys FancyZones CLI + Exe + x64;ARM64 + true + true + false + false + ..\..\..\..\$(Platform)\$(Configuration) + FancyZonesCLI + $(NoWarn);SA1500;SA1402;CA1852 + + + + + + + + + + + + + diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs b/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs new file mode 100644 index 0000000000..2396c51f44 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs @@ -0,0 +1,142 @@ +// 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.IO; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace FancyZonesCLI; + +/// +/// Provides methods to read and write FancyZones configuration data. +/// +internal static class FancyZonesData +{ + /// + /// Try to read applied layouts configuration. + /// + public static bool TryReadAppliedLayouts(out AppliedLayouts result, out string error) + { + return TryReadJsonFile(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts, out result, out error); + } + + /// + /// Read applied layouts or return null if not found. + /// + public static AppliedLayouts ReadAppliedLayouts() + { + return ReadJsonFileOrDefault(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts); + } + + /// + /// Write applied layouts configuration. + /// + public static void WriteAppliedLayouts(AppliedLayouts layouts) + { + WriteJsonFile(FancyZonesPaths.AppliedLayouts, layouts, FancyZonesJsonContext.Default.AppliedLayouts); + } + + /// + /// Read custom layouts or return null if not found. + /// + public static CustomLayouts ReadCustomLayouts() + { + return ReadJsonFileOrDefault(FancyZonesPaths.CustomLayouts, FancyZonesJsonContext.Default.CustomLayouts); + } + + /// + /// Read layout templates or return null if not found. + /// + public static LayoutTemplates ReadLayoutTemplates() + { + return ReadJsonFileOrDefault(FancyZonesPaths.LayoutTemplates, FancyZonesJsonContext.Default.LayoutTemplates); + } + + /// + /// Read layout hotkeys or return null if not found. + /// + public static LayoutHotkeys ReadLayoutHotkeys() + { + return ReadJsonFileOrDefault(FancyZonesPaths.LayoutHotkeys, FancyZonesJsonContext.Default.LayoutHotkeys); + } + + /// + /// Write layout hotkeys configuration. + /// + public static void WriteLayoutHotkeys(LayoutHotkeys hotkeys) + { + WriteJsonFile(FancyZonesPaths.LayoutHotkeys, hotkeys, FancyZonesJsonContext.Default.LayoutHotkeys); + } + + /// + /// Check if editor parameters file exists. + /// + public static bool EditorParametersExist() + { + return File.Exists(FancyZonesPaths.EditorParameters); + } + + private static bool TryReadJsonFile(string filePath, JsonTypeInfo jsonTypeInfo, out T result, out string error) + where T : class + { + result = null; + error = null; + + Logger.LogDebug($"Reading file: {filePath}"); + + if (!File.Exists(filePath)) + { + error = $"File not found: {Path.GetFileName(filePath)}"; + Logger.LogWarning(error); + return false; + } + + try + { + var json = File.ReadAllText(filePath); + result = JsonSerializer.Deserialize(json, jsonTypeInfo); + if (result == null) + { + error = $"Failed to parse {Path.GetFileName(filePath)}"; + Logger.LogError(error); + return false; + } + + Logger.LogDebug($"Successfully read {Path.GetFileName(filePath)}"); + return true; + } + catch (JsonException ex) + { + error = $"JSON parse error in {Path.GetFileName(filePath)}: {ex.Message}"; + Logger.LogError(error, ex); + return false; + } + catch (IOException ex) + { + error = $"Failed to read {Path.GetFileName(filePath)}: {ex.Message}"; + Logger.LogError(error, ex); + return false; + } + } + + private static T ReadJsonFileOrDefault(string filePath, JsonTypeInfo jsonTypeInfo, T defaultValue = null) + where T : class + { + if (TryReadJsonFile(filePath, jsonTypeInfo, out var result, out _)) + { + return result; + } + + return defaultValue; + } + + private static void WriteJsonFile(string filePath, T data, JsonTypeInfo jsonTypeInfo) + { + Logger.LogDebug($"Writing file: {filePath}"); + var json = JsonSerializer.Serialize(data, jsonTypeInfo); + File.WriteAllText(filePath, json); + Logger.LogInfo($"Successfully wrote {Path.GetFileName(filePath)}"); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs b/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs new file mode 100644 index 0000000000..f04d375392 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs @@ -0,0 +1,30 @@ +// 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.IO; + +namespace FancyZonesCLI; + +/// +/// Provides paths to FancyZones configuration files. +/// +internal static class FancyZonesPaths +{ + private static readonly string DataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys", + "FancyZones"); + + public static string AppliedLayouts => Path.Combine(DataPath, "applied-layouts.json"); + + public static string CustomLayouts => Path.Combine(DataPath, "custom-layouts.json"); + + public static string LayoutTemplates => Path.Combine(DataPath, "layout-templates.json"); + + public static string LayoutHotkeys => Path.Combine(DataPath, "layout-hotkeys.json"); + + public static string EditorParameters => Path.Combine(DataPath, "editor-parameters.json"); +} diff --git a/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs new file mode 100644 index 0000000000..fecdf33dbe --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs @@ -0,0 +1,550 @@ +// 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.Globalization; +using System.Text; +using System.Text.Json; + +namespace FancyZonesCLI; + +public static class LayoutVisualizer +{ + public static string DrawTemplateLayout(TemplateLayout template) + { + var sb = new StringBuilder(); + sb.AppendLine(" Visual Preview:"); + + switch (template.Type.ToLowerInvariant()) + { + case "focus": + sb.Append(RenderFocusLayout(template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "columns": + sb.Append(RenderGridLayout(1, template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "rows": + sb.Append(RenderGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3, 1)); + break; + case "grid": + // Grid layout: calculate rows and columns from zone count + // Algorithm from GridLayoutModel.InitGrid() - tries to make it close to square + // with cols >= rows preference + int zoneCount = template.ZoneCount > 0 ? template.ZoneCount : 3; + int rows = 1; + while (zoneCount / rows >= rows) + { + rows++; + } + + rows--; + int cols = zoneCount / rows; + if (zoneCount % rows != 0) + { + cols++; + } + + sb.Append(RenderGridLayoutWithZoneCount(rows, cols, zoneCount)); + break; + case "priority-grid": + sb.Append(RenderPriorityGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "blank": + sb.AppendLine(" (No zones)"); + break; + default: + sb.AppendLine(CultureInfo.InvariantCulture, $" ({template.Type} layout)"); + break; + } + + return sb.ToString(); + } + + public static string DrawCustomLayout(CustomLayout layout) + { + if (layout.Info.ValueKind == JsonValueKind.Undefined || layout.Info.ValueKind == JsonValueKind.Null) + { + return string.Empty; + } + + var sb = new StringBuilder(); + sb.AppendLine(" Visual Preview:"); + + if (layout.Type == "grid" && + layout.Info.TryGetProperty("rows", out var rows) && + layout.Info.TryGetProperty("columns", out var cols)) + { + int r = rows.GetInt32(); + int c = cols.GetInt32(); + + // Check if there's a cell-child-map (merged cells) + if (layout.Info.TryGetProperty("cell-child-map", out var cellMap)) + { + sb.Append(RenderGridLayoutWithMergedCells(r, c, cellMap)); + } + else + { + int height = r >= 4 ? 12 : 8; + sb.Append(RenderGridLayout(r, c, 30, height)); + } + } + else if (layout.Type == "canvas" && + layout.Info.TryGetProperty("zones", out var zones) && + layout.Info.TryGetProperty("ref-width", out var refWidth) && + layout.Info.TryGetProperty("ref-height", out var refHeight)) + { + sb.Append(RenderCanvasLayout(zones, refWidth.GetInt32(), refHeight.GetInt32())); + } + + return sb.ToString(); + } + + private static string RenderFocusLayout(int zoneCount = 3) + { + var sb = new StringBuilder(); + + // Focus layout: overlapping zones with cascading offset + if (zoneCount == 1) + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + else if (zoneCount == 2) + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + else + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" ..."); + sb.AppendLine(CultureInfo.InvariantCulture, $" (total: {zoneCount} zones)"); + sb.AppendLine(" ..."); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + + return sb.ToString(); + } + + private static string RenderPriorityGridLayout(int zoneCount = 3) + { + // Priority Grid has predefined layouts for zone counts 1-11 + // Data format from GridLayoutModel._priorityData + if (zoneCount >= 1 && zoneCount <= 11) + { + int[,] cellMap = GetPriorityGridCellMap(zoneCount); + return RenderGridLayoutWithCellMap(cellMap); + } + else + { + // > 11 zones: use grid layout + int rows = 1; + while (zoneCount / rows >= rows) + { + rows++; + } + + rows--; + int cols = zoneCount / rows; + if (zoneCount % rows != 0) + { + cols++; + } + + return RenderGridLayoutWithZoneCount(rows, cols, zoneCount); + } + } + + private static int[,] GetPriorityGridCellMap(int zoneCount) + { + // Parsed from Editor's _priorityData byte arrays + return zoneCount switch + { + 1 => new int[,] { { 0 } }, + 2 => new int[,] { { 0, 1 } }, + 3 => new int[,] { { 0, 1, 2 } }, + 4 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 } }, + 5 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 } }, + 6 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 }, { 4, 1, 5 } }, + 7 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 }, { 5, 1, 6 } }, + 8 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 2, 7 } }, + 9 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 7, 8 } }, + 10 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 1, 8, 9 } }, + 11 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 8, 9, 10 } }, + _ => new int[,] { { 0 } }, + }; + } + + private static string RenderGridLayoutWithCellMap(int[,] cellMap, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + int rows = cellMap.GetLength(0); + int cols = cellMap.GetLength(1); + + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + bool mergeTop = r > 0 && cellMap[r, c] == cellMap[r - 1, c]; + bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1]; + + if (mergeTop) + { + sb.Append(mergeLeft ? new string(' ', cellWidth) : new string(' ', cellWidth - 1) + "+"); + } + else + { + sb.Append(mergeLeft ? new string('-', cellWidth) : new string('-', cellWidth - 1) + "+"); + } + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1]; + sb.Append(mergeLeft ? ' ' : '|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static string RenderGridLayoutWithMergedCells(int rows, int cols, JsonElement cellMap) + { + var sb = new StringBuilder(); + const int displayWidth = 39; + const int displayHeight = 12; + + // Build zone map from cell-child-map + int[,] zoneMap = new int[rows, cols]; + for (int r = 0; r < rows; r++) + { + var rowArray = cellMap[r]; + for (int c = 0; c < cols; c++) + { + zoneMap[r, c] = rowArray[c].GetInt32(); + } + } + + int cellHeight = displayHeight / rows; + int cellWidth = displayWidth / cols; + + // Draw top border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw rows + for (int r = 0; r < rows; r++) + { + for (int h = 0; h < cellHeight; h++) + { + sb.Append(" |"); + + for (int c = 0; c < cols; c++) + { + int currentZone = zoneMap[r, c]; + int leftZone = c > 0 ? zoneMap[r, c - 1] : -1; + bool needLeftBorder = c > 0 && currentZone != leftZone; + + bool zoneHasTopBorder = r > 0 && h == 0 && currentZone != zoneMap[r - 1, c]; + + if (needLeftBorder) + { + sb.Append('|'); + sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth - 1); + } + else + { + sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth); + } + } + + sb.AppendLine("|"); + } + } + + // Draw bottom border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + return sb.ToString(); + } + + public static string RenderGridLayoutWithZoneCount(int rows, int cols, int zoneCount, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + + // Build zone map like Editor's InitGrid + int[,] zoneMap = new int[rows, cols]; + int index = 0; + for (int r = 0; r < rows; r++) + { + for (int c = 0; c < cols; c++) + { + zoneMap[r, c] = index++; + if (index == zoneCount) + { + index--; // Remaining cells use the last zone index + } + } + } + + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1]; + sb.Append('-', mergeLeft ? cellWidth : cellWidth - 1); + if (!mergeLeft) + { + sb.Append('+'); + } + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1]; + sb.Append(mergeLeft ? ' ' : '|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + public static string RenderGridLayout(int rows, int cols, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + sb.Append('|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static string RenderCanvasLayout(JsonElement zones, int refWidth, int refHeight) + { + var sb = new StringBuilder(); + const int displayWidth = 49; + const int displayHeight = 15; + + // Create a 2D array to track which zones occupy each position + var zoneGrid = new List[displayHeight, displayWidth]; + for (int i = 0; i < displayHeight; i++) + { + for (int j = 0; j < displayWidth; j++) + { + zoneGrid[i, j] = new List(); + } + } + + // Map each zone to the grid + int zoneId = 0; + var zoneList = new List<(int X, int Y, int Width, int Height, int Id)>(); + + foreach (var zone in zones.EnumerateArray()) + { + int x = zone.GetProperty("X").GetInt32(); + int y = zone.GetProperty("Y").GetInt32(); + int w = zone.GetProperty("width").GetInt32(); + int h = zone.GetProperty("height").GetInt32(); + + int dx = Math.Max(0, Math.Min(displayWidth - 1, x * displayWidth / refWidth)); + int dy = Math.Max(0, Math.Min(displayHeight - 1, y * displayHeight / refHeight)); + int dw = Math.Max(3, w * displayWidth / refWidth); + int dh = Math.Max(2, h * displayHeight / refHeight); + + if (dx + dw > displayWidth) + { + dw = displayWidth - dx; + } + + if (dy + dh > displayHeight) + { + dh = displayHeight - dy; + } + + zoneList.Add((dx, dy, dw, dh, zoneId)); + + for (int r = dy; r < dy + dh && r < displayHeight; r++) + { + for (int c = dx; c < dx + dw && c < displayWidth; c++) + { + zoneGrid[r, c].Add(zoneId); + } + } + + zoneId++; + } + + // Draw top border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw each row + char[] shades = { '.', ':', '░', '▒', '▓', '█', '◆', '●', '■', '▪' }; + + for (int r = 0; r < displayHeight; r++) + { + sb.Append(" |"); + for (int c = 0; c < displayWidth; c++) + { + var zonesHere = zoneGrid[r, c]; + + if (zonesHere.Count == 0) + { + sb.Append(' '); + } + else + { + int topZone = zonesHere[zonesHere.Count - 1]; + var rect = zoneList[topZone]; + + bool isTopEdge = r == rect.Y; + bool isBottomEdge = r == rect.Y + rect.Height - 1; + bool isLeftEdge = c == rect.X; + bool isRightEdge = c == rect.X + rect.Width - 1; + + if ((isTopEdge || isBottomEdge) && (isLeftEdge || isRightEdge)) + { + sb.Append('+'); + } + else if (isTopEdge || isBottomEdge) + { + sb.Append('-'); + } + else if (isLeftEdge || isRightEdge) + { + sb.Append('|'); + } + else + { + sb.Append(shades[topZone % shades.Length]); + } + } + } + + sb.AppendLine("|"); + } + + // Draw bottom border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw legend + sb.AppendLine(); + sb.Append(" Legend: "); + for (int i = 0; i < Math.Min(zoneId, shades.Length); i++) + { + if (i > 0) + { + sb.Append(", "); + } + + sb.Append(CultureInfo.InvariantCulture, $"Zone {i} = {shades[i]}"); + } + + sb.AppendLine(); + return sb.ToString(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Logger.cs b/src/modules/fancyzones/FancyZonesCLI/Logger.cs new file mode 100644 index 0000000000..3f62abf7eb --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Logger.cs @@ -0,0 +1,126 @@ +// 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.Globalization; +using System.IO; +using System.Runtime.CompilerServices; + +namespace FancyZonesCLI; + +/// +/// Simple logger for FancyZones CLI. +/// Logs to %LOCALAPPDATA%\Microsoft\PowerToys\FancyZones\CLI\Logs +/// +internal static class Logger +{ + private static readonly object LockObj = new(); + private static string _logFilePath = string.Empty; + private static bool _isInitialized; + + /// + /// Gets the path to the current log file. + /// + public static string LogFilePath => _logFilePath; + + /// + /// Initializes the logger. + /// + public static void InitializeLogger() + { + if (_isInitialized) + { + return; + } + + try + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var logDirectory = Path.Combine(localAppData, "Microsoft", "PowerToys", "FancyZones", "CLI", "Logs"); + + if (!Directory.Exists(logDirectory)) + { + Directory.CreateDirectory(logDirectory); + } + + var logFileName = $"FancyZonesCLI_{DateTime.Now:yyyy-MM-dd}.log"; + _logFilePath = Path.Combine(logDirectory, logFileName); + _isInitialized = true; + + LogInfo("FancyZones CLI started"); + } + catch + { + // Silently fail if logging cannot be initialized + } + } + + /// + /// Logs an error message. + /// + public static void LogError(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("ERROR", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs an error message with exception details. + /// + public static void LogError(string message, Exception ex, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + var fullMessage = ex == null + ? message + : $"{message} | Exception: {ex.GetType().Name}: {ex.Message}"; + Log("ERROR", fullMessage, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs a warning message. + /// + public static void LogWarning(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("WARN", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs an informational message. + /// + public static void LogInfo(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("INFO", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs a debug message (only in DEBUG builds). + /// + [System.Diagnostics.Conditional("DEBUG")] + public static void LogDebug(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("DEBUG", message, memberName, sourceFilePath, sourceLineNumber); + } + + private static void Log(string level, string message, string memberName, string sourceFilePath, int sourceLineNumber) + { + if (!_isInitialized || string.IsNullOrEmpty(_logFilePath)) + { + return; + } + + try + { + var fileName = Path.GetFileName(sourceFilePath); + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + var logEntry = $"[{timestamp}] [{level}] [{fileName}:{sourceLineNumber}] [{memberName}] {message}{Environment.NewLine}"; + + lock (LockObj) + { + File.AppendAllText(_logFilePath, logEntry); + } + } + catch + { + // Silently fail if logging fails + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Models.cs b/src/modules/fancyzones/FancyZonesCLI/Models.cs new file mode 100644 index 0000000000..0c8bbefe54 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Models.cs @@ -0,0 +1,137 @@ +// 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.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FancyZonesCLI; + +// JSON Source Generator for AOT compatibility +[JsonSerializable(typeof(LayoutTemplates))] +[JsonSerializable(typeof(CustomLayouts))] +[JsonSerializable(typeof(AppliedLayouts))] +[JsonSerializable(typeof(LayoutHotkeys))] +[JsonSourceGenerationOptions(WriteIndented = true)] +internal partial class FancyZonesJsonContext : JsonSerializerContext +{ +} + +// Layout Templates +public sealed class LayoutTemplates +{ + [JsonPropertyName("layout-templates")] + public List Templates { get; set; } +} + +public sealed class TemplateLayout +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("zone-count")] + public int ZoneCount { get; set; } + + [JsonPropertyName("show-spacing")] + public bool ShowSpacing { get; set; } + + [JsonPropertyName("spacing")] + public int Spacing { get; set; } + + [JsonPropertyName("sensitivity-radius")] + public int SensitivityRadius { get; set; } +} + +// Custom Layouts +public sealed class CustomLayouts +{ + [JsonPropertyName("custom-layouts")] + public List Layouts { get; set; } +} + +public sealed class CustomLayout +{ + [JsonPropertyName("uuid")] + public string Uuid { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("info")] + public JsonElement Info { get; set; } +} + +// Applied Layouts +public sealed class AppliedLayouts +{ + [JsonPropertyName("applied-layouts")] + public List Layouts { get; set; } +} + +public sealed class AppliedLayoutWrapper +{ + [JsonPropertyName("device")] + public DeviceInfo Device { get; set; } = new(); + + [JsonPropertyName("applied-layout")] + public AppliedLayoutInfo AppliedLayout { get; set; } = new(); +} + +public sealed class DeviceInfo +{ + [JsonPropertyName("monitor")] + public string Monitor { get; set; } = string.Empty; + + [JsonPropertyName("monitor-instance")] + public string MonitorInstance { get; set; } = string.Empty; + + [JsonPropertyName("monitor-number")] + public int MonitorNumber { get; set; } + + [JsonPropertyName("serial-number")] + public string SerialNumber { get; set; } = string.Empty; + + [JsonPropertyName("virtual-desktop")] + public string VirtualDesktop { get; set; } = string.Empty; +} + +public sealed class AppliedLayoutInfo +{ + [JsonPropertyName("uuid")] + public string Uuid { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("show-spacing")] + public bool ShowSpacing { get; set; } + + [JsonPropertyName("spacing")] + public int Spacing { get; set; } + + [JsonPropertyName("zone-count")] + public int ZoneCount { get; set; } + + [JsonPropertyName("sensitivity-radius")] + public int SensitivityRadius { get; set; } +} + +// Layout Hotkeys +public sealed class LayoutHotkeys +{ + [JsonPropertyName("layout-hotkeys")] + public List Hotkeys { get; set; } +} + +public sealed class LayoutHotkey +{ + [JsonPropertyName("key")] + public int Key { get; set; } + + [JsonPropertyName("layout-id")] + public string LayoutId { get; set; } = string.Empty; +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs new file mode 100644 index 0000000000..efab0859bd --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs @@ -0,0 +1,56 @@ +// 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 Windows.Win32; +using Windows.Win32.Foundation; + +namespace FancyZonesCLI; + +/// +/// Native Windows API methods for FancyZones CLI. +/// +internal static class NativeMethods +{ + // Registered Windows messages for notifying FancyZones + private static uint wmPrivAppliedLayoutsFileUpdate; + private static uint wmPrivLayoutHotkeysFileUpdate; + + /// + /// Gets the Windows message ID for applied layouts file update notification. + /// + public static uint WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE => wmPrivAppliedLayoutsFileUpdate; + + /// + /// Gets the Windows message ID for layout hotkeys file update notification. + /// + public static uint WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE => wmPrivLayoutHotkeysFileUpdate; + + /// + /// Initializes the Windows messages used for FancyZones notifications. + /// + public static void InitializeWindowMessages() + { + wmPrivAppliedLayoutsFileUpdate = PInvoke.RegisterWindowMessage("{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}"); + wmPrivLayoutHotkeysFileUpdate = PInvoke.RegisterWindowMessage("{07229b7e-4f22-4357-b136-33c289be2295}"); + } + + /// + /// Broadcasts a notification message to FancyZones. + /// + /// The Windows message ID to broadcast. + public static void NotifyFancyZones(uint message) + { + PInvoke.PostMessage(HWND.HWND_BROADCAST, message, 0, 0); + } + + /// + /// Brings the specified window to the foreground. + /// + /// A handle to the window. + /// True if the window was brought to the foreground. + public static bool SetForegroundWindow(nint hWnd) + { + return PInvoke.SetForegroundWindow(new HWND(hWnd)); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json new file mode 100644 index 0000000000..89cee38a92 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "emitSingleFile": true, + "allowMarshaling": false +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt new file mode 100644 index 0000000000..e3555c2333 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt @@ -0,0 +1,4 @@ +PostMessage +SetForegroundWindow +RegisterWindowMessage +HWND_BROADCAST diff --git a/src/modules/fancyzones/FancyZonesCLI/Program.cs b/src/modules/fancyzones/FancyZonesCLI/Program.cs new file mode 100644 index 0000000000..1b133dfa36 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Program.cs @@ -0,0 +1,115 @@ +// 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.Globalization; +using System.Linq; +using FancyZonesCLI.Commands; + +namespace FancyZonesCLI; + +internal sealed class Program +{ + private static int Main(string[] args) + { + // Initialize logger + Logger.InitializeLogger(); + Logger.LogInfo($"CLI invoked with args: [{string.Join(", ", args)}]"); + + // Initialize Windows messages + NativeMethods.InitializeWindowMessages(); + + (int ExitCode, string Output) result; + + if (args.Length == 0) + { + result = (1, GetUsageText()); + } + else + { + var command = args[0].ToLowerInvariant(); + + result = command switch + { + "open-editor" or "editor" or "e" => EditorCommands.OpenEditor(), + "get-monitors" or "monitors" or "m" => MonitorCommands.GetMonitors(), + "get-layouts" or "layouts" or "ls" => LayoutCommands.GetLayouts(), + "get-active-layout" or "active" or "get-active" or "a" => LayoutCommands.GetActiveLayout(), + "set-layout" or "set" or "s" => args.Length >= 2 + ? LayoutCommands.SetLayout(args.Skip(1).ToArray(), NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE) + : (1, "Error: set-layout requires a UUID parameter"), + "open-settings" or "settings" => EditorCommands.OpenSettings(), + "get-hotkeys" or "hotkeys" or "hk" => HotkeyCommands.GetHotkeys(), + "set-hotkey" or "shk" => args.Length >= 3 + ? HotkeyCommands.SetHotkey(int.Parse(args[1], CultureInfo.InvariantCulture), args[2], NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE) + : (1, "Error: set-hotkey requires "), + "remove-hotkey" or "rhk" => args.Length >= 2 + ? HotkeyCommands.RemoveHotkey(int.Parse(args[1], CultureInfo.InvariantCulture), NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE) + : (1, "Error: remove-hotkey requires "), + "help" or "--help" or "-h" => (0, GetUsageText()), + _ => (1, $"Error: Unknown command: {command}\n\n{GetUsageText()}"), + }; + } + + // Log result + if (result.ExitCode == 0) + { + Logger.LogInfo($"Command completed successfully"); + } + else + { + Logger.LogWarning($"Command failed with exit code {result.ExitCode}: {result.Output}"); + } + + // Output result + if (!string.IsNullOrEmpty(result.Output)) + { + Console.WriteLine(result.Output); + } + + return result.ExitCode; + } + + private static string GetUsageText() + { + return """ + FancyZones CLI - Command line interface for FancyZones + ====================================================== + + Usage: FancyZonesCLI.exe [options] + + Commands: + open-editor (editor, e) Launch FancyZones layout editor + get-monitors (monitors, m) List all monitors and their properties + get-layouts (layouts, ls) List all available layouts + get-active-layout (get-active, active, a) + Show currently active layout + set-layout (set, s) [options] + Set layout by UUID + --monitor Apply to monitor N (1-based) + --all Apply to all monitors + open-settings (settings) Open FancyZones settings page + get-hotkeys (hotkeys, hk) List all layout hotkeys + set-hotkey (shk) Assign hotkey (0-9) to CUSTOM layout + Note: Only custom layouts work with hotkeys + remove-hotkey (rhk) Remove hotkey assignment + help Show this help message + + + Examples: + FancyZonesCLI.exe e # Open editor (short) + FancyZonesCLI.exe m # List monitors (short) + FancyZonesCLI.exe ls # List layouts (short) + FancyZonesCLI.exe a # Get active layout (short) + FancyZonesCLI.exe s focus --all # Set layout (short) + FancyZonesCLI.exe open-editor # Open editor (long) + FancyZonesCLI.exe get-monitors + FancyZonesCLI.exe get-layouts + FancyZonesCLI.exe set-layout {12345678-1234-1234-1234-123456789012} + FancyZonesCLI.exe set-layout focus --monitor 2 + FancyZonesCLI.exe set-layout columns --all + FancyZonesCLI.exe set-hotkey 3 {12345678-1234-1234-1234-123456789012} + """; + } +}