From d192672c7454f1cc8b87c61dd9e780aeba4ec5e5 Mon Sep 17 00:00:00 2001 From: moooyo <42196638+moooyo@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:43:21 +0800 Subject: [PATCH 01/53] fix: Improve Unicode normalization and add regex metachar tests (#44944) Enhanced SanitizeAndNormalize to handle Unicode normalization more robustly, ensuring correct buffer sizing and error handling. Added unit tests for regex metacharacters `$` and `^` to verify correct replacement behavior at string boundaries. Improves Unicode support and test coverage for regex edge cases. ## Summary of the Pull Request ## PR Checklist - [x] Closes: #44942 #44892 - [ ] **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 --------- Co-authored-by: Yu Leng --- .github/actions/spell-check/expect.txt | 1 + .../powerrename/lib/PowerRenameRegEx.cpp | 18 +++++++---- .../powerrename/unittests/CommonRegExTests.h | 32 +++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 79bf8cfcea..e8a15d52d2 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1011,6 +1011,7 @@ MENUITEMINFO MENUITEMINFOW MERGECOPY MERGEPAINT +Metacharacter metadatamatters Metadatas metafile diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.cpp b/src/modules/powerrename/lib/PowerRenameRegEx.cpp index e9ce4fa62a..266fe5af9d 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.cpp +++ b/src/modules/powerrename/lib/PowerRenameRegEx.cpp @@ -33,23 +33,27 @@ static std::wstring SanitizeAndNormalize(const std::wstring& input) // Normalize to NFC (Precomposed). // Get the size needed for the normalized string, including null terminator. - int size = NormalizeString(NormalizationC, sanitized.c_str(), -1, nullptr, 0); - if (size <= 0) + int sizeEstimate = NormalizeString(NormalizationC, sanitized.c_str(), -1, nullptr, 0); + if (sizeEstimate <= 0) { return sanitized; // Return unaltered if normalization fails. } // Perform the normalization. std::wstring normalized; - normalized.resize(size); - NormalizeString(NormalizationC, sanitized.c_str(), -1, &normalized[0], size); + normalized.resize(sizeEstimate); + int actualSize = NormalizeString(NormalizationC, sanitized.c_str(), -1, &normalized[0], sizeEstimate); - // Remove the explicit null terminator added by NormalizeString. - if (!normalized.empty() && normalized.back() == L'\0') + if (actualSize <= 0) { - normalized.pop_back(); + // Normalization failed, return sanitized string. + return sanitized; } + // Resize to actual size minus the null terminator. + // actualSize includes the null terminator when input length is -1. + normalized.resize(static_cast(actualSize) - 1); + return normalized; } diff --git a/src/modules/powerrename/unittests/CommonRegExTests.h b/src/modules/powerrename/unittests/CommonRegExTests.h index 4dc078e9b1..392252655d 100644 --- a/src/modules/powerrename/unittests/CommonRegExTests.h +++ b/src/modules/powerrename/unittests/CommonRegExTests.h @@ -695,6 +695,38 @@ TEST_METHOD(VerifyUnicodeAndWhitespaceNormalizationRegex) VerifyNormalizationHelper(UseRegularExpressions); } +TEST_METHOD(VerifyRegexMetacharacterDollarSign) +{ + CComPtr renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + PWSTR result = nullptr; + Assert::IsTrue(renameRegEx->PutSearchTerm(L"$") == S_OK); + Assert::IsTrue(renameRegEx->PutReplaceTerm(L"_end") == S_OK); + unsigned long index = {}; + Assert::IsTrue(renameRegEx->Replace(L"test.txt", &result, index) == S_OK); + Assert::AreEqual(L"test.txt_end", result); + CoTaskMemFree(result); +} + +TEST_METHOD(VerifyRegexMetacharacterCaret) +{ + CComPtr renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + PWSTR result = nullptr; + Assert::IsTrue(renameRegEx->PutSearchTerm(L"^") == S_OK); + Assert::IsTrue(renameRegEx->PutReplaceTerm(L"start_") == S_OK); + unsigned long index = {}; + Assert::IsTrue(renameRegEx->Replace(L"test.txt", &result, index) == S_OK); + Assert::AreEqual(L"start_test.txt", result); + CoTaskMemFree(result); +} + #ifndef TESTS_PARTIAL }; } From 086c63b6af99f5c700a18625147a789e4cd176ef Mon Sep 17 00:00:00 2001 From: Shawn Yuan <128874481+shuaiyuanxx@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:47:35 +0800 Subject: [PATCH 02/53] [Settings] Fix right click menu display issue (#44982) ## Summary of the Pull Request This pull request updates the tray icon context menu logic to better reflect the state of the "Quick Access" feature. The menu now dynamically updates its items and labels based on whether Quick Access is enabled or disabled, improving clarity for users. **Menu behavior improvements:** * The tray icon menu now reloads itself when the Quick Access setting changes, ensuring the menu always matches the current state. * The "Settings" menu item label changes to "Settings\tLeft-click" when Quick Access is disabled, providing clearer instructions to users. [[1]](diffhunk://#diff-e5efbda4c356e159a6ca82a425db84438ab4014d1d90377b98a2eb6d9632d32dR176-R179) [[2]](diffhunk://#diff-7139ecb2cf76e472c574a155268c19e919e2cce05d9d345c50c1f1bffc939e1aR198-R248) * The Quick Access menu item is removed from the context menu when the feature is disabled, preventing confusion. **Internal state tracking:** * Added a new variable `last_quick_access_state` to track the previous Quick Access state and trigger menu reloads only when necessary. ## PR Checklist - [x] Closes: #44810 - [x] **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 - [x] **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 - When Quick Access is disabled image - When Quick Access is enabled image ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 1 + src/runner/Resources.resx | 4 +++ src/runner/tray_icon.cpp | 37 +++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index e8a15d52d2..b622870905 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -886,6 +886,7 @@ Ldr LEFTALIGN LEFTSCROLLBAR LEFTTEXT +leftclick LError LEVELID LExit diff --git a/src/runner/Resources.resx b/src/runner/Resources.resx index c8eb5f25cc..b94f84714e 100644 --- a/src/runner/Resources.resx +++ b/src/runner/Resources.resx @@ -173,6 +173,10 @@ Settings\tDouble-click Don't localize "\t" as that is what separates the click portion to be right aligned in the menu. + + Settings\tLeft-click + Don't localize "\t" as that is what separates the click portion to be right aligned in the menu. This is shown when Quick Access is disabled. + Documentation diff --git a/src/runner/tray_icon.cpp b/src/runner/tray_icon.cpp index 8fa892e312..d8684da5d0 100644 --- a/src/runner/tray_icon.cpp +++ b/src/runner/tray_icon.cpp @@ -40,6 +40,7 @@ namespace bool double_click_timer_running = false; bool double_clicked = false; POINT tray_icon_click_point; + std::optional last_quick_access_state; // Track the last known Quick Access state static ThemeListener theme_listener; static bool theme_adaptive_enabled = false; @@ -195,6 +196,18 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam case WM_RBUTTONUP: case WM_CONTEXTMENU: { + bool quick_access_enabled = get_general_settings().enableQuickAccess; + + // Reload menu if Quick Access state has changed or is first time + if (h_menu && (!last_quick_access_state.has_value() || quick_access_enabled != last_quick_access_state.value())) + { + DestroyMenu(h_menu); + h_menu = nullptr; + h_sub_menu = nullptr; + } + + last_quick_access_state = quick_access_enabled; + if (!h_menu) { h_menu = LoadMenu(reinterpret_cast(&__ImageBase), MAKEINTRESOURCE(ID_TRAY_MENU)); @@ -202,17 +215,39 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam if (h_menu) { static std::wstring settings_menuitem_label = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT); + static std::wstring settings_menuitem_label_leftclick = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT_LEFTCLICK); static std::wstring close_menuitem_label = GET_RESOURCE_STRING(IDS_CLOSE_MENU_TEXT); static std::wstring submit_bug_menuitem_label = GET_RESOURCE_STRING(IDS_SUBMIT_BUG_TEXT); static std::wstring documentation_menuitem_label = GET_RESOURCE_STRING(IDS_DOCUMENTATION_MENU_TEXT); static std::wstring quick_access_menuitem_label = GET_RESOURCE_STRING(IDS_QUICK_ACCESS_MENU_TEXT); - change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data()); + + // Update Settings menu text based on Quick Access state + if (quick_access_enabled) + { + change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data()); + } + else + { + change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label_leftclick.data()); + } + change_menu_item_text(ID_CLOSE_MENU_COMMAND, close_menuitem_label.data()); change_menu_item_text(ID_REPORT_BUG_COMMAND, submit_bug_menuitem_label.data()); bool bug_report_disabled = is_bug_report_running(); EnableMenuItem(h_sub_menu, ID_REPORT_BUG_COMMAND, MF_BYCOMMAND | (bug_report_disabled ? MF_GRAYED : MF_ENABLED)); change_menu_item_text(ID_DOCUMENTATION_MENU_COMMAND, documentation_menuitem_label.data()); change_menu_item_text(ID_QUICK_ACCESS_MENU_COMMAND, quick_access_menuitem_label.data()); + + // Hide or show Quick Access menu item based on setting + if (!h_sub_menu) + { + h_sub_menu = GetSubMenu(h_menu, 0); + } + if (!quick_access_enabled) + { + // Remove Quick Access menu item when disabled + DeleteMenu(h_sub_menu, ID_QUICK_ACCESS_MENU_COMMAND, MF_BYCOMMAND); + } } if (!h_sub_menu) { From 4ba6fd27237db8a8a51c6de077c8791d9d715b6e Mon Sep 17 00:00:00 2001 From: Shawn Yuan <128874481+shuaiyuanxx@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:44:10 +0800 Subject: [PATCH 03/53] Add telemetry for tray icon (#44985) ## Summary of the Pull Request This pull request adds telemetry tracking for user interactions with the application's tray icon. Specifically, it introduces new methods for logging `left-click`, `right-click`, and `double-click` events, and integrates these telemetry calls into the tray icon event handling logic. ## PR Checklist - [ ] Closes: #xxx - [x] **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 ## Validation Steps Performed --- src/runner/trace.cpp | 33 +++++++++++++++++++++++++++++++++ src/runner/trace.h | 5 +++++ src/runner/tray_icon.cpp | 10 ++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/runner/trace.cpp b/src/runner/trace.cpp index b4682be8ce..6fb2f89ba8 100644 --- a/src/runner/trace.cpp +++ b/src/runner/trace.cpp @@ -81,3 +81,36 @@ void Trace::UpdateDownloadCompleted(bool success, const std::wstring& version) TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); } + +void Trace::TrayIconLeftClick(bool quickAccessEnabled) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "TrayIcon_LeftClick", + TraceLoggingBoolean(quickAccessEnabled, "QuickAccessEnabled"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} + +void Trace::TrayIconDoubleClick(bool quickAccessEnabled) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "TrayIcon_DoubleClick", + TraceLoggingBoolean(quickAccessEnabled, "QuickAccessEnabled"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} + +void Trace::TrayIconRightClick(bool quickAccessEnabled) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "TrayIcon_RightClick", + TraceLoggingBoolean(quickAccessEnabled, "QuickAccessEnabled"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/runner/trace.h b/src/runner/trace.h index ca26aef66b..fb22ce3301 100644 --- a/src/runner/trace.h +++ b/src/runner/trace.h @@ -13,4 +13,9 @@ public: // Auto-update telemetry static void UpdateCheckCompleted(bool success, bool updateAvailable, const std::wstring& fromVersion, const std::wstring& toVersion); static void UpdateDownloadCompleted(bool success, const std::wstring& version); + + // Tray icon interaction telemetry + static void TrayIconLeftClick(bool quickAccessEnabled); + static void TrayIconDoubleClick(bool quickAccessEnabled); + static void TrayIconRightClick(bool quickAccessEnabled); }; diff --git a/src/runner/tray_icon.cpp b/src/runner/tray_icon.cpp index d8684da5d0..307129d63b 100644 --- a/src/runner/tray_icon.cpp +++ b/src/runner/tray_icon.cpp @@ -7,6 +7,7 @@ #include "centralized_kb_hook.h" #include "quick_access_host.h" #include "hotkey_conflict_detector.h" +#include "trace.h" #include #include @@ -131,6 +132,9 @@ void click_timer_elapsed() double_click_timer_running = false; if (!double_clicked) { + // Log telemetry for single click (confirmed it's not a double click) + Trace::TrayIconLeftClick(get_general_settings().enableQuickAccess); + if (get_general_settings().enableQuickAccess) { open_quick_access_flyout_window(); @@ -198,6 +202,9 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam { bool quick_access_enabled = get_general_settings().enableQuickAccess; + // Log telemetry + Trace::TrayIconRightClick(quick_access_enabled); + // Reload menu if Quick Access state has changed or is first time if (h_menu && (!last_quick_access_state.has_value() || quick_access_enabled != last_quick_access_state.value())) { @@ -278,6 +285,9 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam } case WM_LBUTTONDBLCLK: { + // Log telemetry + Trace::TrayIconDoubleClick(get_general_settings().enableQuickAccess); + double_clicked = true; open_settings_window(std::nullopt); break; From 0b3dc089ac2af8062429ec6a3ebac9133be4ee0d Mon Sep 17 00:00:00 2001 From: Gordon Lam <73506701+yeelam-gordon@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:20:07 +0800 Subject: [PATCH 04/53] [Peek] Fix Space key triggering during file rename (#44845) (#44995) Don't show error window when CurrentItem is null - just return silently. This restores the original behavior where CaretVisible() detection in GetSelectedItems() would suppress Peek by returning null, and no window would be shown. PR #44703 added an error window for virtual folders (Home/Recent), but this also triggered when user was typing (rename, search, address bar), stealing focus and cancelling the operation. Fixes #44845 ## Summary of the Pull Request --- src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index 6e257cd73b..15c7812148 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -197,16 +197,10 @@ namespace Peek.UI ViewModel.Initialize(selectedItem); - // If no files were found (e.g., in virtual folders like Home/Recent), show an error + // If no files were found (e.g., user is typing in rename/search box, or in virtual folders), + // don't show anything - just return silently to avoid stealing focus if (ViewModel.CurrentItem == null) { - Logger.LogInfo("Peek: No files found to preview, showing error."); - var errorMessage = ResourceLoaderInstance.ResourceLoader.GetString("NoFilesSelected"); - ViewModel.ShowError(errorMessage); - - // Still show the window so user can see the warning - this.Show(); - WindowHelpers.BringToForeground(this.GetWindowHandle()); return; } From ea4397428770bfc365266f8ed5b461e8dac400d7 Mon Sep 17 00:00:00 2001 From: Shawn Yuan <128874481+shuaiyuanxx@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:28:59 +0800 Subject: [PATCH 05/53] [Settings] [Advanced Paste] Upgrade advanced paste settings safely to fix settings ui crash (#44862) ## Summary of the Pull Request This pull request makes a minor fix in the `AdvancedPasteViewModel` constructor to ensure the correct settings repository is used for null checking. The change improves code correctness by verifying `advancedPasteSettingsRepository` instead of the generic `settingsRepository`. - Fixed null check to use `advancedPasteSettingsRepository` instead of `settingsRepository` in the `AdvancedPasteViewModel` constructor for more accurate validation. ## PR Checklist - [x] Closes: #44835 - [x] **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 ## Validation Steps Performed --- .../AdvancedPastePasteAsFileAction.cs | 6 ++--- .../AdvancedPasteProperties.cs | 4 +-- .../AdvancedPasteTranscodeAction.cs | 4 +-- .../ViewModels/AdvancedPasteViewModel.cs | 26 ++++++++++++++----- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs index c4489eaaf7..b645c68cb5 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs @@ -34,21 +34,21 @@ public sealed class AdvancedPastePasteAsFileAction : Observable, IAdvancedPasteA public AdvancedPasteAdditionalAction PasteAsTxtFile { get => _pasteAsTxtFile; - init => Set(ref _pasteAsTxtFile, value); + init => Set(ref _pasteAsTxtFile, value ?? new()); } [JsonPropertyName(PropertyNames.PasteAsPngFile)] public AdvancedPasteAdditionalAction PasteAsPngFile { get => _pasteAsPngFile; - init => Set(ref _pasteAsPngFile, value); + init => Set(ref _pasteAsPngFile, value ?? new()); } [JsonPropertyName(PropertyNames.PasteAsHtmlFile)] public AdvancedPasteAdditionalAction PasteAsHtmlFile { get => _pasteAsHtmlFile; - init => Set(ref _pasteAsHtmlFile, value); + init => Set(ref _pasteAsHtmlFile, value ?? new()); } [JsonIgnore] diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index 9e2fa7ee12..ecfa0ce636 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -93,11 +93,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("custom-actions")] [CmdConfigureIgnoreAttribute] - public AdvancedPasteCustomActions CustomActions { get; init; } + public AdvancedPasteCustomActions CustomActions { get; set; } [JsonPropertyName("additional-actions")] [CmdConfigureIgnoreAttribute] - public AdvancedPasteAdditionalActions AdditionalActions { get; init; } + public AdvancedPasteAdditionalActions AdditionalActions { get; set; } [JsonPropertyName("paste-ai-configuration")] [CmdConfigureIgnoreAttribute] diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs index 82ea4d09f5..e0ed7d7421 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs @@ -32,14 +32,14 @@ public sealed class AdvancedPasteTranscodeAction : Observable, IAdvancedPasteAct public AdvancedPasteAdditionalAction TranscodeToMp3 { get => _transcodeToMp3; - init => Set(ref _transcodeToMp3, value); + init => Set(ref _transcodeToMp3, value ?? new()); } [JsonPropertyName(PropertyNames.TranscodeToMp4)] public AdvancedPasteAdditionalAction TranscodeToMp4 { get => _transcodeToMp4; - init => Set(ref _transcodeToMp4, value); + init => Set(ref _transcodeToMp4, value ?? new()); } [JsonIgnore] diff --git a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs index c98242d36b..deb47719e1 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs @@ -76,16 +76,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels GeneralSettingsConfig = settingsRepository.SettingsConfig; - // To obtain the settings configurations of Fancy zones. - ArgumentNullException.ThrowIfNull(settingsRepository); + // To obtain the settings configurations of Advanced Paste. + ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository); _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); - ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository); + _advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig ?? throw new ArgumentException("SettingsConfig cannot be null", nameof(advancedPasteSettingsRepository)); - _advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig; + if (_advancedPasteSettings.Properties is null) + { + throw new ArgumentException("AdvancedPasteSettings.Properties cannot be null", nameof(advancedPasteSettingsRepository)); + } + + // Ensure AdditionalActions and CustomActions are initialized to prevent null reference exceptions + // This handles legacy settings files that may be missing these properties + _advancedPasteSettings.Properties.AdditionalActions ??= new AdvancedPasteAdditionalActions(); + _advancedPasteSettings.Properties.CustomActions ??= new AdvancedPasteCustomActions(); AttachConfigurationHandlers(); @@ -93,7 +101,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SendConfigMSG = ipcMSGCallBackFunc; _additionalActions = _advancedPasteSettings.Properties.AdditionalActions; - _customActions = _advancedPasteSettings.Properties.CustomActions.Value; + _customActions = _advancedPasteSettings.Properties.CustomActions.Value ?? new ObservableCollection(); SetupSettingsFileWatcher(); @@ -469,7 +477,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public PasteAIConfiguration PasteAIConfiguration { - get => _advancedPasteSettings.Properties.PasteAIConfiguration; + get + { + // Ensure PasteAIConfiguration is never null for XAML binding + _advancedPasteSettings.Properties.PasteAIConfiguration ??= new PasteAIConfiguration(); + return _advancedPasteSettings.Properties.PasteAIConfiguration; + } + set { if (!ReferenceEquals(value, _advancedPasteSettings.Properties.PasteAIConfiguration)) From f0831742d623886584a4901e87b2f4d8bbea2a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 27 Jan 2026 02:51:16 +0100 Subject: [PATCH 06/53] CmdPal: Remove deadlock bait from AppListItem (#45076) ## Summary of the Pull Request This PR removes a Task.Wait() call from lazy-loading AppListItem details that could be invoked on the UI thread and lead to a deadlock. It now follows the same pattern previously used for loading icons in the same class, which has proven to work well. Prevents #44938 from stepping on this landmine. Cherry-picked from #44973. ## PR Checklist - [x] Closes: #45074 - [ ] **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 --- .../Microsoft.CmdPal.Ext.Apps/AppListItem.cs | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs index 176fae30d2..d907277ddc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -17,12 +17,25 @@ public sealed partial class AppListItem : ListItem { private readonly AppCommand _appCommand; private readonly AppItem _app; - private readonly Lazy
_details; private readonly Lazy> _iconLoadTask; + private readonly Lazy> _detailsLoadTask; private InterlockedBoolean _isLoadingIcon; + private InterlockedBoolean _isLoadingDetails; - public override IDetails? Details { get => _details.Value; set => base.Details = value; } + public override IDetails? Details + { + get + { + if (_isLoadingDetails.Set()) + { + _ = LoadDetailsAsync(); + } + + return base.Details; + } + set => base.Details = value; + } public override IIconInfo? Icon { @@ -52,16 +65,22 @@ public sealed partial class AppListItem : ListItem MoreCommands = AddPinCommands(_app.Commands!, isPinned); - _details = new Lazy
(() => - { - var t = BuildDetails(); - t.Wait(); - return t.Result; - }); - + _detailsLoadTask = new Lazy>(BuildDetails); _iconLoadTask = new Lazy>(async () => await FetchIcon(useThumbnails)); } + private async Task LoadDetailsAsync() + { + try + { + Details = await _detailsLoadTask.Value; + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to load details for {AppIdentifier}\n{ex}"); + } + } + private async Task LoadIconAsync() { try From 13ce5db6b19421293009d7013e5565bbf04104e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 27 Jan 2026 04:14:05 +0100 Subject: [PATCH 07/53] CmdPal: Add solution filter for Microsoft.CmdPal.Ext.PowerToys (#45096) ## Summary of the Pull Request This PR adds a new solution filter (.slnf) for the Microsoft.CmdPal.Ext.PowerToys extension project and its dependencies. This is added as a separate solution filter alongside CommandPalette.slnf, since the extension is not directly dependent on Command Palette. Instead, it relies on the public SDK distributed via NuGet. It also depends on other PowerToys projects, which would unnecessarily clutter CommandPalette.slnf. ## 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 --- .../Microsoft.CmdPal.Ext.PowerToys.slnf | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf b/src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf new file mode 100644 index 0000000000..49518dd33a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf @@ -0,0 +1,26 @@ +{ + "solution": { + "path": "..\\..\\..\\PowerToys.slnx", + "projects": [ + "src\\common\\Common.Search\\Common.Search.csproj", + "src\\common\\Common.UI\\Common.UI.csproj", + "src\\common\\ManagedCommon\\ManagedCommon.csproj", + "src\\common\\ManagedTelemetry\\Telemetry\\ManagedTelemetry.csproj", + "src\\common\\PowerToys.ModuleContracts\\PowerToys.ModuleContracts.csproj", + "src\\common\\SettingsAPI\\SettingsAPI.vcxproj", + "src\\common\\interop\\PowerToys.Interop.vcxproj", + "src\\common\\logger\\logger.vcxproj", + "src\\common\\version\\version.vcxproj", + "src\\logging\\logging.vcxproj", + "src\\modules\\MouseUtils\\MouseJump.Common\\MouseJump.Common.csproj", + "src\\modules\\Workspaces\\Workspaces.ModuleServices\\Workspaces.ModuleServices.csproj", + "src\\modules\\Workspaces\\WorkspacesCsharpLibrary\\WorkspacesCsharpLibrary.csproj", + "src\\modules\\ZoomIt\\ZoomItSettingsInterop\\ZoomItSettingsInterop.vcxproj", + "src\\modules\\awake\\Awake.ModuleServices\\Awake.ModuleServices.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.PowerToys\\Microsoft.CmdPal.Ext.PowerToys.csproj", + "src\\modules\\colorPicker\\ColorPicker.ModuleServices\\ColorPicker.ModuleServices.csproj", + "src\\modules\\fancyzones\\FancyZonesEditorCommon\\FancyZonesEditorCommon.csproj", + "src\\settings-ui\\Settings.UI.Library\\Settings.UI.Library.csproj" + ] + } +} \ No newline at end of file From 5ecb97b4e0f594fe60912e4ee9016aec0a9fb836 Mon Sep 17 00:00:00 2001 From: Heiko <61519853+htcfreek@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:24:02 +0100 Subject: [PATCH 08/53] [Enterprise; Policy] Add policy for CursorWrap to ADMX (#45028) ## Summary of the Pull Request Added missing policy definition. ## PR Checklist - [x] Closes: #44897 - [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 - [x] **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) - [x] **Documentation updated:** See PR for issue #44484 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- src/gpo/assets/PowerToys.admx | 15 +++++++++++++-- src/gpo/assets/en-US/PowerToys.adml | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index 4b77a6783f..eb8eb92b93 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -1,11 +1,11 @@ - + - + @@ -27,6 +27,7 @@ + @@ -338,6 +339,16 @@ + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 1bfa55866d..fe0611022a 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -1,7 +1,7 @@ - + PowerToys PowerToys @@ -34,6 +34,7 @@ PowerToys version 0.89.0 or later PowerToys version 0.90.0 or later PowerToys version 0.96.0 or later + PowerToys version 0.97.0 or later From PowerToys version 0.64.0 until PowerToys version 0.87.1 This policy configures the enabled state for all PowerToys utilities. @@ -266,6 +267,7 @@ If you don't configure this policy, the user will be able to control the setting Keyboard Manager: Configure enabled state Find My Mouse: Configure enabled state Mouse Highlighter: Configure enabled state + CursorWrap: Configure enabled state Mouse Jump: Configure enabled state Mouse Pointer Crosshairs: Configure enabled state Mouse Without Borders: Configure enabled state From 6661adbd5c6059239269862370dac30ad39cd911 Mon Sep 17 00:00:00 2001 From: Gordon Lam <73506701+yeelam-gordon@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:34:11 -0800 Subject: [PATCH 09/53] chore(prompts): add fix active PR comments prompt with scoped changes (#44996) ## Summary of the Pull Request Enhance the active PR comments prompt to allow for scoped changes while removing outdated model references from various prompt files. ## PR Checklist - [ ] **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 - [ ] **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 changes include the addition of a new prompt for fixing active PR comments with scoped changes, ensuring that only simple fixes are applied. Additionally, references to the model 'GPT-5.1-Codex-Max' have been removed from several prompt files to streamline the prompts. ## Validation Steps Performed Manual validation of the new prompt functionality was conducted to ensure it correctly identifies and resolves active PR comments. ``` --- .github/prompts/create-commit-title.prompt.md | 1 - .github/prompts/create-pr-summary.prompt.md | 1 - .github/prompts/fix-issue.prompt.md | 1 - .../prompts/fix-pr-active-comments.prompt.md | 70 +++++++++++++++++++ .github/prompts/fix-spelling.prompt.md | 1 - .github/prompts/review-issue.prompt.md | 1 - .github/prompts/review-pr.prompt.md | 1 - 7 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 .github/prompts/fix-pr-active-comments.prompt.md diff --git a/.github/prompts/create-commit-title.prompt.md b/.github/prompts/create-commit-title.prompt.md index 3696fed262..f61285c304 100644 --- a/.github/prompts/create-commit-title.prompt.md +++ b/.github/prompts/create-commit-title.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Generate an 80-character git commit title for the local diff' --- diff --git a/.github/prompts/create-pr-summary.prompt.md b/.github/prompts/create-pr-summary.prompt.md index 82bb0c869e..9e47c2fc3c 100644 --- a/.github/prompts/create-pr-summary.prompt.md +++ b/.github/prompts/create-pr-summary.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Generate a PowerToys-ready pull request description from the local diff' --- diff --git a/.github/prompts/fix-issue.prompt.md b/.github/prompts/fix-issue.prompt.md index d7aeda0381..9b758c4e8d 100644 --- a/.github/prompts/fix-issue.prompt.md +++ b/.github/prompts/fix-issue.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Execute the fix for a GitHub issue using the previously generated implementation plan' --- diff --git a/.github/prompts/fix-pr-active-comments.prompt.md b/.github/prompts/fix-pr-active-comments.prompt.md new file mode 100644 index 0000000000..4d7c67d986 --- /dev/null +++ b/.github/prompts/fix-pr-active-comments.prompt.md @@ -0,0 +1,70 @@ +--- +description: 'Fix active pull request comments with scoped changes' +name: 'fix-pr-active-comments' +agent: 'agent' +argument-hint: 'PR number or active PR URL' +--- + +# Fix Active PR Comments + +## Mission +Resolve active pull request comments by applying only simple fixes. For complex refactors, write a plan instead of changing code. + +## Scope & Preconditions +- You must have an active pull request context or a provided PR number. +- Only implement simple changes. Do not implement large refactors. +- If required context is missing, request it and stop. + +## Inputs +- Required: ${input:pr_number:PR number or URL} +- Optional: ${input:comment_scope:files or areas to focus on} +- Optional: ${input:fixing_guidelines:additional fixing guidelines from the user} + +## Workflow +1. Locate all active (unresolved) PR review comments for the given PR. +2. For each comment, classify the change scope: + - Simple change: limited edits, localized fix, low risk, no broad redesign. + - Large refactor: multi-file redesign, architecture change, or risky behavior change. +3. For each large refactor request: + - Do not modify code. + - Write a planning document to Generated Files/prReview/${input:pr_number}/fixPlan/. +4. For each simple change request: + - Implement the fix with minimal edits. + - Run quick checks if needed. + - Commit and push the change. +5. For comments that seem invalid, unclear, or not applicable (even if simple): + - Do not change code. + - Add the item to a summary table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md. + - Consult back to the end user in a friendly, polite tone. +6. Respond to each comment that you fixed: + - Reply in the active conversation. + - Use a polite or friendly tone. + - Keep the response under 200 words. + - Resolve the comment after replying. + +## Output Expectations +- Simple fixes: code changes committed and pushed. +- Large refactors: a plan file saved to Generated Files/prReview/${input:pr_number}/fixPlan/. +- Invalid or unclear comments: captured in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md. +- Each fixed comment has a reply under 200 words and is resolved. + +## Plan File Template +Use this template for each large refactor item: + +# Fix Plan: + +## Context +- Comment link: +- Impacted areas: + +## Overview Table Template +Use this table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md: + +| Comment link | Summary | Reason not applied | Suggested follow-up | +| --- | --- | --- | --- | +| | | | | + +## Quality Assurance +- Verify plan file path exists. +- Ensure no code changes were made for large refactor items. +- Confirm replies are under 200 words and comments are resolved. diff --git a/.github/prompts/fix-spelling.prompt.md b/.github/prompts/fix-spelling.prompt.md index 008fd5fae3..bd40c1feea 100644 --- a/.github/prompts/fix-spelling.prompt.md +++ b/.github/prompts/fix-spelling.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Resolve Code scanning / check-spelling comments on the active PR' --- diff --git a/.github/prompts/review-issue.prompt.md b/.github/prompts/review-issue.prompt.md index 45c6b7fcaa..2ed4b9ef1f 100644 --- a/.github/prompts/review-issue.prompt.md +++ b/.github/prompts/review-issue.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Review a GitHub issue, score it (0-100), and generate an implementation plan' --- diff --git a/.github/prompts/review-pr.prompt.md b/.github/prompts/review-pr.prompt.md index 3f8f07d6b0..0f72b6171d 100644 --- a/.github/prompts/review-pr.prompt.md +++ b/.github/prompts/review-pr.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Perform a comprehensive PR review with per-step Markdown and machine-readable outputs' --- From d26d9f745ab1cd8472d5d94bd1f14a5d2bef6c6c Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Tue, 27 Jan 2026 05:27:11 +0000 Subject: [PATCH 10/53] CursorWrap improvements (#44936) ## Summary of the Pull Request - Updated engine for better multi-monitor support. - Closing the laptop lid will now update the monitor topology - New settings/dropdown to support wrapping on horizontal, vertical, or both image ## PR Checklist - [x] Closes: #44820 - [x] Closes: #44864 - [x] Closes: #44952 - [ ] **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 Feedback for CursorWrap shows that users want the ability to constrain wrapping for horizontal only, vertical only, or both (default behavior). This PR adds a new dropdown to CursorWrap settings to enable a user to select the appropriate wrapping model. ## Validation Steps Performed Local build and running on Surface Laptop 7 Pro - will also validate on a multi-monitor setup. --------- Co-authored-by: vanzue --- .github/actions/spell-check/expect.txt | 57 +- .../MouseUtils/CursorWrap/CursorWrap.vcxproj | 5 +- .../MouseUtils/CursorWrap/CursorWrapCore.cpp | 268 +++++ .../MouseUtils/CursorWrap/CursorWrapCore.h | 33 + .../MouseUtils/CursorWrap/MonitorTopology.cpp | 546 +++++++++ .../MouseUtils/CursorWrap/MonitorTopology.h | 106 ++ src/modules/MouseUtils/CursorWrap/dllmain.cpp | 1044 ++++------------- .../CursorWrapProperties.cs | 4 + .../Settings.UI.Library/CursorWrapSettings.cs | 11 +- .../SettingsXAML/Views/MouseUtilsPage.xaml | 7 + .../Settings.UI/Strings/en-us/Resources.resw | 12 + .../ViewModels/MouseUtilsViewModel.cs | 32 + 12 files changed, 1274 insertions(+), 851 deletions(-) create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapCore.h create mode 100644 src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/MonitorTopology.h diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index b622870905..5af3d5d3b6 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -22,7 +22,6 @@ ADate ADDSTRING ADDUNDORECORD ADifferent -adjacents ADMINS adml admx @@ -219,10 +218,11 @@ CIELCh cim CImage cla -claude CLASSDC +classguid classmethod CLASSNOTAVAILABLE +claude CLEARTYPE clickable clickonce @@ -261,7 +261,6 @@ colorhistory colorhistorylimit COLORKEY colorref -Convs comctl comdlg comexp @@ -282,6 +281,7 @@ CONTEXTHELP CONTEXTMENUHANDLER contractversion CONTROLPARENT +Convs copiedcolorrepresentation coppied copyable @@ -348,12 +348,14 @@ datareader datatracker dataversion Dayof +dbcc DBID DBLCLKS DBLEPSILON DBPROP DBPROPIDSET DBPROPSET +DBT DCBA DCOM DComposition @@ -371,8 +373,7 @@ DEFAULTICON defaultlib DEFAULTONLY DEFAULTSIZE -DEFAULTTONEAREST -Defaulttonearest +defaulttonearest DEFAULTTONULL DEFAULTTOPRIMARY DEFERERASE @@ -394,14 +395,19 @@ DESKTOPVERTRES devblogs devdocs devenv +DEVICEINTERFACE +devicetype +DEVINTERFACE devmgmt DEVMODE DEVMODEW +DEVNODES devpal +DEVTYP dfx DIALOGEX -digicert diffs +digicert DINORMAL DISABLEASACTIONKEY DISABLENOSCROLL @@ -544,7 +550,6 @@ fdx FErase fesf FFFF -FInc Figma FILEEXPLORER fileexploreraddons @@ -565,6 +570,7 @@ FILESYSPATH Filetime FILEVERSION FILTERMODE +FInc findfast findmymouse FIXEDFILEINFO @@ -666,13 +672,14 @@ HCRYPTPROV hcursor hcwhite hdc +HDEVNOTIFY hdr hdrop hdwwiz Helpline helptext -HGFE hgdiobj +HGFE hglobal hhk HHmmssfff @@ -748,9 +755,9 @@ HWNDPARENT HWNDPREV hyjiacan IAI +icf ICONERROR ICONLOCATION -icf IDCANCEL IDD idk @@ -841,8 +848,8 @@ jeli jfif jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi jjw -JOBOBJECT jobject +JOBOBJECT jpe jpnime Jsons @@ -929,9 +936,9 @@ LOWORD lparam LPBITMAPINFOHEADER LPCFHOOKPROC +lpch LPCITEMIDLIST LPCLSID -lpch lpcmi LPCMINVOKECOMMANDINFO LPCREATESTRUCT @@ -947,6 +954,7 @@ LPMONITORINFO LPOSVERSIONINFOEXW LPQUERY lprc +LPrivate LPSAFEARRAY lpstr lpsz @@ -956,7 +964,6 @@ lptpm LPTR LPTSTR lpv -LPrivate LPW lpwcx lpwndpl @@ -1000,13 +1007,13 @@ mber MBM MBR Mbuttondown +mcp MDICHILD MDL mdtext mdtxt mdwn meme -mcp memicmp MENUITEMINFO MENUITEMINFOW @@ -1042,8 +1049,8 @@ mmi mmsys mobileredirect mockapi -modelcontextprotocol MODALFRAME +modelcontextprotocol MODESPRUNED MONITORENUMPROC MONITORINFO @@ -1087,9 +1094,9 @@ MSLLHOOKSTRUCT Mso msrc msstore +mstsc msvcp MT -mstsc MTND MULTIPLEUSE multizone @@ -1099,11 +1106,11 @@ muxxc muxxh MVPs mvvm -myorg -myrepo MVVMTK MWBEx MYICON +myorg +myrepo NAMECHANGE namespaceanddescendants nao @@ -1244,10 +1251,8 @@ opencode OPENFILENAME openrdp opensource -openxmlformats -ollama -onnx openurl +openxmlformats OPTIMIZEFORINVOKE ORPHANEDDIALOGTITLE ORSCANS @@ -1464,7 +1469,6 @@ rbhid Rbuttondown rclsid RCZOOMIT -remotedesktop rdp RDW READMODE @@ -1493,6 +1497,7 @@ remappings REMAPSUCCESSFUL REMAPUNSUCCESSFUL Remotable +remotedesktop remoteip Removelnk renamable @@ -1526,8 +1531,8 @@ RIGHTSCROLLBAR riid RKey RNumber -rop rollups +rop ROUNDSMALL ROWSETEXT rpcrt @@ -1766,8 +1771,7 @@ SVGIO svgz SVSI SWFO -SWP -Swp +swp SWPNOSIZE SWPNOZORDER SWRESTORE @@ -1786,8 +1790,7 @@ SYSKEY syskeydown SYSKEYUP SYSLIB -SYSMENU -Sysmenu +sysmenu systemai SYSTEMAPPS SYSTEMMODAL @@ -1891,9 +1894,9 @@ uitests UITo ULONGLONG Ultrawide -ums UMax UMin +ums uncompilable UNCPRIORITY UNDNAME diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj index 59e2095ca7..254bac4678 100644 --- a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -84,14 +84,17 @@ + + + - + Create diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp new file mode 100644 index 0000000000..bea59e6186 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp @@ -0,0 +1,268 @@ +// 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. + +#include "pch.h" +#include "CursorWrapCore.h" +#include "../../../common/logger/logger.h" +#include +#include +#include + +CursorWrapCore::CursorWrapCore() +{ +} + +#ifdef _DEBUG +std::wstring CursorWrapCore::GenerateTopologyJSON() const +{ + std::wostringstream json; + + // Get current time + auto now = std::time(nullptr); + std::tm tm{}; + localtime_s(&tm, &now); + + wchar_t computerName[MAX_COMPUTERNAME_LENGTH + 1] = {0}; + DWORD size = MAX_COMPUTERNAME_LENGTH + 1; + GetComputerNameW(computerName, &size); + + wchar_t userName[256] = {0}; + size = 256; + GetUserNameW(userName, &size); + + json << L"{\n"; + json << L" \"captured_at\": \"" << std::put_time(&tm, L"%Y-%m-%dT%H:%M:%S%z") << L"\",\n"; + json << L" \"computer_name\": \"" << computerName << L"\",\n"; + json << L" \"user_name\": \"" << userName << L"\",\n"; + json << L" \"monitor_count\": " << m_monitors.size() << L",\n"; + json << L" \"monitors\": [\n"; + + for (size_t i = 0; i < m_monitors.size(); ++i) + { + const auto& monitor = m_monitors[i]; + + // Get DPI for this monitor + UINT dpiX = 96, dpiY = 96; + POINT center = { + (monitor.rect.left + monitor.rect.right) / 2, + (monitor.rect.top + monitor.rect.bottom) / 2 + }; + HMONITOR hMon = MonitorFromPoint(center, MONITOR_DEFAULTTONEAREST); + if (hMon) + { + // Try GetDpiForMonitor (requires linking Shcore.lib) + using GetDpiForMonitorFunc = HRESULT (WINAPI *)(HMONITOR, int, UINT*, UINT*); + HMODULE shcore = LoadLibraryW(L"Shcore.dll"); + if (shcore) + { + auto getDpi = reinterpret_cast(GetProcAddress(shcore, "GetDpiForMonitor")); + if (getDpi) + { + getDpi(hMon, 0, &dpiX, &dpiY); // MDT_EFFECTIVE_DPI = 0 + } + FreeLibrary(shcore); + } + } + + int scalingPercent = static_cast((dpiX / 96.0) * 100); + + json << L" {\n"; + json << L" \"left\": " << monitor.rect.left << L",\n"; + json << L" \"top\": " << monitor.rect.top << L",\n"; + json << L" \"right\": " << monitor.rect.right << L",\n"; + json << L" \"bottom\": " << monitor.rect.bottom << L",\n"; + json << L" \"width\": " << (monitor.rect.right - monitor.rect.left) << L",\n"; + json << L" \"height\": " << (monitor.rect.bottom - monitor.rect.top) << L",\n"; + json << L" \"dpi\": " << dpiX << L",\n"; + json << L" \"scaling_percent\": " << scalingPercent << L",\n"; + json << L" \"primary\": " << (monitor.isPrimary ? L"true" : L"false") << L",\n"; + json << L" \"monitor_id\": " << monitor.monitorId << L"\n"; + json << L" }"; + if (i < m_monitors.size() - 1) + { + json << L","; + } + json << L"\n"; + } + + json << L" ]\n"; + json << L"}"; + + return json.str(); +} +#endif + +void CursorWrapCore::UpdateMonitorInfo() +{ + size_t previousMonitorCount = m_monitors.size(); + Logger::info(L"======= UPDATE MONITOR INFO START ======="); + Logger::info(L"Previous monitor count: {}", previousMonitorCount); + + m_monitors.clear(); + + EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL { + auto* self = reinterpret_cast(lParam); + + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(hMonitor, &mi)) + { + MonitorInfo info{}; + info.hMonitor = hMonitor; // Store handle for direct comparison later + info.rect = mi.rcMonitor; + info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0; + info.monitorId = static_cast(self->m_monitors.size()); + self->m_monitors.push_back(info); + + Logger::info(L"Enumerated monitor {}: hMonitor={}, rect=({},{},{},{}), primary={}", + info.monitorId, reinterpret_cast(hMonitor), + mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom, + info.isPrimary ? L"yes" : L"no"); + } + + return TRUE; + }, reinterpret_cast(this)); + + if (previousMonitorCount != m_monitors.size()) + { + Logger::info(L"*** MONITOR CONFIGURATION CHANGED: {} -> {} monitors ***", + previousMonitorCount, m_monitors.size()); + } + + m_topology.Initialize(m_monitors); + + // Log monitor configuration summary + Logger::info(L"Monitor configuration updated: {} monitor(s)", m_monitors.size()); + for (size_t i = 0; i < m_monitors.size(); ++i) + { + const auto& m = m_monitors[i]; + int width = m.rect.right - m.rect.left; + int height = m.rect.bottom - m.rect.top; + Logger::info(L" Monitor {}: {}x{} at ({}, {}){}", + i, width, height, m.rect.left, m.rect.top, + m.isPrimary ? L" [PRIMARY]" : L""); + } + Logger::info(L" Detected {} outer edges for cursor wrapping", m_topology.GetOuterEdges().size()); + + // Detect and log monitor gaps + auto gaps = m_topology.DetectMonitorGaps(); + if (!gaps.empty()) + { + Logger::warn(L"Monitor configuration has coordinate gaps that may prevent wrapping:"); + for (const auto& gap : gaps) + { + Logger::warn(L" Gap between Monitor {} and Monitor {}: {}px horizontal gap, {}px vertical overlap", + gap.monitor1Index, gap.monitor2Index, gap.horizontalGap, gap.verticalOverlap); + } + Logger::warn(L" If monitors appear snapped in Display Settings but show gaps here:"); + Logger::warn(L" 1. Try dragging monitors apart and snapping them back together"); + Logger::warn(L" 2. Update your GPU drivers"); + } + + Logger::info(L"======= UPDATE MONITOR INFO END ======="); +} + +POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode) +{ + // Check if wrapping should be disabled during drag + if (disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000)) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] [DRAG] Left mouse button down - skipping wrap\n"); +#endif + return currentPos; + } + + // Convert int wrapMode to WrapMode enum + WrapMode mode = static_cast(wrapMode); + +#ifdef _DEBUG + { + std::wostringstream oss; + oss << L"[CursorWrap] [MOVE] Cursor at (" << currentPos.x << L", " << currentPos.y << L")"; + + // Get current monitor and identify which one + HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); + RECT monitorRect; + if (m_topology.GetMonitorRect(currentMonitor, monitorRect)) + { + // Find monitor ID + int monitorId = -1; + for (const auto& monitor : m_monitors) + { + if (monitor.rect.left == monitorRect.left && + monitor.rect.top == monitorRect.top && + monitor.rect.right == monitorRect.right && + monitor.rect.bottom == monitorRect.bottom) + { + monitorId = monitor.monitorId; + break; + } + } + oss << L" on Monitor " << monitorId << L" [" << monitorRect.left << L".." << monitorRect.right + << L", " << monitorRect.top << L".." << monitorRect.bottom << L"]"; + } + else + { + oss << L" (beyond monitor bounds)"; + } + oss << L"\n"; + OutputDebugStringW(oss.str().c_str()); + } +#endif + + // Get current monitor + HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); + + // Check if cursor is on an outer edge (filtered by wrap mode) + EdgeType edgeType; + if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode)) + { +#ifdef _DEBUG + static bool lastWasNotOuter = false; + if (!lastWasNotOuter) + { + OutputDebugStringW(L"[CursorWrap] [MOVE] Not on outer edge - no wrapping\n"); + lastWasNotOuter = true; + } +#endif + return currentPos; // Not on an outer edge + } + +#ifdef _DEBUG + { + const wchar_t* edgeStr = L"Unknown"; + switch (edgeType) + { + case EdgeType::Left: edgeStr = L"Left"; break; + case EdgeType::Right: edgeStr = L"Right"; break; + case EdgeType::Top: edgeStr = L"Top"; break; + case EdgeType::Bottom: edgeStr = L"Bottom"; break; + } + std::wostringstream oss; + oss << L"[CursorWrap] [EDGE] Detected outer " << edgeStr << L" edge at (" << currentPos.x << L", " << currentPos.y << L")\n"; + OutputDebugStringW(oss.str().c_str()); + } +#endif + + // Calculate wrap destination + POINT newPos = m_topology.GetWrapDestination(currentMonitor, currentPos, edgeType); + +#ifdef _DEBUG + if (newPos.x != currentPos.x || newPos.y != currentPos.y) + { + std::wostringstream oss; + oss << L"[CursorWrap] [WRAP] Position change: (" << currentPos.x << L", " << currentPos.y + << L") -> (" << newPos.x << L", " << newPos.y << L")\n"; + oss << L"[CursorWrap] [WRAP] Delta: (" << (newPos.x - currentPos.x) << L", " << (newPos.y - currentPos.y) << L")\n"; + OutputDebugStringW(oss.str().c_str()); + } + else + { + OutputDebugStringW(L"[CursorWrap] [WRAP] No position change (same-monitor wrap?)\n"); + } +#endif + + return newPos; +} diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h new file mode 100644 index 0000000000..6c19a26e39 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h @@ -0,0 +1,33 @@ +// 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 once +#include +#include +#include +#include "MonitorTopology.h" + +// Core cursor wrapping engine +class CursorWrapCore +{ +public: + CursorWrapCore(); + + void UpdateMonitorInfo(); + + // Handle mouse move with wrap mode filtering + // wrapMode: 0=Both, 1=VerticalOnly, 2=HorizontalOnly + POINT HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode); + + const std::vector& GetMonitors() const { return m_monitors; } + const MonitorTopology& GetTopology() const { return m_topology; } + +private: +#ifdef _DEBUG + std::wstring GenerateTopologyJSON() const; +#endif + + std::vector m_monitors; + MonitorTopology m_topology; +}; diff --git a/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp b/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp new file mode 100644 index 0000000000..8e613996c6 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp @@ -0,0 +1,546 @@ +// 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. + +#include "pch.h" +#include "MonitorTopology.h" +#include "../../../common/logger/logger.h" +#include +#include + +void MonitorTopology::Initialize(const std::vector& monitors) +{ + Logger::info(L"======= TOPOLOGY INITIALIZATION START ======="); + Logger::info(L"Initializing edge-based topology for {} monitors", monitors.size()); + + m_monitors = monitors; + m_outerEdges.clear(); + m_edgeMap.clear(); + + if (monitors.empty()) + { + Logger::warn(L"No monitors provided to Initialize"); + return; + } + + // Log monitor details + for (size_t i = 0; i < monitors.size(); ++i) + { + const auto& m = monitors[i]; + Logger::info(L"Monitor {}: hMonitor={}, rect=({},{},{},{}), primary={}", + i, reinterpret_cast(m.hMonitor), + m.rect.left, m.rect.top, m.rect.right, m.rect.bottom, + m.isPrimary ? L"yes" : L"no"); + } + + BuildEdgeMap(); + IdentifyOuterEdges(); + + Logger::info(L"Found {} outer edges", m_outerEdges.size()); + for (const auto& edge : m_outerEdges) + { + const wchar_t* typeStr = L"Unknown"; + switch (edge.type) + { + case EdgeType::Left: typeStr = L"Left"; break; + case EdgeType::Right: typeStr = L"Right"; break; + case EdgeType::Top: typeStr = L"Top"; break; + case EdgeType::Bottom: typeStr = L"Bottom"; break; + } + Logger::info(L"Outer edge: Monitor {} {} at position {}, range [{}, {}]", + edge.monitorIndex, typeStr, edge.position, edge.start, edge.end); + } + Logger::info(L"======= TOPOLOGY INITIALIZATION COMPLETE ======="); +} + +void MonitorTopology::BuildEdgeMap() +{ + // Create edges for each monitor using monitor index (not HMONITOR) + // This is important because HMONITOR handles can change when monitors are + // added/removed dynamically, but indices remain stable within a single + // topology configuration + for (size_t idx = 0; idx < m_monitors.size(); ++idx) + { + const auto& monitor = m_monitors[idx]; + int monitorIndex = static_cast(idx); + + // Left edge + MonitorEdge leftEdge; + leftEdge.monitorIndex = monitorIndex; + leftEdge.type = EdgeType::Left; + leftEdge.position = monitor.rect.left; + leftEdge.start = monitor.rect.top; + leftEdge.end = monitor.rect.bottom; + leftEdge.isOuter = true; // Will be updated in IdentifyOuterEdges + m_edgeMap[{monitorIndex, EdgeType::Left}] = leftEdge; + + // Right edge + MonitorEdge rightEdge; + rightEdge.monitorIndex = monitorIndex; + rightEdge.type = EdgeType::Right; + rightEdge.position = monitor.rect.right - 1; + rightEdge.start = monitor.rect.top; + rightEdge.end = monitor.rect.bottom; + rightEdge.isOuter = true; + m_edgeMap[{monitorIndex, EdgeType::Right}] = rightEdge; + + // Top edge + MonitorEdge topEdge; + topEdge.monitorIndex = monitorIndex; + topEdge.type = EdgeType::Top; + topEdge.position = monitor.rect.top; + topEdge.start = monitor.rect.left; + topEdge.end = monitor.rect.right; + topEdge.isOuter = true; + m_edgeMap[{monitorIndex, EdgeType::Top}] = topEdge; + + // Bottom edge + MonitorEdge bottomEdge; + bottomEdge.monitorIndex = monitorIndex; + bottomEdge.type = EdgeType::Bottom; + bottomEdge.position = monitor.rect.bottom - 1; + bottomEdge.start = monitor.rect.left; + bottomEdge.end = monitor.rect.right; + bottomEdge.isOuter = true; + m_edgeMap[{monitorIndex, EdgeType::Bottom}] = bottomEdge; + } +} + +void MonitorTopology::IdentifyOuterEdges() +{ + const int tolerance = 50; + + // Check each edge against all other edges to find adjacent ones + for (auto& [key1, edge1] : m_edgeMap) + { + for (const auto& [key2, edge2] : m_edgeMap) + { + if (edge1.monitorIndex == edge2.monitorIndex) + { + continue; // Same monitor + } + + // Check if edges are adjacent + if (EdgesAreAdjacent(edge1, edge2, tolerance)) + { + edge1.isOuter = false; + break; // This edge has an adjacent monitor + } + } + + if (edge1.isOuter) + { + m_outerEdges.push_back(edge1); + } + } +} + +bool MonitorTopology::EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance) const +{ + // Edges must be opposite types to be adjacent + bool oppositeTypes = false; + + if ((edge1.type == EdgeType::Left && edge2.type == EdgeType::Right) || + (edge1.type == EdgeType::Right && edge2.type == EdgeType::Left) || + (edge1.type == EdgeType::Top && edge2.type == EdgeType::Bottom) || + (edge1.type == EdgeType::Bottom && edge2.type == EdgeType::Top)) + { + oppositeTypes = true; + } + + if (!oppositeTypes) + { + return false; + } + + // Check if positions are within tolerance + if (abs(edge1.position - edge2.position) > tolerance) + { + return false; + } + + // Check if perpendicular ranges overlap + int overlapStart = max(edge1.start, edge2.start); + int overlapEnd = min(edge1.end, edge2.end); + + return overlapEnd > overlapStart + tolerance; +} + +bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const +{ + RECT monitorRect; + if (!GetMonitorRect(monitor, monitorRect)) + { + Logger::warn(L"IsOnOuterEdge: GetMonitorRect failed for monitor handle {}", reinterpret_cast(monitor)); + return false; + } + + // Get monitor index for edge map lookup + int monitorIndex = GetMonitorIndex(monitor); + if (monitorIndex < 0) + { + Logger::warn(L"IsOnOuterEdge: Monitor index not found for handle {} at cursor ({}, {})", + reinterpret_cast(monitor), cursorPos.x, cursorPos.y); + return false; // Monitor not found in our list + } + + // Check each edge type + const int edgeThreshold = 1; + + // At corners, multiple edges may match - collect all candidates and try each + // to find one with a valid wrap destination + std::vector candidateEdges; + + // Left edge - only if mode allows horizontal wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::HorizontalOnly) && + cursorPos.x <= monitorRect.left + edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Left}); + if (it != m_edgeMap.end() && it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Left); + } + } + + // Right edge - only if mode allows horizontal wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::HorizontalOnly) && + cursorPos.x >= monitorRect.right - 1 - edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Right}); + if (it != m_edgeMap.end()) + { + if (it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Right); + } + // Debug: Log why right edge isn't outer + else + { + Logger::trace(L"IsOnOuterEdge: Monitor {} right edge is NOT outer (inner edge)", monitorIndex); + } + } + } + + // Top edge - only if mode allows vertical wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::VerticalOnly) && + cursorPos.y <= monitorRect.top + edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Top}); + if (it != m_edgeMap.end() && it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Top); + } + } + + // Bottom edge - only if mode allows vertical wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::VerticalOnly) && + cursorPos.y >= monitorRect.bottom - 1 - edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Bottom}); + if (it != m_edgeMap.end() && it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Bottom); + } + } + + if (candidateEdges.empty()) + { + return false; + } + + // Try each candidate edge and return first with valid wrap destination + for (EdgeType candidate : candidateEdges) + { + MonitorEdge oppositeEdge = FindOppositeOuterEdge(candidate, + (candidate == EdgeType::Left || candidate == EdgeType::Right) ? cursorPos.y : cursorPos.x); + + if (oppositeEdge.monitorIndex >= 0) + { + outEdgeType = candidate; + return true; + } + } + + return false; +} + +POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const +{ + // Get monitor index for edge map lookup + int monitorIndex = GetMonitorIndex(fromMonitor); + if (monitorIndex < 0) + { + return cursorPos; // Monitor not found + } + + auto it = m_edgeMap.find({monitorIndex, edgeType}); + if (it == m_edgeMap.end()) + { + return cursorPos; // Edge not found + } + + const MonitorEdge& fromEdge = it->second; + + // Calculate relative position on current edge (0.0 to 1.0) + double relativePos = GetRelativePosition(fromEdge, + (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x); + + // Find opposite outer edge + MonitorEdge oppositeEdge = FindOppositeOuterEdge(edgeType, + (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x); + + if (oppositeEdge.monitorIndex < 0) + { + // No opposite edge found, wrap within same monitor + RECT monitorRect; + if (GetMonitorRect(fromMonitor, monitorRect)) + { + POINT result = cursorPos; + switch (edgeType) + { + case EdgeType::Left: + result.x = monitorRect.right - 2; + break; + case EdgeType::Right: + result.x = monitorRect.left + 1; + break; + case EdgeType::Top: + result.y = monitorRect.bottom - 2; + break; + case EdgeType::Bottom: + result.y = monitorRect.top + 1; + break; + } + return result; + } + return cursorPos; + } + + // Calculate target position on opposite edge + POINT result; + + if (edgeType == EdgeType::Left || edgeType == EdgeType::Right) + { + // Horizontal edge -> vertical movement + result.x = oppositeEdge.position; + result.y = GetAbsolutePosition(oppositeEdge, relativePos); + } + else + { + // Vertical edge -> horizontal movement + result.y = oppositeEdge.position; + result.x = GetAbsolutePosition(oppositeEdge, relativePos); + } + + return result; +} + +MonitorEdge MonitorTopology::FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const +{ + EdgeType targetType; + bool findMax; // true = find max position, false = find min position + + switch (fromEdge) + { + case EdgeType::Left: + targetType = EdgeType::Right; + findMax = true; + break; + case EdgeType::Right: + targetType = EdgeType::Left; + findMax = false; + break; + case EdgeType::Top: + targetType = EdgeType::Bottom; + findMax = true; + break; + case EdgeType::Bottom: + targetType = EdgeType::Top; + findMax = false; + break; + default: + return { .monitorIndex = -1 }; // Invalid edge type + } + + MonitorEdge result = { .monitorIndex = -1 }; // -1 indicates not found + int extremePosition = findMax ? INT_MIN : INT_MAX; + + for (const auto& edge : m_outerEdges) + { + if (edge.type != targetType) + { + continue; + } + + // Check if this edge overlaps with the relative position + if (relativePosition >= edge.start && relativePosition <= edge.end) + { + if ((findMax && edge.position > extremePosition) || + (!findMax && edge.position < extremePosition)) + { + extremePosition = edge.position; + result = edge; + } + } + } + + return result; +} + +double MonitorTopology::GetRelativePosition(const MonitorEdge& edge, int coordinate) const +{ + if (edge.end == edge.start) + { + return 0.5; // Avoid division by zero + } + + int clamped = max(edge.start, min(coordinate, edge.end)); + // Use int64_t to avoid overflow warning C26451 + int64_t numerator = static_cast(clamped) - static_cast(edge.start); + int64_t denominator = static_cast(edge.end) - static_cast(edge.start); + return static_cast(numerator) / static_cast(denominator); +} + +int MonitorTopology::GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const +{ + // Use int64_t to prevent arithmetic overflow during subtraction and multiplication + int64_t range = static_cast(edge.end) - static_cast(edge.start); + int64_t offset = static_cast(relativePosition * static_cast(range)); + // Clamp result to int range before returning + int64_t result = static_cast(edge.start) + offset; + return static_cast(result); +} + +std::vector MonitorTopology::DetectMonitorGaps() const +{ + std::vector gaps; + const int gapThreshold = 50; // Same as ADJACENCY_TOLERANCE + + // Check each pair of monitors + for (size_t i = 0; i < m_monitors.size(); ++i) + { + for (size_t j = i + 1; j < m_monitors.size(); ++j) + { + const auto& m1 = m_monitors[i]; + const auto& m2 = m_monitors[j]; + + // Check vertical overlap + int vOverlapStart = max(m1.rect.top, m2.rect.top); + int vOverlapEnd = min(m1.rect.bottom, m2.rect.bottom); + int vOverlap = vOverlapEnd - vOverlapStart; + + if (vOverlap <= 0) + { + continue; // No vertical overlap, skip + } + + // Check horizontal gap + int hGap = min(abs(m1.rect.right - m2.rect.left), abs(m2.rect.right - m1.rect.left)); + + if (hGap > gapThreshold) + { + GapInfo gap; + gap.monitor1Index = static_cast(i); + gap.monitor2Index = static_cast(j); + gap.horizontalGap = hGap; + gap.verticalOverlap = vOverlap; + gaps.push_back(gap); + } + } + } + + return gaps; +} + +HMONITOR MonitorTopology::GetMonitorFromPoint(const POINT& pt) const +{ + return MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); +} + +bool MonitorTopology::GetMonitorRect(HMONITOR monitor, RECT& rect) const +{ + // First try direct HMONITOR comparison + for (const auto& monitorInfo : m_monitors) + { + if (monitorInfo.hMonitor == monitor) + { + rect = monitorInfo.rect; + return true; + } + } + + // Fallback: If direct comparison fails, try matching by current monitor info + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(monitor, &mi)) + { + for (const auto& monitorInfo : m_monitors) + { + if (monitorInfo.rect.left == mi.rcMonitor.left && + monitorInfo.rect.top == mi.rcMonitor.top && + monitorInfo.rect.right == mi.rcMonitor.right && + monitorInfo.rect.bottom == mi.rcMonitor.bottom) + { + rect = monitorInfo.rect; + return true; + } + } + } + + return false; +} + +HMONITOR MonitorTopology::GetMonitorFromRect(const RECT& rect) const +{ + return MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST); +} + +int MonitorTopology::GetMonitorIndex(HMONITOR monitor) const +{ + // First try direct HMONITOR comparison (fast and accurate) + for (size_t i = 0; i < m_monitors.size(); ++i) + { + if (m_monitors[i].hMonitor == monitor) + { + return static_cast(i); + } + } + + // Fallback: If direct comparison fails (e.g., handle changed after display reconfiguration), + // try matching by position. Get the monitor's current rect and find matching stored rect. + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(monitor, &mi)) + { + for (size_t i = 0; i < m_monitors.size(); ++i) + { + // Match by rect bounds + if (m_monitors[i].rect.left == mi.rcMonitor.left && + m_monitors[i].rect.top == mi.rcMonitor.top && + m_monitors[i].rect.right == mi.rcMonitor.right && + m_monitors[i].rect.bottom == mi.rcMonitor.bottom) + { + Logger::trace(L"GetMonitorIndex: Found monitor {} via rect fallback (handle changed from {} to {})", + i, reinterpret_cast(m_monitors[i].hMonitor), reinterpret_cast(monitor)); + return static_cast(i); + } + } + + // Log all stored monitors vs the requested one for debugging + Logger::warn(L"GetMonitorIndex: No match found. Requested monitor rect=({},{},{},{})", + mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom); + for (size_t i = 0; i < m_monitors.size(); ++i) + { + Logger::warn(L" Stored monitor {}: rect=({},{},{},{})", + i, m_monitors[i].rect.left, m_monitors[i].rect.top, + m_monitors[i].rect.right, m_monitors[i].rect.bottom); + } + } + else + { + Logger::warn(L"GetMonitorIndex: GetMonitorInfo failed for handle {}", reinterpret_cast(monitor)); + } + + return -1; // Not found +} + diff --git a/src/modules/MouseUtils/CursorWrap/MonitorTopology.h b/src/modules/MouseUtils/CursorWrap/MonitorTopology.h new file mode 100644 index 0000000000..0dead8e351 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/MonitorTopology.h @@ -0,0 +1,106 @@ +// 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 once +#include +#include +#include + +// Monitor information structure +struct MonitorInfo +{ + HMONITOR hMonitor; // Direct handle for accurate lookup after display changes + RECT rect; + bool isPrimary; + int monitorId; +}; + +// Edge type enumeration +enum class EdgeType +{ + Left = 0, + Right = 1, + Top = 2, + Bottom = 3 +}; + +// Wrap mode enumeration (matches Settings UI dropdown) +enum class WrapMode +{ + Both = 0, // Wrap in both directions + VerticalOnly = 1, // Only wrap top/bottom + HorizontalOnly = 2 // Only wrap left/right +}; + +// Represents a single edge of a monitor +struct MonitorEdge +{ + int monitorIndex; // Index into m_monitors (stable across display changes) + EdgeType type; + int start; // For vertical edges: Y start; horizontal: X start + int end; // For vertical edges: Y end; horizontal: X end + int position; // For vertical edges: X coord; horizontal: Y coord + bool isOuter; // True if no adjacent monitor touches this edge +}; + +// Monitor topology helper - manages edge-based monitor layout +struct MonitorTopology +{ + void Initialize(const std::vector& monitors); + + // Check if cursor is on an outer edge of the given monitor + // wrapMode filters which edges are considered (Both, VerticalOnly, HorizontalOnly) + bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const; + + // Get the wrap destination point for a cursor on an outer edge + POINT GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const; + + // Get monitor at point (helper) + HMONITOR GetMonitorFromPoint(const POINT& pt) const; + + // Get monitor rectangle (helper) + bool GetMonitorRect(HMONITOR monitor, RECT& rect) const; + + // Get outer edges collection (for debugging) + const std::vector& GetOuterEdges() const { return m_outerEdges; } + + // Detect gaps between monitors that should be snapped together + struct GapInfo { + int monitor1Index; + int monitor2Index; + int horizontalGap; + int verticalOverlap; + }; + std::vector DetectMonitorGaps() const; + +private: + std::vector m_monitors; + std::vector m_outerEdges; + + // Map from (monitor index, edge type) to edge info + // Using monitor index instead of HMONITOR because HMONITOR handles can change + // when monitors are added/removed dynamically + std::map, MonitorEdge> m_edgeMap; + + // Helper to resolve HMONITOR to monitor index at runtime + int GetMonitorIndex(HMONITOR monitor) const; + + // Helper to get consistent HMONITOR from RECT + HMONITOR GetMonitorFromRect(const RECT& rect) const; + + void BuildEdgeMap(); + void IdentifyOuterEdges(); + + // Check if two edges are adjacent (within tolerance) + bool EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance = 50) const; + + // Find the opposite outer edge for wrapping + MonitorEdge FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const; + + // Calculate relative position along an edge (0.0 to 1.0) + double GetRelativePosition(const MonitorEdge& edge, int coordinate) const; + + // Convert relative position to absolute coordinate on target edge + int GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const; +}; diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp index ee026b7b12..08c39bab60 100644 --- a/src/modules/MouseUtils/CursorWrap/dllmain.cpp +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -1,3 +1,7 @@ +// 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. + #include "pch.h" #include "../../../interface/powertoy_module_interface.h" #include "../../../common/SettingsAPI/settings_objects.h" @@ -14,8 +18,10 @@ #include #include #include +#include +#include #include "resource.h" -#include "CursorWrapTests.h" +#include "CursorWrapCore.h" // Disable C26451 arithmetic overflow warning for this file since the operations are safe in this context #pragma warning(disable: 26451) @@ -47,6 +53,7 @@ namespace const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut"; const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag"; + const wchar_t JSON_KEY_WRAP_MODE[] = L"wrap_mode"; } // The PowerToy name that will be shown in the settings. @@ -54,34 +61,10 @@ const static wchar_t* MODULE_NAME = L"CursorWrap"; // Add a description that will we shown in the module settings page. const static wchar_t* MODULE_DESC = L""; -// Mouse hook data structure -struct MonitorInfo -{ - RECT rect; - bool isPrimary; - int monitorId; // Add monitor ID for easier debugging -}; - -// Add structure for logical monitor grid position -struct LogicalPosition -{ - int row; - int col; - bool isValid; -}; - -// Add monitor topology helper -struct MonitorTopology -{ - std::vector> grid; // 3x3 grid of monitors - std::map monitorToPosition; - std::map, HMONITOR> positionToMonitor; - - void Initialize(const std::vector& monitors); - LogicalPosition GetPosition(HMONITOR monitor) const; - HMONITOR GetMonitorAt(int row, int col) const; - HMONITOR FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const; -}; +// Monitor device interface GUID for RegisterDeviceNotification +// {e6f07b5f-ee97-4a90-b076-33f57bf4eaa7} +static const GUID GUID_DEVINTERFACE_MONITOR = + { 0xe6f07b5f, 0xee97, 0x4a90, { 0xb0, 0x76, 0x33, 0xf5, 0x7b, 0xf4, 0xea, 0xa7 } }; // Forward declaration class CursorWrap; @@ -97,14 +80,14 @@ private: bool m_enabled = false; bool m_autoActivate = false; bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag + int m_wrapMode = 0; // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly // Mouse hook HHOOK m_mouseHook = nullptr; std::atomic m_hookActive{ false }; - // Monitor information - std::vector m_monitors; - MonitorTopology m_topology; + // Core wrapping engine (edge-based polygon model) + CursorWrapCore m_core; // Hotkey Hotkey m_activationHotkey{}; @@ -115,13 +98,19 @@ private: std::thread m_eventThread; std::atomic_bool m_listening{ false }; + // Display change notification + HWND m_messageWindow = nullptr; + HDEVNOTIFY m_deviceNotify = nullptr; + static constexpr UINT_PTR TIMER_UPDATE_MONITORS = 1; + static constexpr UINT DEBOUNCE_DELAY_MS = 500; + public: // Constructor CursorWrap() { LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::cursorWrapLoggerName); init_settings(); - UpdateMonitorInfo(); + m_core.UpdateMonitorInfo(); g_cursorWrapInstance = this; // Set global instance pointer }; @@ -218,6 +207,9 @@ public: MSG msg; PeekMessage(&msg, nullptr, WM_USER, WM_USER, PM_NOREMOVE); + // Create message window for display change notifications + RegisterForDisplayChanges(); + StartMouseHook(); Logger::info("CursorWrap enabled - mouse hook started"); @@ -247,6 +239,9 @@ public: } } + // Cleanup display change notifications + UnregisterDisplayChanges(); + StopMouseHook(); Logger::info("CursorWrap event listener stopped"); }); @@ -318,7 +313,17 @@ public: return false; } - private: + // Called when display configuration changes - update monitor topology + void OnDisplayChange() + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Display configuration changed, updating monitor topology\n"); +#endif + Logger::info("Display configuration changed, updating monitor topology"); + m_core.UpdateMonitorInfo(); + } + +private: void ToggleMouseHook() { // Toggle cursor wrapping. @@ -329,10 +334,6 @@ public: else { StartMouseHook(); -#ifdef _DEBUG - // Run comprehensive tests when hook is started in debug builds - RunComprehensiveTests(); -#endif } } @@ -399,6 +400,21 @@ public: { Logger::warn("Failed to initialize CursorWrap disable wrap during drag from settings. Will use default value (true)"); } + + try + { + // Parse wrap mode + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_WRAP_MODE)) + { + auto wrapModeObject = propertiesObject.GetNamedObject(JSON_KEY_WRAP_MODE); + m_wrapMode = static_cast(wrapModeObject.GetNamedNumber(JSON_KEY_VALUE)); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap wrap mode from settings. Will use default value (0=Both)"); + } } else { @@ -416,31 +432,6 @@ public: } } - void UpdateMonitorInfo() - { - m_monitors.clear(); - - EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL { - auto* self = reinterpret_cast(lParam); - - MONITORINFO mi{}; - mi.cbSize = sizeof(MONITORINFO); - if (GetMonitorInfo(hMonitor, &mi)) - { - MonitorInfo info{}; - info.rect = mi.rcMonitor; - info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0; - info.monitorId = static_cast(self->m_monitors.size()); - self->m_monitors.push_back(info); - } - - return TRUE; - }, reinterpret_cast(this)); - - // Initialize monitor topology - m_topology.Initialize(m_monitors); - } - void StartMouseHook() { if (m_mouseHook || m_hookActive) @@ -449,7 +440,8 @@ public: return; } - UpdateMonitorInfo(); + // Refresh monitor info before starting hook + m_core.UpdateMonitorInfo(); m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(nullptr), 0); if (m_mouseHook) @@ -481,6 +473,167 @@ public: } } + void RegisterForDisplayChanges() + { + if (m_messageWindow) + { + return; // Already registered + } + + // Create a hidden top-level window to receive broadcast messages + // NOTE: Message-only windows (HWND_MESSAGE parent) do NOT receive + // WM_DISPLAYCHANGE, WM_SETTINGCHANGE, or WM_DEVICECHANGE broadcasts. + // We must use a real (hidden) top-level window instead. + WNDCLASSEXW wc = { sizeof(WNDCLASSEXW) }; + wc.lpfnWndProc = MessageWindowProc; + wc.hInstance = GetModuleHandle(nullptr); + wc.lpszClassName = L"CursorWrapDisplayChangeWindow"; + + RegisterClassExW(&wc); + + // Create a hidden top-level window (not message-only) + // WS_EX_TOOLWINDOW prevents taskbar button, WS_POPUP with no size makes it invisible + m_messageWindow = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + L"CursorWrapDisplayChangeWindow", + nullptr, + WS_POPUP, // Minimal window style + 0, 0, 0, 0, // Zero size = invisible + nullptr, // No parent - top-level window to receive broadcasts + nullptr, + GetModuleHandle(nullptr), + nullptr); + + if (m_messageWindow) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Registered for display change notifications\n"); +#endif + Logger::info("Registered for display change notifications"); + + // Register for device notifications (monitor hardware add/remove) + DEV_BROADCAST_DEVICEINTERFACE filter = {}; + filter.dbcc_size = sizeof(DEV_BROADCAST_DEVICEINTERFACE); + filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE; + filter.dbcc_classguid = GUID_DEVINTERFACE_MONITOR; + + m_deviceNotify = RegisterDeviceNotificationW( + m_messageWindow, + &filter, + DEVICE_NOTIFY_WINDOW_HANDLE); + + if (m_deviceNotify) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Registered for device notifications (monitor hardware changes)\n"); +#endif + Logger::info("Registered for device notifications (monitor hardware changes)"); + } + else + { + DWORD error = GetLastError(); +#ifdef _DEBUG + std::wostringstream oss; + oss << L"[CursorWrap] Failed to register device notifications. Error: " << error << L"\n"; + OutputDebugStringW(oss.str().c_str()); +#endif + Logger::warn("Failed to register device notifications. Error: {}", error); + } + } + else + { + DWORD error = GetLastError(); + Logger::error(L"Failed to create message window for display changes, error: {}", error); + } + } + + void UnregisterDisplayChanges() + { + if (m_deviceNotify) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Unregistering device notifications...\n"); +#endif + UnregisterDeviceNotification(m_deviceNotify); + m_deviceNotify = nullptr; + Logger::info("Unregistered device notifications"); + } + + if (m_messageWindow) + { + KillTimer(m_messageWindow, TIMER_UPDATE_MONITORS); + DestroyWindow(m_messageWindow); + m_messageWindow = nullptr; + UnregisterClassW(L"CursorWrapDisplayChangeWindow", GetModuleHandle(nullptr)); +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Unregistered display change notifications\n"); +#endif + Logger::info("Unregistered display change notifications"); + } + } + + static LRESULT CALLBACK MessageWindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) + { + if (!g_cursorWrapInstance) + { + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + + switch (msg) + { + case WM_DISPLAYCHANGE: +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] WM_DISPLAYCHANGE received - monitor resolution/DPI changed\n"); +#endif + Logger::info("WM_DISPLAYCHANGE received - resolution/DPI changed"); + // Debounce: Wait for multiple changes to settle + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + SetTimer(hwnd, TIMER_UPDATE_MONITORS, DEBOUNCE_DELAY_MS, nullptr); + break; + + case WM_SETTINGCHANGE: + if (wParam == SPI_SETWORKAREA) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] WM_SETTINGCHANGE (SPI_SETWORKAREA) received - taskbar changed\n"); +#endif + Logger::info("WM_SETTINGCHANGE (SPI_SETWORKAREA) received"); + // Taskbar position/size changed + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + SetTimer(hwnd, TIMER_UPDATE_MONITORS, DEBOUNCE_DELAY_MS, nullptr); + } + break; + + case WM_DEVICECHANGE: + // Handle monitor hardware add/remove + if (wParam == DBT_DEVNODES_CHANGED) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] DBT_DEVNODES_CHANGED received - monitor hardware change detected\n"); +#endif + Logger::info("DBT_DEVNODES_CHANGED received - monitor hardware change detected"); + // Debounce: Wait for multiple changes to settle + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + SetTimer(hwnd, TIMER_UPDATE_MONITORS, DEBOUNCE_DELAY_MS, nullptr); + return TRUE; + } + break; + + case WM_TIMER: + if (wParam == TIMER_UPDATE_MONITORS) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Debounce timer expired - triggering topology update\n"); +#endif + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + g_cursorWrapInstance->OnDisplayChange(); + } + break; + } + + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode >= 0 && wParam == WM_MOUSEMOVE) @@ -490,7 +643,11 @@ public: if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) { - POINT newPos = g_cursorWrapInstance->HandleMouseMove(currentPos); + POINT newPos = g_cursorWrapInstance->m_core.HandleMouseMove( + currentPos, + g_cursorWrapInstance->m_disableWrapDuringDrag, + g_cursorWrapInstance->m_wrapMode); + if (newPos.x != currentPos.x || newPos.y != currentPos.y) { #ifdef _DEBUG @@ -505,765 +662,8 @@ public: return CallNextHookEx(nullptr, nCode, wParam, lParam); } - - // Helper method to check if there's a monitor adjacent in coordinate space (not grid) - bool HasAdjacentMonitorInCoordinateSpace(const RECT& currentMonitorRect, int direction) - { - // direction: 0=left, 1=right, 2=top, 3=bottom - const int tolerance = 50; // Allow small gaps - - for (const auto& monitor : m_monitors) - { - bool isAdjacent = false; - - switch (direction) - { - case 0: // Left - check if another monitor's right edge touches/overlaps our left edge - isAdjacent = (abs(monitor.rect.right - currentMonitorRect.left) <= tolerance) && - (monitor.rect.bottom > currentMonitorRect.top + tolerance) && - (monitor.rect.top < currentMonitorRect.bottom - tolerance); - break; - - case 1: // Right - check if another monitor's left edge touches/overlaps our right edge - isAdjacent = (abs(monitor.rect.left - currentMonitorRect.right) <= tolerance) && - (monitor.rect.bottom > currentMonitorRect.top + tolerance) && - (monitor.rect.top < currentMonitorRect.bottom - tolerance); - break; - - case 2: // Top - check if another monitor's bottom edge touches/overlaps our top edge - isAdjacent = (abs(monitor.rect.bottom - currentMonitorRect.top) <= tolerance) && - (monitor.rect.right > currentMonitorRect.left + tolerance) && - (monitor.rect.left < currentMonitorRect.right - tolerance); - break; - - case 3: // Bottom - check if another monitor's top edge touches/overlaps our bottom edge - isAdjacent = (abs(monitor.rect.top - currentMonitorRect.bottom) <= tolerance) && - (monitor.rect.right > currentMonitorRect.left + tolerance) && - (monitor.rect.left < currentMonitorRect.right - tolerance); - break; - } - - if (isAdjacent) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Found adjacent monitor in coordinate space (direction {})", direction); -#endif - return true; - } - } - - return false; - } - - // *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC *** - // Implements vertical scrolling to bottom/top of vertical stack as requested - // Only wraps when there's NO adjacent monitor in the coordinate space - POINT HandleMouseMove(const POINT& currentPos) - { - POINT newPos = currentPos; - - // Check if we should skip wrapping during drag if the setting is enabled - if (m_disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Left mouse button is down and disable_wrap_during_drag is enabled - skipping wrap"); -#endif - return currentPos; // Return unchanged position (no wrapping) - } - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE START ======="); - Logger::info(L"CursorWrap DEBUG: Input position ({}, {})", currentPos.x, currentPos.y); -#endif - - // Find which monitor the cursor is currently on - HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); - MONITORINFO currentMonitorInfo{}; - currentMonitorInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(currentMonitor, ¤tMonitorInfo); - - LogicalPosition currentLogicalPos = m_topology.GetPosition(currentMonitor); - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Current monitor bounds: Left={}, Top={}, Right={}, Bottom={}", - currentMonitorInfo.rcMonitor.left, currentMonitorInfo.rcMonitor.top, - currentMonitorInfo.rcMonitor.right, currentMonitorInfo.rcMonitor.bottom); - Logger::info(L"CursorWrap DEBUG: Logical position: Row={}, Col={}, Valid={}", - currentLogicalPos.row, currentLogicalPos.col, currentLogicalPos.isValid); -#endif - - bool wrapped = false; - - // *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING *** - // Move to bottom of vertical stack when hitting top edge - // Only wrap if there's NO adjacent monitor in the coordinate space - if (currentPos.y <= currentMonitorInfo.rcMonitor.top) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor above in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 2)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists above (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the bottom-most monitor in the vertical stack (same column) - HMONITOR bottomMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search down from current position to find the bottom-most monitor in same column - for (int row = 2; row >= 0; row--) { // Start from bottom and work up - HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); - if (candidateMonitor) { - bottomMonitor = candidateMonitor; - break; // Found the bottom-most monitor - } - } - } - - if (bottomMonitor && bottomMonitor != currentMonitor) { - // *** MOVE TO BOTTOM OF VERTICAL STACK *** - MONITORINFO bottomInfo{}; - bottomInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(bottomMonitor, &bottomInfo); - - // Calculate relative X position to maintain cursor X alignment - double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / - (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); - - int targetWidth = bottomInfo.rcMonitor.right - bottomInfo.rcMonitor.left; - newPos.x = bottomInfo.rcMonitor.left + static_cast(relativeX * targetWidth); - newPos.y = bottomInfo.rcMonitor.bottom - 1; // Bottom edge of bottom monitor - - // Clamp X to target monitor bounds - newPos.x = max(bottomInfo.rcMonitor.left, min(newPos.x, bottomInfo.rcMonitor.right - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to bottom of vertical stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.y = currentMonitorInfo.rcMonitor.bottom - 1; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - else if (currentPos.y >= currentMonitorInfo.rcMonitor.bottom - 1) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor below in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 3)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists below (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the top-most monitor in the vertical stack (same column) - HMONITOR topMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search up from current position to find the top-most monitor in same column - for (int row = 0; row <= 2; row++) { // Start from top and work down - HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); - if (candidateMonitor) { - topMonitor = candidateMonitor; - break; // Found the top-most monitor - } - } - } - - if (topMonitor && topMonitor != currentMonitor) { - // *** MOVE TO TOP OF VERTICAL STACK *** - MONITORINFO topInfo{}; - topInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(topMonitor, &topInfo); - - // Calculate relative X position to maintain cursor X alignment - double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / - (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); - - int targetWidth = topInfo.rcMonitor.right - topInfo.rcMonitor.left; - newPos.x = topInfo.rcMonitor.left + static_cast(relativeX * targetWidth); - newPos.y = topInfo.rcMonitor.top; // Top edge of top monitor - - // Clamp X to target monitor bounds - newPos.x = max(topInfo.rcMonitor.left, min(newPos.x, topInfo.rcMonitor.right - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to top of vertical stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.y = currentMonitorInfo.rcMonitor.top; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - - // *** FIXED HORIZONTAL WRAPPING LOGIC *** - // Move to opposite end of horizontal stack when hitting left/right edge - // Only wrap if there's NO adjacent monitor in the coordinate space (let Windows handle natural transitions) - if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor to the left in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 0)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the left (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the right-most monitor in the horizontal stack (same row) - HMONITOR rightMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search right from current position to find the right-most monitor in same row - for (int col = 2; col >= 0; col--) { // Start from right and work left - HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); - if (candidateMonitor) { - rightMonitor = candidateMonitor; - break; // Found the right-most monitor - } - } - } - - if (rightMonitor && rightMonitor != currentMonitor) { - // *** MOVE TO RIGHT END OF HORIZONTAL STACK *** - MONITORINFO rightInfo{}; - rightInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(rightMonitor, &rightInfo); - - // Calculate relative Y position to maintain cursor Y alignment - double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / - (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); - - int targetHeight = rightInfo.rcMonitor.bottom - rightInfo.rcMonitor.top; - newPos.y = rightInfo.rcMonitor.top + static_cast(relativeY * targetHeight); - newPos.x = rightInfo.rcMonitor.right - 1; // Right edge of right monitor - - // Clamp Y to target monitor bounds - newPos.y = max(rightInfo.rcMonitor.top, min(newPos.y, rightInfo.rcMonitor.bottom - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to right end of horizontal stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.x = currentMonitorInfo.rcMonitor.right - 1; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - else if (!wrapped && currentPos.x >= currentMonitorInfo.rcMonitor.right - 1) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor to the right in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 1)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the right (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the left-most monitor in the horizontal stack (same row) - HMONITOR leftMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search left from current position to find the left-most monitor in same row - for (int col = 0; col <= 2; col++) { // Start from left and work right - HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); - if (candidateMonitor) { - leftMonitor = candidateMonitor; - break; // Found the left-most monitor - } - } - } - - if (leftMonitor && leftMonitor != currentMonitor) { - // *** MOVE TO LEFT END OF HORIZONTAL STACK *** - MONITORINFO leftInfo{}; - leftInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(leftMonitor, &leftInfo); - - // Calculate relative Y position to maintain cursor Y alignment - double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / - (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); - - int targetHeight = leftInfo.rcMonitor.bottom - leftInfo.rcMonitor.top; - newPos.y = leftInfo.rcMonitor.top + static_cast(relativeY * targetHeight); - newPos.x = leftInfo.rcMonitor.left; // Left edge of left monitor - - // Clamp Y to target monitor bounds - newPos.y = max(leftInfo.rcMonitor.top, min(newPos.y, leftInfo.rcMonitor.bottom - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to left end of horizontal stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.x = currentMonitorInfo.rcMonitor.left; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - -#ifdef _DEBUG - if (wrapped) - { - Logger::info(L"CursorWrap DEBUG: ======= WRAP RESULT ======="); - Logger::info(L"CursorWrap DEBUG: Original: ({}, {}) -> New: ({}, {})", - currentPos.x, currentPos.y, newPos.x, newPos.y); - } - else - { - Logger::info(L"CursorWrap DEBUG: No wrapping performed - cursor not at edge"); - } - Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE END ======="); -#endif - - return newPos; - } - - // Add test method for monitor topology validation - void RunMonitorTopologyTests() - { -#ifdef _DEBUG - Logger::info(L"CursorWrap: Running monitor topology tests..."); - - // Test all 9 possible monitor positions in 3x3 grid - const char* gridNames[3][3] = { - {"TL", "TC", "TR"}, // Top-Left, Top-Center, Top-Right - {"ML", "MC", "MR"}, // Middle-Left, Middle-Center, Middle-Right - {"BL", "BC", "BR"} // Bottom-Left, Bottom-Center, Bottom-Right - }; - - for (int row = 0; row < 3; row++) - { - for (int col = 0; col < 3; col++) - { - HMONITOR monitor = m_topology.GetMonitorAt(row, col); - if (monitor) - { - std::string gridName(gridNames[row][col]); - std::wstring wGridName(gridName.begin(), gridName.end()); - Logger::info(L"CursorWrap TEST: Monitor at [{}][{}] ({}) exists", - row, col, wGridName.c_str()); - - // Test adjacent monitor finding - HMONITOR up = m_topology.FindAdjacentMonitor(monitor, -1, 0); - HMONITOR down = m_topology.FindAdjacentMonitor(monitor, 1, 0); - HMONITOR left = m_topology.FindAdjacentMonitor(monitor, 0, -1); - HMONITOR right = m_topology.FindAdjacentMonitor(monitor, 0, 1); - - Logger::info(L"CursorWrap TEST: Adjacent monitors - Up: {}, Down: {}, Left: {}, Right: {}", - up ? L"YES" : L"NO", down ? L"YES" : L"NO", - left ? L"YES" : L"NO", right ? L"YES" : L"NO"); - } - } - } - - Logger::info(L"CursorWrap: Monitor topology tests completed."); -#endif - } - - // Add method to trigger test suite (can be called via hotkey in debug builds) - void RunComprehensiveTests() - { -#ifdef _DEBUG - RunMonitorTopologyTests(); - - // Test cursor wrapping scenarios - Logger::info(L"CursorWrap: Testing cursor wrapping scenarios..."); - - // Simulate cursor positions at each monitor edge and verify expected behavior - for (const auto& monitor : m_monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - LogicalPosition pos = m_topology.GetPosition(hMonitor); - - if (pos.isValid) - { - Logger::info(L"CursorWrap TEST: Testing monitor at position [{}][{}]", pos.row, pos.col); - - // Test top edge - POINT topEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.top}; - POINT newPos = HandleMouseMove(topEdge); - Logger::info(L"CursorWrap TEST: Top edge ({}, {}) -> ({}, {})", - topEdge.x, topEdge.y, newPos.x, newPos.y); - - // Test bottom edge - POINT bottomEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.bottom - 1}; - newPos = HandleMouseMove(bottomEdge); - Logger::info(L"CursorWrap TEST: Bottom edge ({}, {}) -> ({}, {})", - bottomEdge.x, bottomEdge.y, newPos.x, newPos.y); - - // Test left edge - POINT leftEdge = {monitor.rect.left, (monitor.rect.top + monitor.rect.bottom) / 2}; - newPos = HandleMouseMove(leftEdge); - Logger::info(L"CursorWrap TEST: Left edge ({}, {}) -> ({}, {})", - leftEdge.x, leftEdge.y, newPos.x, newPos.y); - - // Test right edge - POINT rightEdge = {monitor.rect.right - 1, (monitor.rect.top + monitor.rect.bottom) / 2}; - newPos = HandleMouseMove(rightEdge); - Logger::info(L"CursorWrap TEST: Right edge ({}, {}) -> ({}, {})", - rightEdge.x, rightEdge.y, newPos.x, newPos.y); - } - } - - Logger::info(L"CursorWrap: Comprehensive tests completed."); -#endif - } }; -// Implementation of MonitorTopology methods -void MonitorTopology::Initialize(const std::vector& monitors) -{ - // Clear existing data - grid.assign(3, std::vector(3, nullptr)); - monitorToPosition.clear(); - positionToMonitor.clear(); - - if (monitors.empty()) return; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION START ======="); - Logger::info(L"CursorWrap DEBUG: Initializing topology for {} monitors", monitors.size()); - for (const auto& monitor : monitors) - { - Logger::info(L"CursorWrap DEBUG: Monitor {}: bounds=({},{},{},{}), isPrimary={}", - monitor.monitorId, monitor.rect.left, monitor.rect.top, - monitor.rect.right, monitor.rect.bottom, monitor.isPrimary); - } -#endif - - // Special handling for 2 monitors - use physical position, not discovery order - if (monitors.size() == 2) - { - // Determine if arrangement is horizontal or vertical by comparing centers - POINT center0 = {(monitors[0].rect.left + monitors[0].rect.right) / 2, - (monitors[0].rect.top + monitors[0].rect.bottom) / 2}; - POINT center1 = {(monitors[1].rect.left + monitors[1].rect.right) / 2, - (monitors[1].rect.top + monitors[1].rect.bottom) / 2}; - - int xDiff = abs(center0.x - center1.x); - int yDiff = abs(center0.y - center1.y); - - bool isHorizontal = xDiff > yDiff; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor centers: M0=({}, {}), M1=({}, {})", - center0.x, center0.y, center1.x, center1.y); - Logger::info(L"CursorWrap DEBUG: Differences: X={}, Y={}, IsHorizontal={}", - xDiff, yDiff, isHorizontal); -#endif - - if (isHorizontal) - { - // Horizontal arrangement - place in middle row [1,0] and [1,2] - for (const auto& monitor : monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - POINT center = {(monitor.rect.left + monitor.rect.right) / 2, - (monitor.rect.top + monitor.rect.bottom) / 2}; - - int row = 1; // Middle row - int col = (center.x < (center0.x + center1.x) / 2) ? 0 : 2; // Left or right based on center - - grid[row][col] = hMonitor; - monitorToPosition[hMonitor] = {row, col, true}; - positionToMonitor[{row, col}] = hMonitor; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor {} (horizontal) placed at grid[{}][{}]", - monitor.monitorId, row, col); -#endif - } - } - else - { - // *** VERTICAL ARRANGEMENT - CRITICAL LOGIC *** - // Sort monitors by Y coordinate to determine vertical order - std::vector> sortedMonitors; - for (int i = 0; i < 2; i++) { - sortedMonitors.push_back({i, monitors[i]}); - } - - // Sort by Y coordinate (top to bottom) - std::sort(sortedMonitors.begin(), sortedMonitors.end(), - [](const std::pair& a, const std::pair& b) { - int centerA = (a.second.rect.top + a.second.rect.bottom) / 2; - int centerB = (b.second.rect.top + b.second.rect.bottom) / 2; - return centerA < centerB; // Top first - }); - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL ARRANGEMENT DETECTED"); - Logger::info(L"CursorWrap DEBUG: Top monitor: ID={}, Y-center={}", - sortedMonitors[0].second.monitorId, - (sortedMonitors[0].second.rect.top + sortedMonitors[0].second.rect.bottom) / 2); - Logger::info(L"CursorWrap DEBUG: Bottom monitor: ID={}, Y-center={}", - sortedMonitors[1].second.monitorId, - (sortedMonitors[1].second.rect.top + sortedMonitors[1].second.rect.bottom) / 2); -#endif - - // Place monitors in grid based on sorted order - for (int i = 0; i < 2; i++) { - const auto& monitorPair = sortedMonitors[i]; - const auto& monitor = monitorPair.second; - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - - int col = 1; // Middle column for vertical arrangement - int row = (i == 0) ? 0 : 2; // Top monitor at row 0, bottom at row 2 - - grid[row][col] = hMonitor; - monitorToPosition[hMonitor] = {row, col, true}; - positionToMonitor[{row, col}] = hMonitor; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor {} (vertical) placed at grid[{}][{}] - {} position", - monitor.monitorId, row, col, (i == 0) ? L"TOP" : L"BOTTOM"); -#endif - } - } - } - else - { - // For more than 2 monitors, use edge-based alignment algorithm - // This ensures monitors with aligned edges (e.g., top edges at same Y) are grouped in same row - - // Helper lambda to check if two ranges overlap or are adjacent (with tolerance) - auto rangesOverlapOrTouch = [](int start1, int end1, int start2, int end2, int tolerance = 50) -> bool { - // Check if ranges overlap or are within tolerance distance - return (start1 <= end2 + tolerance) && (start2 <= end1 + tolerance); - }; - - // Sort monitors by horizontal position (left edge) for column assignment - std::vector monitorsByX; - for (const auto& monitor : monitors) { - monitorsByX.push_back(&monitor); - } - std::sort(monitorsByX.begin(), monitorsByX.end(), [](const MonitorInfo* a, const MonitorInfo* b) { - return a->rect.left < b->rect.left; - }); - - // Sort monitors by vertical position (top edge) for row assignment - std::vector monitorsByY; - for (const auto& monitor : monitors) { - monitorsByY.push_back(&monitor); - } - std::sort(monitorsByY.begin(), monitorsByY.end(), [](const MonitorInfo* a, const MonitorInfo* b) { - return a->rect.top < b->rect.top; - }); - - // Assign rows based on vertical overlap - monitors that overlap vertically should be in same row - std::map monitorToRow; - int currentRow = 0; - - for (size_t i = 0; i < monitorsByY.size(); i++) { - const auto* monitor = monitorsByY[i]; - - // Check if this monitor overlaps vertically with any monitor already assigned to current row - bool foundOverlap = false; - for (size_t j = 0; j < i; j++) { - const auto* other = monitorsByY[j]; - if (monitorToRow[other] == currentRow) { - // Check vertical overlap - if (rangesOverlapOrTouch(monitor->rect.top, monitor->rect.bottom, - other->rect.top, other->rect.bottom)) { - monitorToRow[monitor] = currentRow; - foundOverlap = true; - break; - } - } - } - - if (!foundOverlap) { - // Start new row if no overlap found and we have room - if (currentRow < 2 && i < monitorsByY.size() - 1) { - currentRow++; - } - monitorToRow[monitor] = currentRow; - } - } - - // Assign columns based on horizontal position (left-to-right order) - // Monitors are already sorted by X coordinate (left edge) - std::map monitorToCol; - - // For horizontal arrangement, distribute monitors evenly across columns - if (monitorsByX.size() == 1) { - // Single monitor - place in middle column - monitorToCol[monitorsByX[0]] = 1; - } - else if (monitorsByX.size() == 2) { - // Two monitors - place at opposite ends for wrapping - monitorToCol[monitorsByX[0]] = 0; // Leftmost monitor - monitorToCol[monitorsByX[1]] = 2; // Rightmost monitor - } - else { - // Three or more monitors - distribute across grid - for (size_t i = 0; i < monitorsByX.size() && i < 3; i++) { - monitorToCol[monitorsByX[i]] = static_cast(i); - } - // If more than 3 monitors, place extras in rightmost column - for (size_t i = 3; i < monitorsByX.size(); i++) { - monitorToCol[monitorsByX[i]] = 2; - } - } - - // Place monitors in grid using the computed row/column assignments - for (const auto& monitor : monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - int row = monitorToRow[&monitor]; - int col = monitorToCol[&monitor]; - - grid[row][col] = hMonitor; - monitorToPosition[hMonitor] = {row, col, true}; - positionToMonitor[{row, col}] = hMonitor; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}] (left={}, top={}, right={}, bottom={})", - monitor.monitorId, row, col, - monitor.rect.left, monitor.rect.top, monitor.rect.right, monitor.rect.bottom); -#endif - } - } - -#ifdef _DEBUG - // *** CRITICAL: Print topology map using OutputDebugString for debug builds *** - Logger::info(L"CursorWrap DEBUG: ======= FINAL TOPOLOGY MAP ======="); - OutputDebugStringA("CursorWrap TOPOLOGY MAP:\n"); - for (int r = 0; r < 3; r++) - { - std::string rowStr = " "; - for (int c = 0; c < 3; c++) - { - if (grid[r][c]) - { - // Find monitor ID for this handle - int monitorId = -1; - for (const auto& monitor : monitors) - { - HMONITOR handle = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - if (handle == grid[r][c]) - { - monitorId = monitor.monitorId + 1; // Convert to 1-based for display - break; - } - } - rowStr += std::to_string(monitorId) + " "; - } - else - { - rowStr += ". "; - } - } - rowStr += "\n"; - OutputDebugStringA(rowStr.c_str()); - - // Also log to PowerToys logger - std::wstring wRowStr(rowStr.begin(), rowStr.end()); - Logger::info(wRowStr.c_str()); - } - OutputDebugStringA("======= END TOPOLOGY MAP =======\n"); - - // Additional validation logging - Logger::info(L"CursorWrap DEBUG: ======= GRID POSITION VALIDATION ======="); - for (const auto& monitor : monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - LogicalPosition pos = GetPosition(hMonitor); - if (pos.isValid) - { - Logger::info(L"CursorWrap DEBUG: Monitor {} -> grid[{}][{}]", monitor.monitorId, pos.row, pos.col); - OutputDebugStringA(("Monitor " + std::to_string(monitor.monitorId) + " -> grid[" + std::to_string(pos.row) + "][" + std::to_string(pos.col) + "]\n").c_str()); - - // Test adjacent finding - HMONITOR up = FindAdjacentMonitor(hMonitor, -1, 0); - HMONITOR down = FindAdjacentMonitor(hMonitor, 1, 0); - HMONITOR left = FindAdjacentMonitor(hMonitor, 0, -1); - HMONITOR right = FindAdjacentMonitor(hMonitor, 0, 1); - - Logger::info(L"CursorWrap DEBUG: Monitor {} adjacents - Up: {}, Down: {}, Left: {}, Right: {}", - monitor.monitorId, up ? L"YES" : L"NO", down ? L"YES" : L"NO", - left ? L"YES" : L"NO", right ? L"YES" : L"NO"); - } - } - Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION COMPLETE ======="); -#endif -} - -LogicalPosition MonitorTopology::GetPosition(HMONITOR monitor) const -{ - auto it = monitorToPosition.find(monitor); - if (it != monitorToPosition.end()) - { - return it->second; - } - return {-1, -1, false}; -} - -HMONITOR MonitorTopology::GetMonitorAt(int row, int col) const -{ - if (row >= 0 && row < 3 && col >= 0 && col < 3) - { - return grid[row][col]; - } - return nullptr; -} - -HMONITOR MonitorTopology::FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const -{ - LogicalPosition currentPos = GetPosition(current); - if (!currentPos.isValid) return nullptr; - - int newRow = currentPos.row + deltaRow; - int newCol = currentPos.col + deltaCol; - - return GetMonitorAt(newRow, newCol); -} - extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() { return new CursorWrap(); diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs index cf66b4ba09..bffa75a3f3 100644 --- a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -22,11 +22,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("disable_wrap_during_drag")] public BoolProperty DisableWrapDuringDrag { get; set; } + [JsonPropertyName("wrap_mode")] + public IntProperty WrapMode { get; set; } + public CursorWrapProperties() { ActivationShortcut = DefaultActivationShortcut; AutoActivate = new BoolProperty(false); DisableWrapDuringDrag = new BoolProperty(true); + WrapMode = new IntProperty(0); // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly } } } diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs index 8c9059123c..0ee6c4a523 100644 --- a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -47,7 +47,16 @@ namespace Microsoft.PowerToys.Settings.UI.Library // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { - return false; + bool settingsUpgraded = false; + + // Add WrapMode property if it doesn't exist (for users upgrading from older versions) + if (Properties.WrapMode == null) + { + Properties.WrapMode = new IntProperty(0); // Default to Both + settingsUpgraded = true; + } + + return settingsUpgraded; } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index 567c4246eb..39c3800f93 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -47,6 +47,13 @@ + + + + + + + diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index de1bf99919..72eac551ff 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2728,6 +2728,18 @@ From there, simply click on one of the supported files in the File Explorer and Automatically activate on utility startup + + Wrap mode + + + Vertical only + + + Horizontal only + + + Vertical and horizontal + Mouse Pointer Crosshairs Mouse as in the hardware peripheral. diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs index 0c3eb06649..11045e0108 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs @@ -113,6 +113,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // Null-safe access in case property wasn't upgraded yet - default to TRUE _cursorWrapDisableWrapDuringDrag = CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag?.Value ?? true; + // Null-safe access in case property wasn't upgraded yet - default to 0 (Both) + _cursorWrapWrapMode = CursorWrapSettingsConfig.Properties.WrapMode?.Value ?? 0; + int isEnabled = 0; Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); @@ -1083,6 +1086,34 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public int CursorWrapWrapMode + { + get + { + return _cursorWrapWrapMode; + } + + set + { + if (value != _cursorWrapWrapMode) + { + _cursorWrapWrapMode = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.WrapMode == null) + { + CursorWrapSettingsConfig.Properties.WrapMode = new IntProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.WrapMode.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + public void NotifyCursorWrapPropertyChanged([CallerMemberName] string propertyName = null) { OnPropertyChanged(propertyName); @@ -1154,5 +1185,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _isCursorWrapEnabled; private bool _cursorWrapAutoActivate; private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings + private int _cursorWrapWrapMode; // 0=Both, 1=VerticalOnly, 2=HorizontalOnly } } From 08715a6e46f7c926c77493866a7e875cc61379fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 27 Jan 2026 20:52:12 +0100 Subject: [PATCH 11/53] CmdPal: Allow list item context menu (#45086) ## Summary of the Pull Request This PR enables pointer-invoked context menus for list items when the list contains at least one item, including the primary item. The command bar "More" button or Ctrl+K hotkey remain unaffected - the menu is not accessible through those. image ## PR Checklist - [x] Closes: #45083 - [ ] **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 --- .../ContextMenuViewModel.cs | 2 +- .../Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs index 0263f20464..07c238ab42 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs @@ -53,7 +53,7 @@ public partial class ContextMenuViewModel : ObservableObject, { if (SelectedItem is not null) { - if (SelectedItem.MoreCommands.Count() > 1) + if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands) { ContextMenuStack.Clear(); PushContextStack(SelectedItem.AllCommands); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs index 5666381d82..133a9364f0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs @@ -44,13 +44,14 @@ public sealed partial class CommandBar : UserControl, public void Receive(OpenContextMenuMessage message) { - if (!ViewModel.ShouldShowContextMenu) - { - return; - } - if (message.Element is null) { + // This is invoked from the "More" button on the command bar + if (!ViewModel.ShouldShowContextMenu) + { + return; + } + _ = DispatcherQueue.TryEnqueue( () => { @@ -65,6 +66,7 @@ public sealed partial class CommandBar : UserControl, } else { + // This is invoked from a specific element _ = DispatcherQueue.TryEnqueue( () => { From f534e5b8e58c7c192632bec1e8b4e0dd50912d67 Mon Sep 17 00:00:00 2001 From: Kai Tao <69313318+vanzue@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:37:22 +0800 Subject: [PATCH 12/53] [ZoomIt] Show users full hotkey list in settings (#43073) ## Summary of the Pull Request This PR enhances the ZoomIt settings UI by refactoring some of the XAML code and putting instructions as part of the settingsexpanders. Additionally, the alternate hotkey combinations are now shown too and will be updated based on the configured hotkey. so that **Users will now be able to see all the hotkeys**. ## PR Checklist - [x] Closes: #42236 - [ ] **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 ### ZoomIt Extended (Derived) Hotkeys Feature | Base Key Property | Default Base Key | Derived Key Property | XOR Logic | Default Derived Key | Description -- | -- | -- | -- | -- | -- | -- LiveZoom | LiveZoomToggleKey | Ctrl+4 | LiveZoomToggleKeyDraw | XOR Shift | Ctrl+Shift+4 | Enter drawing mode in LiveZoom Record | RecordToggleKey | Ctrl+5 | RecordToggleKeyCrop | XOR Shift | Ctrl+Shift+5 | Record selected region (crop) Record | RecordToggleKey | Ctrl+5 | RecordToggleKeyWindow | XOR Alt | Ctrl+Alt+5 | Record specific window Snip | SnipToggleKey | Ctrl+6 | SnipToggleKeySave | XOR Shift | Ctrl+Shift+6 | Snip and save to file DemoType | DemoTypeToggleKey | Ctrl+7 | DemoTypeToggleKeyReset | XOR Shift | Ctrl+Shift+7 | Rewind to previous segment Frame 2018778631 --------- Signed-off-by: check-spelling-bot Co-authored-by: Niels Laute Co-authored-by: Kai Tao --- ...otkeySettingsToLocalizedStringConverter.cs | 31 ++ .../ZoomItOpacitySliderConverter.cs | 24 + .../Settings.UI/PowerToys.Settings.csproj | 3 + .../Settings.UI/SettingsXAML/App.xaml | 6 +- .../ShortcutWithTextLabelControl.xaml | 119 ++--- .../ShortcutWithTextLabelControl.xaml.cs | 49 ++- .../SettingsXAML/Views/ZoomItPage.xaml | 416 ++++++++++-------- .../Settings.UI/Strings/en-us/Resources.resw | 150 ++++--- .../Settings.UI/ViewModels/ZoomItViewModel.cs | 181 +++++++- 9 files changed, 658 insertions(+), 321 deletions(-) create mode 100644 src/settings-ui/Settings.UI/Converters/HotkeySettingsToLocalizedStringConverter.cs create mode 100644 src/settings-ui/Settings.UI/Converters/ZoomItOpacitySliderConverter.cs diff --git a/src/settings-ui/Settings.UI/Converters/HotkeySettingsToLocalizedStringConverter.cs b/src/settings-ui/Settings.UI/Converters/HotkeySettingsToLocalizedStringConverter.cs new file mode 100644 index 0000000000..c092525c75 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/HotkeySettingsToLocalizedStringConverter.cs @@ -0,0 +1,31 @@ +// 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.Windows; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Data; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class HotkeySettingsToLocalizedStringConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is HotkeySettings keySettings && parameter is string resourceKey) + { + return string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString(resourceKey), keySettings.ToString()); + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/ZoomItOpacitySliderConverter.cs b/src/settings-ui/Settings.UI/Converters/ZoomItOpacitySliderConverter.cs new file mode 100644 index 0000000000..e82211c886 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/ZoomItOpacitySliderConverter.cs @@ -0,0 +1,24 @@ +// 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 Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public sealed partial class ZoomItOpacitySliderConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + // Slider value is 1-100, display as percentage + int percentage = System.Convert.ToInt32((double)value); + return $"{percentage}%"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index dcb0ef97ba..261ca48155 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -176,6 +176,9 @@ Always + + MSBuild:Compile + MSBuild:Compile diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml index f63ccdf3a6..54b1e1a02f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml @@ -4,6 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"> @@ -18,7 +19,7 @@ - + @@ -81,9 +82,6 @@ - - - diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml index bc46c9d17e..719091a787 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml @@ -1,58 +1,67 @@ - + + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:tk="using:CommunityToolkit.WinUI" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs index c3829e3984..7e4d31c28b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs @@ -1,15 +1,15 @@ -// Copyright (c) Microsoft Corporation +// 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 CommunityToolkit.WinUI.Controls; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Controls { - public sealed partial class ShortcutWithTextLabelControl : UserControl + public sealed partial class ShortcutWithTextLabelControl : Control { public string Text { @@ -27,26 +27,47 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public static readonly DependencyProperty KeysProperty = DependencyProperty.Register(nameof(Keys), typeof(List), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); - public LabelPlacement LabelPlacement + public Placement LabelPlacement { - get { return (LabelPlacement)GetValue(LabelPlacementProperty); } + get { return (Placement)GetValue(LabelPlacementProperty); } set { SetValue(LabelPlacementProperty, value); } } - public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(LabelPlacement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: LabelPlacement.After, OnIsLabelPlacementChanged)); + public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(Placement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: Placement.After, OnIsLabelPlacementChanged)); + + public MarkdownConfig MarkdownConfig + { + get { return (MarkdownConfig)GetValue(MarkdownConfigProperty); } + set { SetValue(MarkdownConfigProperty, value); } + } + + public static readonly DependencyProperty MarkdownConfigProperty = DependencyProperty.Register(nameof(MarkdownConfig), typeof(MarkdownConfig), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(new MarkdownConfig())); + + public Style KeyVisualStyle + { + get { return (Style)GetValue(KeyVisualStyleProperty); } + set { SetValue(KeyVisualStyleProperty, value); } + } + + public static readonly DependencyProperty KeyVisualStyleProperty = DependencyProperty.Register(nameof(KeyVisualStyle), typeof(Style), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(Style))); public ShortcutWithTextLabelControl() { - this.InitializeComponent(); + DefaultStyleKey = typeof(ShortcutWithTextLabelControl); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); } private static void OnIsLabelPlacementChanged(DependencyObject d, DependencyPropertyChangedEventArgs newValue) { if (d is ShortcutWithTextLabelControl labelControl) { - if (labelControl.LabelPlacement == LabelPlacement.Before) + if (labelControl.LabelPlacement == Placement.Before) { - VisualStateManager.GoToState(labelControl, "LabelBefore", true); + VisualStateManager.GoToState(labelControl, "LabelBefore", true); } else { @@ -54,11 +75,11 @@ namespace Microsoft.PowerToys.Settings.UI.Controls } } } - } - public enum LabelPlacement - { - Before, - After, + public enum Placement + { + Before, + After, + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml index 80adbcc590..68ff588566 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml @@ -1,4 +1,4 @@ - - + + + + - - - - - - - - - - - - - - + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + + + + + + + + + + + + + + + + + + - - - + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + + + + + + + + + - - - + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + + + + + + + + + - - + + - +