From dd420509ab4568ab71e77842c7eb9f562aa33952 Mon Sep 17 00:00:00 2001 From: Mason Bergstrom <13530957+MasonBergstrom@users.noreply.github.com> Date: Wed, 22 Oct 2025 21:06:33 -0700 Subject: [PATCH 01/59] [Mouse Without Borders] Adding Horizontal Scrolling Support (#42179) ## Summary of the Pull Request Added support for horizontal scrolling to Mouse Without Borders, instead of being a no-op. ## PR Checklist - [x] Closes: #37037 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments Works in a backward compatible fashion, continuing to be a no-op when forwarded to an older version, but works once both devices are updated. ## Validation Steps Performed Built on two separate devices that are paired with each other. First tested with one device updated and one on the old code, confirming backwards compatibility support. Second tested both devices updated, confirming horizontal scroll is now working on remote device. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/MouseWithoutBorders/App/Class/Common.VK.cs | 1 + src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs | 3 +++ src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs | 1 + 3 files changed, 5 insertions(+) diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.VK.cs b/src/modules/MouseWithoutBorders/App/Class/Common.VK.cs index 79aa50c6dc..3f54a0281d 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Common.VK.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Common.VK.cs @@ -112,6 +112,7 @@ namespace MouseWithoutBorders internal const int WM_RBUTTONDBLCLK = 0x206; internal const int WM_MBUTTONDBLCLK = 0x209; internal const int WM_MOUSEWHEEL = 0x020A; + internal const int WM_MOUSEHWHEEL = 0x020E; internal const int WM_KEYDOWN = 0x100; internal const int WM_KEYUP = 0x101; diff --git a/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs b/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs index 0bbd8014ae..e735db814c 100644 --- a/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs +++ b/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs @@ -204,6 +204,9 @@ namespace MouseWithoutBorders.Class case Common.WM_MOUSEWHEEL: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.WHEEL; break; + case Common.WM_MOUSEHWHEEL: + mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.HWHEEL; + break; case Common.WM_XBUTTONUP: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.XUP; break; diff --git a/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs b/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs index 539e0267bd..831144f377 100644 --- a/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs +++ b/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs @@ -556,6 +556,7 @@ namespace MouseWithoutBorders.Class XDOWN = 0x0080, XUP = 0x0100, WHEEL = 0x0800, + HWHEEL = 0x1000, VIRTUALDESK = 0x4000, ABSOLUTE = 0x8000, } From c26dfef81b5aad59e8d7ca29bdaf078b63c29e90 Mon Sep 17 00:00:00 2001 From: Kai Tao <69313318+vanzue@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:20:40 +0800 Subject: [PATCH 02/59] Find My Mouse: Cursor should not go busy & window should not be active (#42795) ## Summary of the Pull Request Fix two issue in find my mouse: #42758 #42765 ## PR Checklist - [x] Closes: #42758 and #42765 - [ ] **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 | Problem | Before | After this fix | |----------|---------|----------------| | Mouse Loading status | html
| html
| | Current window lose focus | html
| html
| The window lose focus test: Currently after activate the find my mouse, the window lose focus, after the fix, foreground window is still the focused window, And my keystroke will directly apply in the foreground window. Maybe hard to see in the video. --- src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp index adf5075837..c94c79e178 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp @@ -269,6 +269,10 @@ LRESULT SuperSonar::BaseWndProc(UINT message, WPARAM wParam, LPARAM lParam) n case WM_NCHITTEST: return HTTRANSPARENT; + + case WM_SETCURSOR: + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + return TRUE; } if (message == WM_PRIV_SHORTCUT) @@ -535,7 +539,7 @@ void SuperSonar::StartSonar() Trace::MousePointerFocused(); // Cover the entire virtual screen. // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. - SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, 0); + SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, SWP_NOACTIVATE); m_sonarPos = ptNowhere; OnMouseTimer(); UpdateMouseSnooping(); From d64f06906cfe4b116547a3b83747c50b232eeae3 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Thu, 23 Oct 2025 11:21:01 +0100 Subject: [PATCH 03/59] Enable switching to and from MousePointerCrosshairs and Gliding Cursor (#42105) ## Summary of the Pull Request This PR enables a user to switch between Mouse Pointer Crosshairs and Gliding Cursor (or the other way round!). The primary change is to the underlying state machine that's shared between Mouse Pointer Crosshairs and Gliding Cursor, both are implemented in the same Mouse Module. ## 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 - [ ] **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 See above - this is primarily a change to the shared state machine between Mouse Pointer Crosshairs and Gliding Cursor - this change enables transition between Mouse Pointer Crosshairs and Gliding Cursor, the underlying state is reset when a user transitions from Gliding to Mouse Pointer and back again. ## Validation Steps Performed Validation on a Windows Surface Laptop 7 Pro for the following states. - Mouse Pointer Crosshairs and Gliding Cursor NOT active - enable/disable Mouse Pointer Crosshairs - Mouse Pointer Crosshairs and Gliding Cursor NOT active - enable/step states for Gliding Cursor - Activate and disable Mouse Pointer Crosshairs - Activate and step through Gliding Cursor - Mouse Pointer Crosshairs Active - Switch to Gliding Cursor - Gliding Cursor Active - Switch to Mouse Pointer Crosshairs --- .../MousePointerCrosshairs/dllmain.cpp | 98 ++++++++++++------- 1 file changed, 63 insertions(+), 35 deletions(-) diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp index fd144e807b..b460e29643 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp +++ b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp @@ -14,6 +14,9 @@ extern void InclusiveCrosshairsRequestUpdatePosition(); extern void InclusiveCrosshairsEnsureOn(); extern void InclusiveCrosshairsEnsureOff(); extern void InclusiveCrosshairsSetExternalControl(bool enabled); +extern void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation); +extern bool InclusiveCrosshairsIsEnabled(); +extern void InclusiveCrosshairsSwitch(); // Non-Localizable strings namespace @@ -244,12 +247,19 @@ public: return false; } - if (hotkeyId == 0) + if (hotkeyId == 0) // Crosshairs activation { + // If gliding cursor is active, cancel it and activate crosshairs + if (m_glideState.load() != 0) + { + CancelGliding(true /*activateCrosshairs*/); + return true; + } + // Otherwise, normal crosshairs toggle InclusiveCrosshairsSwitch(); return true; } - if (hotkeyId == 1) + if (hotkeyId == 1) // Gliding cursor activation { HandleGlidingHotkey(); return true; @@ -268,25 +278,44 @@ private: SendInput(2, inputs, sizeof(INPUT)); } - // Cancel gliding without performing the final click (Escape handling) - void CancelGliding() + // Cancel gliding with option to activate crosshairs in user's preferred orientation + void CancelGliding(bool activateCrosshairs) { int state = m_glideState.load(); if (state == 0) { return; // nothing to cancel } + + // Stop all gliding operations StopXTimer(); StopYTimer(); m_glideState = 0; - InclusiveCrosshairsEnsureOff(); + UninstallKeyboardHook(); + + // Reset crosshairs control and restore user settings InclusiveCrosshairsSetExternalControl(false); + InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation); + + if (activateCrosshairs) + { + // User is switching to crosshairs mode - enable with their settings + InclusiveCrosshairsEnsureOn(); + } + else + { + // User canceled (Escape) - turn off crosshairs completely + InclusiveCrosshairsEnsureOff(); + } + + // Reset gliding state if (auto s = m_state) { s->xFraction = 0.0; s->yFraction = 0.0; } - Logger::debug("Gliding cursor cancelled via Escape key"); + + Logger::debug("Gliding cursor cancelled (activateCrosshairs={})", activateCrosshairs ? 1 : 0); } // Stateless helpers operating on shared State @@ -425,21 +454,22 @@ private: { return; } - // Simulate the AHK state machine + int state = m_glideState.load(); switch (state) { - case 0: + case 0: // Starting gliding { - // For detect for cancel key + // Install keyboard hook for Escape cancellation InstallKeyboardHook(); - // Ensure crosshairs on (do not toggle off if already on) - InclusiveCrosshairsEnsureOn(); - // Disable internal mouse hook so we control position updates explicitly + + // Force crosshairs visible in BOTH orientation for gliding, regardless of user setting + // Set external control before enabling to prevent internal movement hook from attaching InclusiveCrosshairsSetExternalControl(true); - // Override crosshairs to show both for Gliding Cursor InclusiveCrosshairsSetOrientation(CrosshairsOrientation::Both); + InclusiveCrosshairsEnsureOn(); // Always ensure they are visible + // Initialize gliding state s->currentXPos = 0; s->currentXSpeed = s->fastHSpeed; s->xFraction = 0.0; @@ -447,20 +477,17 @@ private: int y = GetSystemMetrics(SM_CYVIRTUALSCREEN) / 2; SetCursorPos(0, y); InclusiveCrosshairsRequestUpdatePosition(); + m_glideState = 1; StartXTimer(); break; } - case 1: - { - // Slow horizontal + case 1: // Slow horizontal s->currentXSpeed = s->slowHSpeed; m_glideState = 2; break; - } - case 2: + case 2: // Switch to vertical fast { - // Stop horizontal, start vertical (fast) StopXTimer(); s->currentYSpeed = s->fastVSpeed; s->currentYPos = 0; @@ -471,33 +498,37 @@ private: StartYTimer(); break; } - case 3: - { - // Slow vertical + case 3: // Slow vertical s->currentYSpeed = s->slowVSpeed; m_glideState = 4; break; - } - case 4: + case 4: // Finalize (click and end) default: { - UninstallKeyboardHook(); - // Stop vertical, click, turn crosshairs off, re-enable internal tracking, reset state + // Complete the gliding sequence StopYTimer(); m_glideState = 0; LeftClick(); - InclusiveCrosshairsEnsureOff(); + + // Restore normal crosshairs operation and turn them off InclusiveCrosshairsSetExternalControl(false); - // Restore original crosshairs orientation setting InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation); - s->xFraction = 0.0; - s->yFraction = 0.0; + InclusiveCrosshairsEnsureOff(); + + UninstallKeyboardHook(); + + // Reset state + if (auto sp = m_state) + { + sp->xFraction = 0.0; + sp->yFraction = 0.0; + } break; } } } - // Low-level keyboard hook procedures + // Low-level keyboard hook for Escape cancellation static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode == HC_ACTION) @@ -509,14 +540,11 @@ private: { if (inst->m_enabled && inst->m_glideState.load() != 0) { - inst->UninstallKeyboardHook(); - inst->CancelGliding(); + inst->CancelGliding(false); // Escape cancels without activating crosshairs } } } } - - // Do not swallow Escape; pass it through return CallNextHookEx(nullptr, nCode, wParam, lParam); } From c6c7bfb8613ede7a88378cc14d0daf67b5bb4bfa Mon Sep 17 00:00:00 2001 From: Jaylyn Barbee <51131738+Jaylyn-Barbee@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:18:45 -0400 Subject: [PATCH 04/59] Updated installer hashes for 0.95.1 (#42820) Title, updating installer links for next release. --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 85fad26e1f..efb0366409 100644 --- a/README.md +++ b/README.md @@ -56,17 +56,17 @@ Go to the [PowerToys GitHub releases][github-release-link], click Assets to reve [github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.96%22 [github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.95%22 -[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.0/PowerToysUserSetup-0.95.0-x64.exe -[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.0/PowerToysUserSetup-0.95.0-arm64.exe -[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.0/PowerToysSetup-0.95.0-x64.exe -[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.0/PowerToysSetup-0.95.0-arm64.exe +[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysUserSetup-0.95.1-x64.exe +[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysUserSetup-0.95.1-arm64.exe +[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysSetup-0.95.1-x64.exe +[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysSetup-0.95.1-arm64.exe | Description | Filename | |----------------|----------| -| Per user - x64 | [PowerToysUserSetup-0.95.0-x64.exe][ptUserX64] | -| Per user - ARM64 | [PowerToysUserSetup-0.95.0-arm64.exe][ptUserArm64] | -| Machine wide - x64 | [PowerToysSetup-0.95.0-x64.exe][ptMachineX64] | -| Machine wide - ARM64 | [PowerToysSetup-0.95.0-arm64.exe][ptMachineArm64] | +| Per user - x64 | [PowerToysUserSetup-0.95.1-x64.exe][ptUserX64] | +| Per user - ARM64 | [PowerToysUserSetup-0.95.1-arm64.exe][ptUserArm64] | +| Machine wide - x64 | [PowerToysSetup-0.95.1-x64.exe][ptMachineX64] | +| Machine wide - ARM64 | [PowerToysSetup-0.95.1-arm64.exe][ptMachineArm64] | From c628b4901d372e686cb90041bf5e09b74124c03a Mon Sep 17 00:00:00 2001 From: Clint Rutkas Date: Thu, 23 Oct 2025 13:07:16 -0700 Subject: [PATCH 05/59] Added open tag to default expand open (#42824) By default, none of the 4 methods are expanded. this will expand the top item by default for users. image --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index efb0366409..e737281523 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Before you begin, make sure your device meets the system requirements: Choose one of the installation methods below: -
+
Download .exe from GitHub Go to the [PowerToys GitHub releases][github-release-link], click Assets to reveal the downloads, and choose the installer that matches your architecture and install scope. For most devices, that's the x64 per-user installer. @@ -281,4 +281,4 @@ The application logs basic diagnostic data (telemetry). For more privacy informa [roadmap]: https://github.com/microsoft/PowerToys/wiki/Roadmap [privacy-link]: http://go.microsoft.com/fwlink/?LinkId=521839 [loc-bug]: https://github.com/microsoft/PowerToys/issues/new?assignees=&labels=&template=translation_issue.md&title= -[usingPowerToys-docs-link]: https://aka.ms/powertoys-docs \ No newline at end of file +[usingPowerToys-docs-link]: https://aka.ms/powertoys-docs From cd5f7531404b8a2a467da617308cc7658f84543f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Thu, 23 Oct 2025 23:53:06 +0200 Subject: [PATCH 06/59] CmdPal: Migrate bookmarks manually (#42814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR fixes the migration of bookmarks from versions prior to 0.95, resolving an issue where hotkeys and aliases wouldn’t persist on bookmarks created with Command Palette 0.94 or earlier. It removes ID auto-fixing from `BookmarkData` in favor of an explicit migration step handled by `BookmarkManager`. ## PR Checklist - [x] Closes: #42796 - [ ] **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 --- .../BookmarkManagerTests.cs | 44 +++++++++++++++++++ .../MockBookmarkDataSource.cs | 24 ++++++++++ .../BookmarksManager.cs | 28 +++++++++++- .../Persistence/BookmarkData.cs | 2 +- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs index 0751b5afe3..b4e533d66d 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs @@ -186,4 +186,48 @@ public class BookmarkManagerTests Assert.AreEqual("D:\\UpdatedPath", updatedBookmark.Bookmark); Assert.IsTrue(bookmarkUpdatedEventFired); } + + [TestMethod] + public void BookmarkManager_LegacyData_IdsArePersistedAcrossLoads() + { + // Arrange + const string json = """ + { + "Data": + [ + { "Name": "C:\\","Bookmark": "C:\\" }, + { "Name": "Bing.com","Bookmark": "https://bing.com" } + ] + } + """; + + var dataSource = new MockBookmarkDataSource(json); + + // First load: IDs should be generated for legacy entries + var manager1 = new BookmarksManager(dataSource); + var firstLoad = manager1.Bookmarks.ToList(); + Assert.AreEqual(2, firstLoad.Count); + Assert.AreNotEqual(Guid.Empty, firstLoad[0].Id); + Assert.AreNotEqual(Guid.Empty, firstLoad[1].Id); + + // Keep a name->id map to be insensitive to ordering + var firstIdsByName = firstLoad.ToDictionary(b => b.Name, b => b.Id); + + // Wait deterministically for async persistence to complete + var wasSaved = dataSource.WaitForSave(1, 5000); + Assert.IsTrue(wasSaved, "Data was not saved within the expected time."); + + // Second load: should read back the same IDs from persisted data + var manager2 = new BookmarksManager(dataSource); + var secondLoad = manager2.Bookmarks.ToList(); + Assert.AreEqual(2, secondLoad.Count); + + var secondIdsByName = secondLoad.ToDictionary(b => b.Name, b => b.Id); + + foreach (var kvp in firstIdsByName) + { + Assert.IsTrue(secondIdsByName.ContainsKey(kvp.Key), $"Missing bookmark '{kvp.Key}' after reload."); + Assert.AreEqual(kvp.Value, secondIdsByName[kvp.Key], $"Bookmark '{kvp.Key}' upgraded ID was not persisted across loads."); + } + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs index 3980ac13c6..02d71d6d77 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs @@ -2,6 +2,8 @@ // 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.Threading; using Microsoft.CmdPal.Ext.Bookmarks.Persistence; namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; @@ -9,6 +11,7 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; internal sealed class MockBookmarkDataSource : IBookmarkDataSource { private string _jsonData; + private int _saveCount; public MockBookmarkDataSource(string initialJsonData = "[]") { @@ -23,5 +26,26 @@ internal sealed class MockBookmarkDataSource : IBookmarkDataSource public void SaveBookmarkData(string jsonData) { _jsonData = jsonData; + Interlocked.Increment(ref _saveCount); + } + + public int SaveCount => Volatile.Read(ref _saveCount); + + // Waits until at least expectedMinSaves have occurred or the timeout elapses. + // Returns true if the condition was met, false on timeout. + public bool WaitForSave(int expectedMinSaves = 1, int timeoutMs = 2000) + { + var start = Environment.TickCount; + while (Volatile.Read(ref _saveCount) < expectedMinSaves) + { + if (Environment.TickCount - start > timeoutMs) + { + return false; + } + + Thread.Sleep(50); + } + + return true; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs index 1eb57fb7eb..fde574360f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs @@ -103,7 +103,33 @@ internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager try { var jsonData = _dataSource.GetBookmarkData(); - _bookmarksData = _parser.ParseBookmarks(jsonData); + var bookmarksData = _parser.ParseBookmarks(jsonData); + + // Upgrade old bookmarks if necessary + // Pre .95 versions did not assign IDs to bookmarks + var upgraded = false; + for (var index = 0; index < bookmarksData.Data.Count; index++) + { + var bookmark = bookmarksData.Data[index]; + if (bookmark.Id == Guid.Empty) + { + bookmarksData.Data[index] = bookmark with { Id = Guid.NewGuid() }; + upgraded = true; + } + } + + lock (_lock) + { + _bookmarksData = bookmarksData; + } + + // LOAD BEARING: Save upgraded data back to file + // This ensures that old bookmarks are not repeatedly upgraded on each load, + // as the hotkeys and aliases are tied to the generated bookmark IDs. + if (upgraded) + { + _ = SaveChangesAsync(); + } } catch (Exception ex) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs index 3129e1b578..b577f9cb35 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs @@ -19,7 +19,7 @@ public sealed record BookmarkData [SetsRequiredMembers] public BookmarkData(Guid id, string? name, string? bookmark) { - Id = id == Guid.Empty ? Guid.NewGuid() : id; + Id = id; Name = name ?? string.Empty; Bookmark = bookmark ?? string.Empty; } From a69f7fa806539e0de6e9e901b4f651c512fdd54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Thu, 23 Oct 2025 23:57:12 +0200 Subject: [PATCH 07/59] CmdPal: Update top-level item view model to reflect change of the associated command (#42806) ## Summary of the Pull Request This PR implements a fix that ensures the top-level command's alias, hotkey, and tags are automatically updated whenever the associated command is modified, as the command defines the actual identity of the item. ## PR Checklist - [x] Closes: #42796 - [x] Related to: #42807 - [ ] **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 --- .../cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index 9d05e8019f..fc5e36d1e2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -219,7 +219,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem { PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName)); - if (e.PropertyName == "IsInitialized") + if (e.PropertyName is "IsInitialized" or nameof(CommandItemViewModel.Command)) { GenerateId(); From c71fdca277c971434a4e283fb357806ed1b102cd Mon Sep 17 00:00:00 2001 From: Kai Tao <69313318+vanzue@users.noreply.github.com> Date: Fri, 24 Oct 2025 10:24:39 +0800 Subject: [PATCH 08/59] Hybrid CRT for powertys (#42073) ## Summary of the Pull Request Hybrid CRT across powertoys for better bundle size ## 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 Bundle Size comparasion: | bundle | Before Hybrid CRT | After Hybrid CRT | diff | |---------------|-------------------|------------------|------| | x64-user | 317M | 310M | 7M | | x64-machine | 317M |310M | 7M | | arm64-user | 305M | 299M | 6M | | arm64-machine | 305M | 299M | 6M | Did verification on a sandbox machine, every module launches as expected, no dependency issue --- Cpp.Build.props | 52 +++++++++++++++++- .../PowerToysSetupCustomActionsVNext.vcxproj | 3 - .../PowerToysSetupVNext/Directory.Build.props | 3 +- .../SilentFilesInUseBAFunction.vcxproj | 55 ++----------------- .../SilentFilesInUseBAFunctions.cpp | 8 +-- .../CalculatorEngineCommon.vcxproj | 50 +---------------- src/common/interop/PowerToys.Interop.vcxproj | 2 - .../BackgroundActivator.vcxproj | 10 ---- .../CropAndLock/CropAndLock.vcxproj | 4 -- .../FileLocksmithLibInterop.vcxproj | 2 - .../FindMyMouse/FindMyMouse.vcxproj | 2 - .../MouseHighlighter/MouseHighlighter.vcxproj | 2 - .../MouseUtils/MouseJump/MouseJump.vcxproj | 2 - .../MousePointerCrosshairs.vcxproj | 2 - .../NewShellExtensionContextMenu.vcxproj | 4 -- .../WorkspacesLauncher.vcxproj | 2 - .../WorkspacesSnapshotTool.vcxproj | 2 - .../WorkspacesWindowArranger.vcxproj | 2 - src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj | 6 -- .../AlwaysOnTop/AlwaysOnTop.vcxproj | 2 - .../CmdPalKeyboardService.vcxproj | 39 ------------- .../Microsoft.Terminal.UI.vcxproj | 24 -------- ...icrosoft.CommandPalette.Extensions.vcxproj | 28 ---------- .../fancyzones/FancyZones/FancyZones.vcxproj | 2 - .../KeyboardManagerEditor.vcxproj | 2 - .../PowerRename.FuzzingTest.vcxproj | 1 - .../ModuleTemplate/ModuleTemplate.vcxproj | 2 - .../ModuleTemplateCompileTest.vcxproj | 2 - 28 files changed, 59 insertions(+), 256 deletions(-) diff --git a/Cpp.Build.props b/Cpp.Build.props index 5a4538f940..99738fd0dc 100644 --- a/Cpp.Build.props +++ b/Cpp.Build.props @@ -1,6 +1,56 @@ + + + $(Configuration) + + + + + + + + + + + MultiThreadedDebug + + + + %(IgnoreSpecificDefaultLibraries);libucrtd.lib + %(AdditionalOptions) /defaultlib:ucrtd.lib + + + + + + MultiThreaded + + + + %(IgnoreSpecificDefaultLibraries);libucrt.lib + %(AdditionalOptions) /defaultlib:ucrt.lib + + + + + + + MultiThreadedDebugDLL + + + + + MultiThreadedDLL + + @@ -73,7 +123,6 @@ _DEBUG;%(PreprocessorDefinitions) Disabled - MultiThreadedDebug true @@ -83,7 +132,6 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed - MultiThreaded true true diff --git a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj index 7cd49be6ea..3dca775d92 100644 --- a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj +++ b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj @@ -38,7 +38,6 @@ $(Platform)\$(Configuration)\UserSetup\ $(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\MachineSetup\obj\ $(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\UserSetup\obj\ - false true @@ -115,7 +114,6 @@ Disabled _DEBUG;_WINDOWS;_USRDLL;CUSTOMACTIONTEST_EXPORTS;%(PreprocessorDefinitions) EnableFastChecks - MultiThreadedDebug true @@ -128,7 +126,6 @@ MaxSpeed true NDEBUG;_WINDOWS;_USRDLL;CUSTOMACTIONTEST_EXPORTS;%(PreprocessorDefinitions) - MultiThreaded true diff --git a/installer/PowerToysSetupVNext/Directory.Build.props b/installer/PowerToysSetupVNext/Directory.Build.props index 505e3cf844..69a63832d1 100644 --- a/installer/PowerToysSetupVNext/Directory.Build.props +++ b/installer/PowerToysSetupVNext/Directory.Build.props @@ -1,4 +1,5 @@ + @@ -8,4 +9,4 @@ $(BaseIntermediateOutputPath) - + \ No newline at end of file diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj index 3972c1b0f7..d45e32f87c 100644 --- a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj @@ -1,30 +1,7 @@ - - - - - Debug - ARM64 - - - Release - ARM64 - - - Debug - x64 - - - Release - x64 - - - {F8B9F842-F5C3-4A2D-8C85-7F8B9E2B4F1D} - DynamicLibrary - Unicode SilentFilesInUseBAFunction PowerToysSetupCustomActionsVNext bafunctions.def @@ -33,7 +10,6 @@ - DynamicLibrary true @@ -65,7 +41,10 @@ - + + Use + precomp.h + Create precomp.h @@ -92,31 +71,5 @@ - - - - _DEBUG;%(PreprocessorDefinitions) - Disabled - MultiThreadedDebug - - - true - - - - - NDEBUG;%(PreprocessorDefinitions) - MaxSpeed - MultiThreaded - true - true - - - true - true - true - - - diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp index 9b9e5d570f..ceccde5f0d 100644 --- a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp @@ -18,7 +18,6 @@ public: // IBootstrapperApplication BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running detect begin BA function. fCached=%d, registrationType=%d, cPackages=%u, fCancel=%d", fCached, registrationType, cPackages, *pfCancel); - LExit: return hr; } @@ -32,12 +31,6 @@ public: // IBAFunctions BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running plan begin BA function. cPackages=%u, fCancel=%d", cPackages, *pfCancel); - //------------------------------------------------------------------------------------------------- - // YOUR CODE GOES HERE - // BalExitOnFailure(hr, "Change this message to represent real error handling."); - //------------------------------------------------------------------------------------------------- - - LExit: return hr; } @@ -63,6 +56,7 @@ public: // IBAFunctions ) { HRESULT hr = S_OK; + UNREFERENCED_PARAMETER(source); BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION CALLED *** Running OnExecuteFilesInUse BA function. packageId=%ls, cFiles=%u, recommendation=%d", wzPackageId, cFiles, nRecommendation); diff --git a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj index 43f4749892..ff9332cfc0 100644 --- a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj +++ b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj @@ -9,12 +9,6 @@ CalculatorEngineCommon false - - - true - false - - true @@ -25,11 +19,9 @@ true - true + false true Windows Store - false - @@ -148,43 +140,5 @@ - - - - - - - MultiThreadedDebug - stdcpp17 - - - - %(IgnoreSpecificDefaultLibraries);libucrtd.lib - %(AdditionalOptions) /defaultlib:ucrtd.lib - - - - - - MultiThreaded - - - - %(IgnoreSpecificDefaultLibraries);libucrt.lib - %(AdditionalOptions) /defaultlib:ucrt.lib - - - + \ No newline at end of file diff --git a/src/common/interop/PowerToys.Interop.vcxproj b/src/common/interop/PowerToys.Interop.vcxproj index ca29e69cce..472119925e 100644 --- a/src/common/interop/PowerToys.Interop.vcxproj +++ b/src/common/interop/PowerToys.Interop.vcxproj @@ -63,14 +63,12 @@ - MultiThreadedDebug true true - MultiThreaded false true false diff --git a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj index 077333a664..b2ebc7cb72 100644 --- a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj +++ b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj @@ -46,16 +46,6 @@ notifications - - - MultiThreadedDebugDLL - - - - - MultiThreadedDLL - - $(IntDir)pch.pch diff --git a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj index c3e9e4f3f1..71b535c629 100644 --- a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj +++ b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj @@ -82,8 +82,6 @@ Disabled _DEBUG;%(PreprocessorDefinitions) - MultiThreadedDebug - MultiThreadedDebug false @@ -95,8 +93,6 @@ true true NDEBUG;%(PreprocessorDefinitions) - MultiThreaded - MultiThreaded true diff --git a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj index c4489cdad8..184eec3342 100644 --- a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj @@ -39,7 +39,6 @@ _DEBUG;%(PreprocessorDefinitions) - MultiThreadedDebugDLL true true @@ -49,7 +48,6 @@ NDEBUG;%(PreprocessorDefinitions) true true - MultiThreadedDLL false true false diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj index d127de245e..0f444134ec 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj @@ -64,7 +64,6 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreadedDebug stdcpplatest @@ -82,7 +81,6 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreaded stdcpplatest diff --git a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj index df0df021da..ecd6ea3ec4 100644 --- a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj +++ b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj @@ -48,7 +48,6 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreadedDebug stdcpplatest @@ -66,7 +65,6 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreaded stdcpplatest diff --git a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj index 29e8f444bf..89abed873a 100644 --- a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj +++ b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj @@ -48,7 +48,6 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreadedDebug stdcpplatest @@ -66,7 +65,6 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreaded stdcpplatest diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj index 58668c663f..7fef06e960 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj +++ b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj @@ -49,7 +49,6 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreadedDebug stdcpplatest @@ -67,7 +66,6 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreaded stdcpplatest diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj index 90058a503e..6685afafc2 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj @@ -67,8 +67,6 @@ false dll.def runtimeobject.lib;$(CoreLibraryDependencies) - - del $(OutDir)\NewPlusPackage.msix /q @@ -100,8 +98,6 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv false dll.def runtimeobject.lib;$(CoreLibraryDependencies) - - del $(OutDir)\NewPlusPackage.msix /q diff --git a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj index 9d4fc4bcab..7be5219a9f 100644 --- a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj +++ b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj @@ -29,7 +29,6 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true - MultiThreadedDebug true @@ -40,7 +39,6 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false - MultiThreaded true true diff --git a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj index 05e4241c1c..ad7a96ec84 100644 --- a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj +++ b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj @@ -29,7 +29,6 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true - MultiThreadedDebug true @@ -40,7 +39,6 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false - MultiThreaded true true diff --git a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj index 2451be2470..4555f6257b 100644 --- a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj +++ b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj @@ -29,7 +29,6 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true - MultiThreadedDebug true @@ -40,7 +39,6 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false - MultiThreaded true true diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj index d054d2b4bd..f12898dbd4 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj @@ -81,7 +81,6 @@ MaxSpeed __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x602;NDEBUG;_WIN32_WINNT=0x602;_WIN32_WINDOWS=0x501;WIN32;_WINDOWS;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) true - MultiThreaded true @@ -103,7 +102,6 @@ MaxSpeed __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x602;NDEBUG;_WIN32_WINNT=0x602;_WIN32_WINDOWS=0x501;WIN32;_WINDOWS;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) true - MultiThreaded true @@ -126,7 +124,6 @@ MaxSpeed __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x602;NDEBUG;_WIN32_WINNT=0x602;_WIN32_WINDOWS=0x501;WIN32;_WINDOWS;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) true - MultiThreaded true @@ -148,7 +145,6 @@ Disabled __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x0602;_DEBUG;_WIN32_WINNT=0x602.MSVC6;_WIN32_WINDOWS=0x600;WIN32;_WINDOWS;_WIN32_WINNT=0x602;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) EnableFastChecks - MultiThreadedDebug _DEBUG;_M_IX86;%(PreprocessorDefinitions) @@ -169,7 +165,6 @@ Disabled __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x0602;_DEBUG;_WIN32_WINNT=0x602.MSVC6;_WIN32_WINDOWS=0x600;WIN32;_WINDOWS;_WIN32_WINNT=0x602;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) EnableFastChecks - MultiThreadedDebug _DEBUG;_M_X64;%(PreprocessorDefinitions) @@ -191,7 +186,6 @@ Disabled __ZOOMIT_POWERTOYS__;_UNICODE;UNICODE;WINVER=0x0602;_DEBUG;_WIN32_WINNT=0x602.MSVC6;_WIN32_WINDOWS=0x600;WIN32;_WINDOWS;_WIN32_WINNT=0x602;MSVC6=1;_CRT_SECURE_NO_DEPRECATE;%(PreprocessorDefinitions) EnableFastChecks - MultiThreadedDebug _DEBUG;_M_ARM64;%(PreprocessorDefinitions) diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj index bf3e5c6851..5adad25bac 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj @@ -29,7 +29,6 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true - MultiThreadedDebug true @@ -40,7 +39,6 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false - MultiThreaded true true diff --git a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj index f891ce96e6..4e20f55383 100644 --- a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj +++ b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj @@ -141,43 +141,4 @@ - - - - - - - MultiThreadedDebug - - - - %(IgnoreSpecificDefaultLibraries);libucrtd.lib - %(AdditionalOptions) /defaultlib:ucrtd.lib - - - - - - MultiThreaded - - - - %(IgnoreSpecificDefaultLibraries);libucrt.lib - %(AdditionalOptions) /defaultlib:ucrt.lib - - - - \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj index 6e474cf5f3..347b5a1bc6 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj @@ -74,35 +74,11 @@ stdcpp20 - - - MultiThreadedDebug - - - - %(IgnoreSpecificDefaultLibraries);libucrtd.lib - %(AdditionalOptions) /defaultlib:ucrtd.lib /profile /opt:ref /opt:icf - stdcpp20 - - - MultiThreaded - - - - %(IgnoreSpecificDefaultLibraries);libucrt.lib - %(AdditionalOptions) /defaultlib:ucrt.lib /profile /opt:ref /opt:icf - diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj index a6cad871ab..92baf3dfa6 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj @@ -68,34 +68,6 @@ true false - - - - MultiThreadedDebug - - - - %(IgnoreSpecificDefaultLibraries);libucrtd.lib - %(AdditionalOptions) /defaultlib:ucrtd.lib /profile /opt:ref /opt:icf - - - - - - MultiThreaded - - - - %(IgnoreSpecificDefaultLibraries);libucrt.lib - %(AdditionalOptions) /defaultlib:ucrt.lib /profile /opt:ref /opt:icf - - diff --git a/src/modules/fancyzones/FancyZones/FancyZones.vcxproj b/src/modules/fancyzones/FancyZones/FancyZones.vcxproj index b54ee19e34..7aab504830 100644 --- a/src/modules/fancyzones/FancyZones/FancyZones.vcxproj +++ b/src/modules/fancyzones/FancyZones/FancyZones.vcxproj @@ -26,7 +26,6 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true - MultiThreadedDebug true @@ -37,7 +36,6 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false - MultiThreaded true true diff --git a/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj index 3c124d63e4..8a4fa95889 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj @@ -30,7 +30,6 @@ _DEBUG;%(PreprocessorDefinitions) Disabled true - MultiThreadedDebug true @@ -41,7 +40,6 @@ NDEBUG;%(PreprocessorDefinitions) MaxSpeed false - MultiThreaded true true diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj index f9e245559c..1a7ca91972 100644 --- a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj @@ -44,7 +44,6 @@ true NotUsing /fsanitize=address /fsanitize-coverage=inline-8bit-counters /fsanitize-coverage=edge /fsanitize-coverage=trace-cmp /fsanitize-coverage=trace-div %(AdditionalOptions) - MultiThreaded stdcpplatest ..\;..\lib\;..\..\..\;%(AdditionalIncludeDirectories) diff --git a/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj b/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj index 028007de67..39c656a6cc 100644 --- a/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj +++ b/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj @@ -46,7 +46,6 @@ true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreadedDebug stdcpplatest @@ -64,7 +63,6 @@ true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true - MultiThreaded stdcpplatest diff --git a/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj b/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj index 297516b0d5..a1ef0522aa 100644 --- a/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj +++ b/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj @@ -47,7 +47,6 @@ Disabled true _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) - MultiThreadedDebug stdcpplatest @@ -64,7 +63,6 @@ true true NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) - MultiThreaded stdcpplatest From 82dc4cdc184b2cea7faec9093e354a0b161dfa1b Mon Sep 17 00:00:00 2001 From: moooyo <42196638+moooyo@users.noreply.github.com> Date: Sat, 25 Oct 2025 03:28:30 +0800 Subject: [PATCH 09/59] [CmdPal] Replace complex cancellation token mechanism with a simple task queue. (#42356) ## Summary of the Pull Request Just consider user are trying to search a long name such as "Visual Studio Code" The old mechanism: User input: V Task: V Then input: i Task cancel for V and start task i etc... The problem is: 1. I don't think we can really cancel the most time-cost part (Find packages from WinGet). 2. User cannot see anything before they really end the input and the last task complete. UX exp is so bad. 3. It's so complex to maintain. Hard to understand for the new contributor. New mechanism: User input: V Task: V Then input: i Prev Task is still running but mark the next task is i Input: s Prev Task is still running but override the next task to s etc... We can get: 1. User can see some results if prev task complete. 2. It's simple to understand 3. The extra time cost I think will not too much. Because we ignored the middle input. Compare: https://github.com/user-attachments/assets/f45f4073-efab-4f43-87f0-f47b727f36dc ## 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 Co-authored-by: Yu Leng --- .../Pages/WinGetExtensionPage.cs | 136 +++++++++--------- 1 file changed, 64 insertions(+), 72 deletions(-) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs index 1d7758769a..e84802b8fa 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs @@ -28,9 +28,10 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable public bool HasTag => !string.IsNullOrEmpty(_tag); private readonly Lock _resultsLock = new(); + private readonly Lock _taskLock = new(); - private CancellationTokenSource? _cancellationTokenSource; - private Task>? _currentSearchTask; + private string? _nextSearchQuery; + private bool _isTaskRunning; private List? _results; @@ -85,7 +86,6 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable stopwatch.Stop(); Logger.LogDebug($"Building ListItems took {stopwatch.ElapsedMilliseconds}ms", memberName: nameof(GetItems)); - IsLoading = false; return results; } } @@ -98,8 +98,6 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable Properties.Resources.winget_no_packages_found, }; - IsLoading = false; - return []; } @@ -117,64 +115,70 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable private void DoUpdateSearchText(string newSearch) { - // Cancel any ongoing search - if (_cancellationTokenSource is not null) + lock (_taskLock) { - Logger.LogDebug("Cancelling old search", memberName: nameof(DoUpdateSearchText)); - _cancellationTokenSource.Cancel(); - } - - _cancellationTokenSource = new CancellationTokenSource(); - - var cancellationToken = _cancellationTokenSource.Token; - - IsLoading = true; - - try - { - // Save the latest search task - _currentSearchTask = DoSearchAsync(newSearch, cancellationToken); - } - catch (OperationCanceledException) - { - // DO NOTHING HERE - return; - } - catch (Exception ex) - { - // Handle other exceptions - ExtensionHost.LogMessage($"[WinGet] DoUpdateSearchText throw exception: {ex.Message}"); - return; - } - - // Await the task to ensure only the latest one gets processed - _ = ProcessSearchResultsAsync(_currentSearchTask, newSearch); - } - - private async Task ProcessSearchResultsAsync( - Task> searchTask, - string newSearch) - { - try - { - var results = await searchTask; - - // Ensure this is still the latest task - if (_currentSearchTask == searchTask) + if (_isTaskRunning) { - // Process the results (e.g., update UI) - UpdateWithResults(results, newSearch); + // If a task is running, queue the next search query + // Keep IsLoading = true since we still have work to do + Logger.LogDebug($"Task is running, queueing next search: '{newSearch}'", memberName: nameof(DoUpdateSearchText)); + _nextSearchQuery = newSearch; + } + else + { + // No task is running, start a new search + Logger.LogDebug($"Starting new search: '{newSearch}'", memberName: nameof(DoUpdateSearchText)); + _isTaskRunning = true; + _nextSearchQuery = null; + IsLoading = true; + + _ = ExecuteSearchChainAsync(newSearch); } } - catch (OperationCanceledException) + } + + private async Task ExecuteSearchChainAsync(string query) + { + while (true) { - // Handle cancellation gracefully (e.g., log or ignore) - Logger.LogDebug($" Cancelled search for '{newSearch}'"); - } - catch (Exception ex) - { - // Handle other exceptions - Logger.LogError("Unexpected error while processing results", ex); + try + { + Logger.LogDebug($"Executing search for '{query}'", memberName: nameof(ExecuteSearchChainAsync)); + + var results = await DoSearchAsync(query); + + // Update UI with results + UpdateWithResults(results, query); + } + catch (Exception ex) + { + Logger.LogError($"Unexpected error while searching for '{query}'", ex); + } + + // Check if there's a next query to process + string? nextQuery; + lock (_taskLock) + { + if (_nextSearchQuery is not null) + { + // There's a queued search, execute it + nextQuery = _nextSearchQuery; + _nextSearchQuery = null; + + Logger.LogDebug($"Found queued search, continuing with: '{nextQuery}'", memberName: nameof(ExecuteSearchChainAsync)); + } + else + { + // No more searches queued, mark task as completed + _isTaskRunning = false; + IsLoading = false; + Logger.LogDebug("No more queued searches, task chain completed", memberName: nameof(ExecuteSearchChainAsync)); + break; + } + } + + // Continue with the next query + query = nextQuery; } } @@ -189,11 +193,8 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable RaiseItemsChanged(); } - private async Task> DoSearchAsync(string query, CancellationToken ct) + private async Task> DoSearchAsync(string query) { - // Were we already canceled? - ct.ThrowIfCancellationRequested(); - Stopwatch stopwatch = new(); stopwatch.Start(); @@ -230,9 +231,6 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable opts.Filters.Add(tagFilter); } - // Clean up here, then... - ct.ThrowIfCancellationRequested(); - var catalogTask = HasTag ? WinGetStatics.CompositeWingetCatalog : WinGetStatics.CompositeAllCatalog; // Both these catalogs should have been instantiated by the @@ -251,13 +249,11 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable findPackages_stopwatch.Start(); Logger.LogDebug($" Searching {catalog.Info.Name} ({query})", memberName: nameof(DoSearchAsync)); - ct.ThrowIfCancellationRequested(); - Logger.LogDebug($"Preface for \"{searchDebugText}\" took {stopwatch.ElapsedMilliseconds}ms", memberName: nameof(DoSearchAsync)); // BODGY, re: microsoft/winget-cli#5151 // FindPackagesAsync isn't actually async. - var internalSearchTask = Task.Run(() => catalog.FindPackages(opts), ct); + var internalSearchTask = Task.Run(() => catalog.FindPackages(opts)); var searchResults = await internalSearchTask; findPackages_stopwatch.Stop(); @@ -271,8 +267,6 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable return []; } - ct.ThrowIfCancellationRequested(); - Logger.LogDebug($" got results for ({query})", memberName: nameof(DoSearchAsync)); // FYI Using .ToArray or any other kind of enumerable loop @@ -282,8 +276,6 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable { var match = searchResults.Matches[i]; - ct.ThrowIfCancellationRequested(); - var package = match.CatalogPackage; results.Add(package); } From 0e36e7e7a7accc92cfbdaa9828fd7c38fd57d6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Sat, 25 Oct 2025 02:12:59 +0200 Subject: [PATCH 10/59] CmdPal: Add keyboard shortcut (Ctrl+,) to open Settings (#42787) ## Summary of the Pull Request This PR introduces a new keyboard shortcut `Ctrl + ,` that opens the Settings window directly. ## PR Checklist - [x] Closes: #42785 - [ ] **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 --- .../Pages/ShellPage.xaml.cs | 41 +++++++++++-------- .../Strings/en-us/Resources.resw | 3 ++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 6ec7f23a59..457b0fddbf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -591,24 +591,31 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed; - if (e.Key == VirtualKey.Left && onlyAlt) + var onlyCtrl = !altPressed && ctrlPressed && !shiftPressed && !winPressed; + switch (e.Key) { - WeakReferenceMessenger.Default.Send(new()); - e.Handled = true; - } - else if (e.Key == VirtualKey.Home && onlyAlt) - { - WeakReferenceMessenger.Default.Send(new(WithAnimation: false)); - e.Handled = true; - } - else - { - // The CommandBar is responsible for handling all the item keybindings, - // since the bound context item may need to then show another - // context menu - TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); - WeakReferenceMessenger.Default.Send(msg); - e.Handled = msg.Handled; + case VirtualKey.Left when onlyAlt: // Alt+Left arrow + WeakReferenceMessenger.Default.Send(new()); + e.Handled = true; + break; + case VirtualKey.Home when onlyAlt: // Alt+Home + WeakReferenceMessenger.Default.Send(new(WithAnimation: false)); + e.Handled = true; + break; + case (VirtualKey)188 when onlyCtrl: // Ctrl+, + WeakReferenceMessenger.Default.Send(new()); + e.Handled = true; + break; + default: + { + // The CommandBar is responsible for handling all the item keybindings, + // since the bound context item may need to then show another + // context menu + TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + WeakReferenceMessenger.Default.Send(msg); + e.Handled = msg.Handled; + break; + } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index ec7ce8b68c..4eb8be9b2b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -471,4 +471,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Navigated to {0} page + + Settings (Ctrl+,) + \ No newline at end of file From 6e5ad11bc320f65392765a5b782e86e4337b23af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Sat, 25 Oct 2025 02:15:34 +0200 Subject: [PATCH 11/59] CmdpPal: SearchBox visibility and async loading race (#42783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR introduces two related fixes to improve the stability and reliability of navigation and search UI behavior in the shell: - **Ensure search box visibility is correctly updated** - `ShellViewModel` previously set `IsSearchBoxVisible` after navigation to the page, but didn’t update it when the value changed. While the value isn’t expected to change dynamically, the property initialization is asynchronous, which could cause a race condition. - As a defensive measure, this also changes the default value of uninitialized property to make it visible by default. - **Cancel asynchronous focus placement if navigation changes** - Ensures that any pending asynchronous focus operation is cancelled when another navigation occurs before it completes. ## PR Checklist - [x] Closes: #42782 - [ ] **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 --- .../PageViewModel.cs | 2 +- .../ShellViewModel.cs | 12 +++ .../Pages/ShellPage.xaml.cs | 97 ++++++++++++++----- 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 82b4b7d59e..2d750c7df3 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -68,7 +68,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext // `IsLoading` property as a combo of this value and `IsInitialized` public bool ModelIsLoading { get; protected set; } = true; - public bool HasSearchBox { get; protected set; } + public bool HasSearchBox { get; protected set; } = true; public IconInfoViewModel Icon { get; protected set; } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs index 41db974f5b..046d7ea336 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs @@ -2,6 +2,7 @@ // 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.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; @@ -48,6 +49,9 @@ public partial class ShellViewModel : ObservableObject, var oldValue = _currentPage; if (SetProperty(ref _currentPage, value)) { + oldValue.PropertyChanged -= CurrentPage_PropertyChanged; + value.PropertyChanged += CurrentPage_PropertyChanged; + if (oldValue is IDisposable disposable) { try @@ -63,6 +67,14 @@ public partial class ShellViewModel : ObservableObject, } } + private void CurrentPage_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PageViewModel.HasSearchBox)) + { + IsSearchBoxVisible = CurrentPage.HasSearchBox; + } + } + private IPage? _rootPage; private bool _isNested; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 457b0fddbf..3509db9f6d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -48,7 +48,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, IRecipient, IRecipient, IRecipient, - INotifyPropertyChanged + INotifyPropertyChanged, + IDisposable { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -65,6 +66,9 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, private SettingsWindow? _settingsWindow; + private CancellationTokenSource? _focusAfterLoadedCts; + private WeakReference? _lastNavigatedPageRef; + public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService()!; public event PropertyChangedEventHandler? PropertyChanged; @@ -488,6 +492,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, if (e.Content is Page element) { + _lastNavigatedPageRef = new WeakReference(element); element.Loaded += FocusAfterLoaded; } } @@ -497,6 +502,18 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, var page = (Page)sender; page.Loaded -= FocusAfterLoaded; + // Only handle focus for the latest navigated page + if (_lastNavigatedPageRef is null || !_lastNavigatedPageRef.TryGetTarget(out var last) || !ReferenceEquals(page, last)) + { + return; + } + + // Cancel any previous pending focus work + _focusAfterLoadedCts?.Cancel(); + _focusAfterLoadedCts?.Dispose(); + _focusAfterLoadedCts = new CancellationTokenSource(); + var token = _focusAfterLoadedCts.Token; + AnnounceNavigationToPage(page); var shouldSearchBoxBeVisible = ViewModel.CurrentPage?.HasSearchBox ?? false; @@ -509,34 +526,57 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, } else { - _ = Task.Run(async () => - { - await page.DispatcherQueue.EnqueueAsync(async () => + _ = Task.Run( + async () => { - // I hate this so much, but it can take a while for the page to be ready to accept focus; - // focusing page with MarkdownTextBlock takes up to 5 attempts (* 100ms delay between attempts) - for (var i = 0; i < 10; i++) + if (token.IsCancellationRequested) { - if (FocusManager.FindFirstFocusableElement(page) is FrameworkElement frameworkElement) - { - var set = frameworkElement.Focus(FocusState.Programmatic); - if (set) - { - break; - } - } - - await Task.Delay(100); + return; } - // Update the search box visibility based on the current page: - // - We do this here after navigation so the focus is not jumping around too much, - // it messes with screen readers if we do it too early - // - Since this should hide the search box on content pages, it's not a problem if we - // wait for the code above to finish trying to focus the content - ViewModel.IsSearchBoxVisible = ViewModel.CurrentPage?.HasSearchBox ?? false; - }); - }); + try + { + await page.DispatcherQueue.EnqueueAsync( + async () => + { + // I hate this so much, but it can take a while for the page to be ready to accept focus; + // focusing page with MarkdownTextBlock takes up to 5 attempts (* 100ms delay between attempts) + for (var i = 0; i < 10; i++) + { + token.ThrowIfCancellationRequested(); + + if (FocusManager.FindFirstFocusableElement(page) is FrameworkElement frameworkElement) + { + var set = frameworkElement.Focus(FocusState.Programmatic); + if (set) + { + break; + } + } + + await Task.Delay(100, token); + } + + token.ThrowIfCancellationRequested(); + + // Update the search box visibility based on the current page: + // - We do this here after navigation so the focus is not jumping around too much, + // it messes with screen readers if we do it too early + // - Since this should hide the search box on content pages, it's not a problem if we + // wait for the code above to finish trying to focus the content + ViewModel.IsSearchBoxVisible = ViewModel.CurrentPage?.HasSearchBox ?? false; + }); + } + catch (OperationCanceledException) + { + // Swallow cancellation - another FocusAfterLoaded invocation superseded this one + } + catch (Exception ex) + { + Logger.LogError("Error during FocusAfterLoaded async focus work", ex); + } + }, + token); } } @@ -665,4 +705,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, Logger.LogError("Error handling mouse button press event", ex); } } + + public void Dispose() + { + _focusAfterLoadedCts?.Cancel(); + _focusAfterLoadedCts?.Dispose(); + _focusAfterLoadedCts = null; + } } From 20188bda9b4e3e5b2ebfdb90f3b6d2462f61771f Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Fri, 24 Oct 2025 19:16:21 -0500 Subject: [PATCH 12/59] File search now has filters (#42141) Closes #39260 Search for all files & folders, folders only, or files only. Enjoy. https://github.com/user-attachments/assets/43ba93f5-dfc5-4e73-8414-547cf99dcfcf --- .../ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs | 6 +- .../Indexer/SearchFilters.cs | 27 +++++++++ .../Pages/IndexerPage.cs | 55 ++++++++++++++++--- .../Properties/Resources.Designer.cs | 27 +++++++++ .../Properties/Resources.resx | 9 +++ 5 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs index f57b8a2d07..bd9759514c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs @@ -6,7 +6,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Indexer; -internal sealed class Icons +internal static class Icons { internal static IconInfo FileExplorerSegoeIcon { get; } = new("\uEC50"); @@ -19,4 +19,8 @@ internal sealed class Icons internal static IconInfo DocumentIcon { get; } = new("\uE8A5"); // Document internal static IconInfo FolderOpenIcon { get; } = new("\uE838"); // FolderOpen + + internal static IconInfo FilesIcon { get; } = new("\uF571"); // PrintAllPages + + internal static IconInfo FilterIcon { get; } = new("\uE71C"); // Filter } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs new file mode 100644 index 0000000000..bf2e5dc451 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer; + +internal sealed partial class SearchFilters : Filters +{ + public SearchFilters() + { + CurrentFilterId = "all"; + } + + public override IFilterItem[] GetFilters() + { + return [ + new Filter() { Id = "all", Name = Resources.Indexer_Filter_All, Icon = Icons.FilterIcon }, + new Separator(), + new Filter() { Id = "folders", Name = Resources.Indexer_Filter_Folders_Only, Icon = Icons.FolderOpenIcon }, + new Filter() { Id = "files", Name = Resources.Indexer_Filter_Files_Only, Icon = Icons.FilesIcon }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs index 3b09fcf149..0ed9458398 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Globalization; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Indexer.Indexer; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -36,6 +37,11 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable PlaceholderText = Resources.Indexer_PlaceholderText; _searchEngine = new(); _queryCookie = 10; + + var filters = new SearchFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + CreateEmptyContent(); } @@ -49,6 +55,11 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable initialQuery = query; SearchText = query; disposeSearchEngine = false; + + var filters = new SearchFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + CreateEmptyContent(); } @@ -79,30 +90,56 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable { // {20D04FE0-3AEA-1069-A2D8-08002B30309D} is CLSID for "This PC" const string template = "search-ms:query={0}&crumb=location:::{{20D04FE0-3AEA-1069-A2D8-08002B30309D}}"; - var encodedSearchText = UrlEncoder.Default.Encode(SearchText); + var fullSearchText = FullSearchString(SearchText); + var encodedSearchText = UrlEncoder.Default.Encode(fullSearchText); var command = string.Format(CultureInfo.CurrentCulture, template, encodedSearchText); ShellHelpers.OpenInShell(command); } public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent : _nothingFoundEmptyContent; + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) + { + PerformSearch(SearchText); + } + public override void UpdateSearchText(string oldSearch, string newSearch) { if (oldSearch != newSearch && newSearch != initialQuery) { - _ = Task.Run(() => - { - _isEmptyQuery = string.IsNullOrWhiteSpace(newSearch); - Query(newSearch); - LoadMore(); - OnPropertyChanged(nameof(EmptyContent)); - initialQuery = null; - }); + PerformSearch(newSearch); } } + private void PerformSearch(string newSearch) + { + var actualSearch = FullSearchString(newSearch); + _ = Task.Run(() => + { + _isEmptyQuery = string.IsNullOrWhiteSpace(actualSearch); + Query(actualSearch); + LoadMore(); + OnPropertyChanged(nameof(EmptyContent)); + initialQuery = null; + }); + } + public override IListItem[] GetItems() => [.. _indexerListItems]; + private string FullSearchString(string query) + { + switch (Filters.CurrentFilterId) + { + case "folders": + return $"{query} kind:folders"; + case "files": + return $"{query} kind:NOT folders"; + case "all": + default: + return query; + } + } + public override void LoadMore() { IsLoading = true; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs index 44b87b05e2..d4d8f9cd0f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs @@ -177,6 +177,33 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } + /// + /// Looks up a localized string similar to Files and folders. + /// + internal static string Indexer_Filter_All { + get { + return ResourceManager.GetString("Indexer_Filter_All", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Files. + /// + internal static string Indexer_Filter_Files_Only { + get { + return ResourceManager.GetString("Indexer_Filter_Files_Only", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Folders. + /// + internal static string Indexer_Filter_Folders_Only { + get { + return ResourceManager.GetString("Indexer_Filter_Folders_Only", resourceCulture); + } + } + /// /// Looks up a localized string similar to Find file from path. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx index 66504abed1..8e67eae4e9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx @@ -196,4 +196,13 @@ You can try searching all files on this PC or adjust your indexing settings. Search all files + + Files and folders + + + Folders + + + Files + \ No newline at end of file From 623c804093922c5ff34432096a567aa6045e4e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Sun, 26 Oct 2025 02:50:07 +0200 Subject: [PATCH 13/59] ManagedCommon: Log correct HRESULT for the inner exception (#42178) ## Summary of the Pull Request This PR fixes incorrect HRESULT for inner exception when an error is logged. ## 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 --- src/common/ManagedCommon/Logger.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index 11115b1846..0db1614671 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -130,7 +130,7 @@ namespace ManagedCommon { exMessage += "Inner exception: " + Environment.NewLine + - ex.InnerException.GetType() + " (" + ex.HResult + "): " + ex.InnerException.Message + Environment.NewLine; + ex.InnerException.GetType() + " (" + ex.InnerException.HResult + "): " + ex.InnerException.Message + Environment.NewLine; } exMessage += From e256e7968575ffde90c7c7a27d9bb8cdb063b442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Sun, 26 Oct 2025 02:51:53 +0200 Subject: [PATCH 14/59] CmdPal: Fix search box text selection in ShellPage.GoHome (#42937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR fixes an issue where `ShellPage.GoHome` wouldn’t select the search box text when the current page was already the home page. In that case, the navigation stack was empty, and no code was executed because focusing the text had been delegated to the `GoBack` operation. ## Change log one-liner Ensured search text is selected when Go home when activated and Highlight search on activate are both enabled. ## PR Checklist - [x] Closes: #42443 - [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 - [x] **Dev docs:** Added/updated - [x] **New binaries:** Added on the required places - [x] **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 --- .../cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 3509db9f6d..d009c626e0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -451,7 +451,15 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, { while (RootFrame.CanGoBack) { - GoBack(withAnimation, focusSearch); + // don't focus on each step, just at the end + GoBack(withAnimation, focusSearch: false); + } + + // focus search box, even if we were already home + if (focusSearch) + { + SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + SearchBox.SelectSearch(); } } From b774e13176fdec6c72f15994714f77908c1c191f Mon Sep 17 00:00:00 2001 From: Kai Tao <69313318+vanzue@users.noreply.github.com> Date: Mon, 27 Oct 2025 09:33:26 +0800 Subject: [PATCH 15/59] Fix the foreground style for find my mouse (#42865) ## Summary of the Pull Request Find my mouse should use full transparent window ## PR Checklist - [X] Closes: #42758 - [ ] **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 https://github.com/user-attachments/assets/75c73eb3-04bb-438c-8823-3c9f18923cc6 --- src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp index c94c79e178..f953af0fdd 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp @@ -189,7 +189,7 @@ bool SuperSonar::Initialize(HINSTANCE hinst) return false; } - DWORD exStyle = WS_EX_TOOLWINDOW | Shim()->GetExtendedStyle(); + DWORD exStyle = WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | Shim()->GetExtendedStyle(); HWND created = CreateWindowExW(exStyle, className, windowTitle, WS_POPUP, CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, m_hwndOwner, nullptr, hinst, this); if (!created) { @@ -269,10 +269,6 @@ LRESULT SuperSonar::BaseWndProc(UINT message, WPARAM wParam, LPARAM lParam) n case WM_NCHITTEST: return HTTRANSPARENT; - - case WM_SETCURSOR: - SetCursor(LoadCursor(nullptr, IDC_ARROW)); - return TRUE; } if (message == WM_PRIV_SHORTCUT) @@ -539,7 +535,7 @@ void SuperSonar::StartSonar() Trace::MousePointerFocused(); // Cover the entire virtual screen. // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. - SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, SWP_NOACTIVATE); + SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, 0); m_sonarPos = ptNowhere; OnMouseTimer(); UpdateMouseSnooping(); From 5d6f96559c7b37958b0176411646bc61689770af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 28 Oct 2025 02:40:20 +0100 Subject: [PATCH 16/59] Multiple toys: Exclude TitleBars from tab navigation (#42030) ## Summary of the Pull Request This PR removes title bar controls from tab navigation, solving one of hidden tab stops (the other being #40637). Affected apps: - Command Palette - Settings - Environment Variables - File Locksmith - Hosts File Editor - Registry Preview - Settings (the search box in the title bar is still tab navigable) ## PR Checklist - [x] Closes: #41944 - [ ] **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 All apps were manually tests, by pressing tab and shift + tab. --------- Co-authored-by: Niels Laute --- .../EnvironmentVariablesXAML/MainWindow.xaml | 2 +- .../FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml | 2 +- src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml | 2 +- .../cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml | 5 ++++- .../RegistryPreview/RegistryPreviewXAML/MainWindow.xaml | 2 +- .../Settings.UI/SettingsXAML/Views/ShellPage.xaml | 1 + 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml index c48b7fbb25..ae77a78caa 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml @@ -20,7 +20,7 @@ - + - + - + - + - + 516 From 1a1894472aea33f7a84863c308042be6fe688837 Mon Sep 17 00:00:00 2001 From: leileizhang Date: Tue, 28 Oct 2025 17:16:03 +0800 Subject: [PATCH 17/59] Fix package identity build issue (#43019) ## Summary of the Pull Request image Add an EntryPoint to the AppxManifest to fix the issue. ## 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 --- src/PackageIdentity/AppxManifest.xml | 6 +++--- src/PackageIdentity/PackageIdentity.vcxproj | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PackageIdentity/AppxManifest.xml b/src/PackageIdentity/AppxManifest.xml index af637229ca..2e9d52a2fa 100644 --- a/src/PackageIdentity/AppxManifest.xml +++ b/src/PackageIdentity/AppxManifest.xml @@ -36,7 +36,7 @@ - + - + - + - From a4791cc4935f67fe8bb7c3325d00ce0469b51f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 28 Oct 2025 17:22:09 +0100 Subject: [PATCH 18/59] CmdPal: Ensure CommandItemViewModel reacts to changes of replaced Command (#42982) ## Summary of the Pull Request This PR resolves the issue with CommandItemViewModel's subscription to changes in the associated Command when it gets replaced by another Command. The current implementation removes the handler from the old command but fails to attach a new one. ## Change log one-liner ## PR Checklist - [x] Closes: #42981 - [ ] **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 --- .../CommandItemViewModel.cs | 1 + .../Pages/SampleListPage.cs | 38 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs index b1a977f0de..e0d2c38262 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs @@ -306,6 +306,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa Command.PropertyChanged -= Command_PropertyChanged; Command = new(model.Command, PageContext); Command.InitializeProperties(); + Command.PropertyChanged += Command_PropertyChanged; // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs index 95f5eb84c0..2464724d50 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs @@ -190,7 +190,11 @@ internal sealed partial class SampleListPage : ListPage new CommandContextItem(new EverChangingCommand("Faces", "😁", "🥺", "😍")), new CommandContextItem(new EverChangingCommand("Hearts", "♥️", "💚", "💜", "🧡", "💛", "💙")), ], - } + }, + new ListItemChangingCommandInTime() + { + Title = "I'm a list item that changes entire command in time", + }, ]; } @@ -248,6 +252,11 @@ internal sealed partial class SampleListPage : ListPage private int _currentIndex; public EverChangingCommand(string name, params string[] icons) + : this(name, TimeSpan.FromSeconds(5), icons) + { + } + + public EverChangingCommand(string name, TimeSpan interval, params string[] icons) { _icons = icons ?? throw new ArgumentNullException(nameof(icons)); if (_icons.Length == 0) @@ -260,7 +269,7 @@ internal sealed partial class SampleListPage : ListPage Icon = new IconInfo(_icons[_currentIndex]); // Start timer to change icon and name every 5 seconds - _timer = new Timer(OnTimerElapsed, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + _timer = new Timer(OnTimerElapsed, null, interval, interval); } private void OnTimerElapsed(object state) @@ -282,4 +291,29 @@ internal sealed partial class SampleListPage : ListPage _timer?.Dispose(); } } + + internal sealed partial class ListItemChangingCommandInTime : ListItem + { + private readonly EverChangingCommand[] _commands = + [ + new("Water", TimeSpan.FromSeconds(2), "🐬", "🐳", "🐟", "🦈"), + new("Faces", TimeSpan.FromSeconds(2), "😁", "🥺", "😍"), + new("Hearts", TimeSpan.FromSeconds(2), "♥️", "💚", "💜", "🧡", "💛", "💙"), + ]; + + private int _state; + + public ListItemChangingCommandInTime() + { + Subtitle = "I change my command every 10 seconds, and the command changes it's icon every 2 seconds"; + var timer = new Timer(OnTimerElapsed, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); + this.Command = _commands[0]; + } + + private void OnTimerElapsed(object state) + { + _state = (_state + 1) % _commands.Length; + this.Command = _commands[_state]; + } + } } From d197af3da9bfa4b3c1d6f84e15cadf0df12e1e95 Mon Sep 17 00:00:00 2001 From: Sam Rueby Date: Tue, 28 Oct 2025 13:56:17 -0400 Subject: [PATCH 19/59] CmdPal's search bar now accepts page up/down keyboard strokes. (#41886) ## Summary of the Pull Request The page up/down keys now function while the search box is focused. ## PR Checklist - [ X ] Closes: #41877 - [ ] **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 Previously, the page up/down keys only performed any action while an item in the list was focused. The page up/down keys did not have any effect while the search box was focused, however the up/down arrows do have effect. This PR enables the page up/down keys while the search box is focused. There is a caveat here. The page up/down behavior is not consistent. I do not see a way to tell the ListView to perform its native page up/down function. Instead, I manually calculate roughly which item to scroll-to. Because of this, the amount of scroll between when the search box is focused and when an item in the ListView is focused is not consistent. ## Validation Steps Performed ![pageupdown](https://github.com/user-attachments/assets/b30f6e4e-03de-45bd-8570-0b06850bef24) In this GIF: 1. CmdPal appears 2. SearchBar focused, down/up arrow keys. 3. SearchBar focused, page down/up keys. 4. Tab to item in ListView 5. ListView item focused down/up arrow keys. 6. ListView item focused page down/up keys. 7. SearchBar focused 8. Filter "abc" 9. SearchBar focused page down/up keys. --- .../Messages/NavigatePageDownCommand.cs | 12 ++ .../Messages/NavigatePageUpCommand.cs | 12 ++ .../Controls/SearchBar.xaml.cs | 10 ++ .../ExtViews/ListPage.xaml.cs | 155 +++++++++++++++++- 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs new file mode 100644 index 0000000000..6c11394382 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +/// +/// Used to navigate down one page in the page when pressing the PageDown key in the SearchBox. +/// +public record NavigatePageDownCommand +{ +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs new file mode 100644 index 0000000000..1985c07438 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +/// +/// Used to navigate up one page in the page when pressing the PageUp key in the SearchBox. +/// +public record NavigatePageUpCommand +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index 5337a126c0..f5bac4a286 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -216,6 +216,16 @@ public sealed partial class SearchBar : UserControl, e.Handled = true; } + else if (e.Key == VirtualKey.PageDown) + { + WeakReferenceMessenger.Default.Send(); + e.Handled = true; + } + else if (e.Key == VirtualKey.PageUp) + { + WeakReferenceMessenger.Default.Send(); + e.Handled = true; + } if (InSuggestion) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index 55b2f368ba..a28ae3e133 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -18,6 +18,7 @@ using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; +using Windows.Foundation; using Windows.System; namespace Microsoft.CmdPal.UI; @@ -25,6 +26,8 @@ namespace Microsoft.CmdPal.UI; public sealed partial class ListPage : Page, IRecipient, IRecipient, + IRecipient, + IRecipient, IRecipient, IRecipient { @@ -82,6 +85,8 @@ public sealed partial class ListPage : Page, // RegisterAll isn't AOT compatible WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -94,6 +99,8 @@ public sealed partial class ListPage : Page, WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); @@ -181,9 +188,9 @@ public sealed partial class ListPage : Page, var notificationText = li.Title; UIHelper.AnnounceActionForAccessibility( - ItemsList, - notificationText, - "CommandPaletteSelectedItemChanged"); + ItemsList, + notificationText, + "CommandPaletteSelectedItemChanged"); } } } @@ -296,6 +303,142 @@ public sealed partial class ListPage : Page, } } + public void Receive(NavigatePageDownCommand message) + { + var indexes = CalculateTargetIndexPageUpDownScrollTo(true); + if (indexes is null) + { + return; + } + + if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) + { + ItemView.SelectedIndex = indexes.Value.TargetIndex; + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + } + + public void Receive(NavigatePageUpCommand message) + { + var indexes = CalculateTargetIndexPageUpDownScrollTo(false); + if (indexes is null) + { + return; + } + + if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) + { + ItemView.SelectedIndex = indexes.Value.TargetIndex; + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + } + + /// + /// Calculates the item index to target when performing a page up or page down + /// navigation. The calculation attempts to estimate how many items fit into + /// the visible viewport by measuring actual container heights currently visible + /// within the internal ScrollViewer. If measurements are not available a + /// fallback estimate is used. + /// + /// True to calculate a page-down target, false for page-up. + /// + /// A tuple containing the current index and the calculated target index, or null + /// if a valid calculation could not be performed (for example, missing ScrollViewer). + /// + private (int CurrentIndex, int TargetIndex)? CalculateTargetIndexPageUpDownScrollTo(bool isPageDown) + { + var scroll = FindScrollViewer(ItemView); + if (scroll is null) + { + return null; + } + + var viewportHeight = scroll.ViewportHeight; + if (viewportHeight <= 0) + { + return null; + } + + var currentIndex = ItemView.SelectedIndex < 0 ? 0 : ItemView.SelectedIndex; + var itemCount = ItemView.Items.Count; + + // Compute visible item heights within the ScrollViewer viewport + const int firstVisibleIndexNotFound = -1; + var firstVisibleIndex = firstVisibleIndexNotFound; + var visibleHeights = new List(itemCount); + + for (var i = 0; i < itemCount; i++) + { + if (ItemView.ContainerFromIndex(i) is FrameworkElement container) + { + try + { + var transform = container.TransformToVisual(scroll); + var topLeft = transform.TransformPoint(new Point(0, 0)); + var bottom = topLeft.Y + container.ActualHeight; + + // If any part of the container is inside the viewport, consider it visible + if (topLeft.Y >= 0 && bottom <= viewportHeight) + { + if (firstVisibleIndex == firstVisibleIndexNotFound) + { + firstVisibleIndex = i; + } + + visibleHeights.Add(container.ActualHeight > 0 ? container.ActualHeight : 0); + } + } + catch + { + // ignore transform errors and continue + } + } + } + + var itemsPerPage = 0; + + // Calculate how many items fit in the viewport based on their actual heights + if (visibleHeights.Count > 0) + { + double accumulated = 0; + for (var i = 0; i < visibleHeights.Count; i++) + { + accumulated += visibleHeights[i] <= 0 ? 1 : visibleHeights[i]; + itemsPerPage++; + if (accumulated >= viewportHeight) + { + break; + } + } + } + else + { + // fallback: estimate using first measured container height + double itemHeight = 0; + for (var i = currentIndex; i < itemCount; i++) + { + if (ItemView.ContainerFromIndex(i) is FrameworkElement { ActualHeight: > 0 } c) + { + itemHeight = c.ActualHeight; + break; + } + } + + if (itemHeight <= 0) + { + itemHeight = 1; + } + + itemsPerPage = Math.Max(1, (int)Math.Floor(viewportHeight / itemHeight)); + } + + var targetIndex = isPageDown + ? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage)) + : Math.Max(0, currentIndex - Math.Max(1, itemsPerPage)); + + return (currentIndex, targetIndex); + } + private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is ListPage @this) @@ -351,11 +494,11 @@ public sealed partial class ListPage : Page, } } - private ScrollViewer? FindScrollViewer(DependencyObject parent) + private static ScrollViewer? FindScrollViewer(DependencyObject parent) { - if (parent is ScrollViewer) + if (parent is ScrollViewer viewer) { - return (ScrollViewer)parent; + return viewer; } for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) From 01fb831e4efb7d50a4b321934ecb5d204f020fd7 Mon Sep 17 00:00:00 2001 From: Jaylyn Barbee <51131738+Jaylyn-Barbee@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:00:38 -0400 Subject: [PATCH 20/59] [Light Switch] Light Switch should detect changes in Windows Settings and treat as manual override (same as using shortcut) (#42882) ## Summary of the Pull Request This PR ensures that Light Switch detects changes to app/system theme from Windows Settings. This PR also introduces new behavior where switching the schedule will cause an instant update to the theme if necessary. ## PR Checklist - [x] Closes: #42878 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **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 --- .../LightSwitchService/LightSwitchService.cpp | 305 ++++++++++++------ .../LightSwitchService.vcxproj | 2 + .../LightSwitchService.vcxproj.filters | 6 + .../LightSwitchServiceObserver.cpp | 29 ++ .../LightSwitchServiceObserver.h | 16 + .../LightSwitchSettings.cpp | 6 + .../LightSwitchService/LightSwitchSettings.h | 2 +- .../LightSwitchService/SettingsObserver.h | 3 +- 8 files changed, 265 insertions(+), 104 deletions(-) create mode 100644 src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.cpp create mode 100644 src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.h diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index 7ebe4a67eb..9d51a6cf75 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include "ThemeScheduler.h" #include "ThemeHelper.h" @@ -11,11 +11,12 @@ #include #include #include +#include SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; HANDLE g_ServiceStopEvent = nullptr; -static int g_lastUpdatedDay = -1; +extern int g_lastUpdatedDay = -1; static ScheduleMode prevMode = ScheduleMode::Off; static std::wstring prevLat, prevLon; @@ -161,25 +162,18 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) Logger::info(L"[LightSwitchService] Worker thread starting..."); Logger::info(L"[LightSwitchService] Parent PID: {}", parentPid); - // Initialize settings system LightSwitchSettings::instance().InitFileWatcher(); - // Open the manual override event created by the module interface + LightSwitchServiceObserver observer({ SettingId::LightTime, + SettingId::DarkTime, + SettingId::ScheduleMode, + SettingId::Sunrise_Offset, + SettingId::Sunset_Offset }); + HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); auto applyTheme = [](int nowMinutes, int lightMinutes, int darkMinutes, const auto& settings) { - bool isLightActive = false; - - if (lightMinutes < darkMinutes) - { - // Normal case: sunrise < sunset - isLightActive = (nowMinutes >= lightMinutes && nowMinutes < darkMinutes); - } - else - { - // Wraparound case: e.g. light at 21:00, dark at 06:00 - isLightActive = (nowMinutes >= lightMinutes || nowMinutes < darkMinutes); - } + bool isLightActive = (lightMinutes < darkMinutes) ? (nowMinutes >= lightMinutes && nowMinutes < darkMinutes) : (nowMinutes >= lightMinutes || nowMinutes < darkMinutes); bool isSystemCurrentlyLight = GetCurrentSystemTheme(); bool isAppsCurrentlyLight = GetCurrentAppsTheme(); @@ -212,85 +206,72 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) } }; - // --- Initial settings load --- LightSwitchSettings::instance().LoadSettings(); auto& settings = LightSwitchSettings::instance().settings(); - // --- Initial theme application (if schedule enabled) --- + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + if (settings.scheduleMode != ScheduleMode::Off) { - SYSTEMTIME st; - GetLocalTime(&st); - int nowMinutes = st.wHour * 60 + st.wMinute; - applyTheme(nowMinutes, settings.lightTime + settings.sunrise_offset, settings.darkTime + settings.sunset_offset, settings); + applyTheme(nowMinutes, + settings.lightTime + settings.sunrise_offset, + settings.darkTime + settings.sunset_offset, + settings); + Logger::trace(L"[LightSwitchService] Initialized g_lastUpdatedDay = {}", g_lastUpdatedDay); } else { Logger::info(L"[LightSwitchService] Schedule mode is OFF - ticker suspended, waiting for manual action or mode change."); } - // --- Main loop --- + g_lastUpdatedDay = st.wDay; + ULONGLONG lastSettingsReload = 0; + for (;;) { HANDLE waits[2] = { g_ServiceStopEvent, hParent }; DWORD count = hParent ? 2 : 1; + bool skipRest = false; - LightSwitchSettings::instance().LoadSettings(); const auto& settings = LightSwitchSettings::instance().settings(); - // Check for changes in schedule mode or coordinates - bool modeChangedToSunset = (prevMode != settings.scheduleMode && - settings.scheduleMode == ScheduleMode::SunsetToSunrise); - bool coordsChanged = (prevLat != settings.latitude || prevLon != settings.longitude); + bool scheduleJustEnabled = (prevMode == ScheduleMode::Off && settings.scheduleMode != ScheduleMode::Off); + prevMode = settings.scheduleMode; - if ((modeChangedToSunset || coordsChanged) && settings.scheduleMode == ScheduleMode::SunsetToSunrise) - { - Logger::info(L"[LightSwitchService] Mode or coordinates changed, recalculating sun times."); - update_sun_times(settings); - SYSTEMTIME st; - GetLocalTime(&st); - g_lastUpdatedDay = st.wDay; - prevMode = settings.scheduleMode; - prevLat = settings.latitude; - prevLon = settings.longitude; - } - - // If schedule is off, idle but keep watching settings and manual override + // ─── Handle "Schedule Off" Mode ───────────────────────────────────────────── if (settings.scheduleMode == ScheduleMode::Off) { Logger::info(L"[LightSwitchService] Schedule mode OFF - suspending scheduler but keeping service alive."); if (!hManualOverride) - { hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); - } - HANDLE waits[4]; - DWORD count = 0; - waits[count++] = g_ServiceStopEvent; + HANDLE waitsOff[4]; + DWORD countOff = 0; + waitsOff[countOff++] = g_ServiceStopEvent; if (hParent) - waits[count++] = hParent; + waitsOff[countOff++] = hParent; if (hManualOverride) - waits[count++] = hManualOverride; - waits[count++] = LightSwitchSettings::instance().GetSettingsChangedEvent(); + waitsOff[countOff++] = hManualOverride; + waitsOff[countOff++] = LightSwitchSettings::instance().GetSettingsChangedEvent(); for (;;) { - DWORD wait = WaitForMultipleObjects(count, waits, FALSE, INFINITE); + DWORD wait = WaitForMultipleObjects(countOff, waitsOff, FALSE, INFINITE); - // --- Handle exit signals --- - if (wait == WAIT_OBJECT_0) // stop event + if (wait == WAIT_OBJECT_0) { Logger::info(L"[LightSwitchService] Stop event triggered - exiting worker loop."); - break; + goto cleanup; } if (hParent && wait == WAIT_OBJECT_0 + 1) { Logger::info(L"[LightSwitchService] Parent exited - stopping service."); - break; + goto cleanup; } - // --- Manual override triggered --- if (wait == WAIT_OBJECT_0 + (hParent ? 2 : 1)) { Logger::info(L"[LightSwitchService] Manual override received while schedule OFF."); @@ -298,15 +279,13 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) continue; } - // --- Settings file changed --- if (wait == WAIT_OBJECT_0 + (hParent ? 3 : 2)) { Logger::trace(L"[LightSwitchService] Settings change event triggered, reloading settings..."); - ResetEvent(LightSwitchSettings::instance().GetSettingsChangedEvent()); - LightSwitchSettings::instance().LoadSettings(); const auto& newSettings = LightSwitchSettings::instance().settings(); + lastSettingsReload = GetTickCount64(); if (newSettings.scheduleMode != ScheduleMode::Off) { @@ -315,63 +294,137 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) } } } + + continue; } + // ─── Normal Schedule Loop ─────────────────────────────────────────────────── + ULONGLONG nowTick = GetTickCount64(); + bool recentSettingsReload = (nowTick - lastSettingsReload < 5000); - // --- When schedule is active, run once per minute --- - SYSTEMTIME st; - GetLocalTime(&st); - int nowMinutes = st.wHour * 60 + st.wMinute; - - // Refresh suntimes at day boundary - if ((g_lastUpdatedDay != st.wDay) && (settings.scheduleMode == ScheduleMode::SunsetToSunrise)) + if (g_lastUpdatedDay != -1) { - update_sun_times(settings); - g_lastUpdatedDay = st.wDay; - Logger::info(L"[LightSwitchService] Recalculated sun times at new day boundary."); - } + bool manualOverrideActive = (hManualOverride && WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); - // Have to do this again in case settings got updated in the refresh suntimes chunk - LightSwitchSettings::instance().LoadSettings(); - const auto& currentSettings = LightSwitchSettings::instance().settings(); - - wchar_t msg[160]; - swprintf_s(msg, - L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d | mode=%d", - st.wHour, - st.wMinute, - currentSettings.lightTime / 60, - currentSettings.lightTime % 60, - currentSettings.darkTime / 60, - currentSettings.darkTime % 60, - static_cast(currentSettings.scheduleMode)); - Logger::info(msg); - - // --- Manual override check --- - bool manualOverrideActive = false; - if (hManualOverride) - { - manualOverrideActive = (WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); - } - - if (manualOverrideActive) - { - if (nowMinutes == (currentSettings.lightTime + currentSettings.sunrise_offset) % 1440 || - nowMinutes == (currentSettings.darkTime + currentSettings.sunset_offset) % 1440) + if (settings.scheduleMode != ScheduleMode::Off && !recentSettingsReload && !scheduleJustEnabled) { - ResetEvent(hManualOverride); - Logger::info(L"[LightSwitchService] Manual override cleared at boundary"); + Logger::debug(L"[LightSwitchService] Checking if manual override is active..."); + bool manualOverrideActive = (hManualOverride && WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); + Logger::debug(L"[LightSwitchService] Manual override active = {}", manualOverrideActive); + + if (!manualOverrideActive) + { + bool currentSystemTheme = GetCurrentSystemTheme(); + bool currentAppsTheme = GetCurrentAppsTheme(); + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + bool shouldBeLight = (settings.lightTime < settings.darkTime) ? (nowMinutes >= settings.lightTime && nowMinutes < settings.darkTime) : (nowMinutes >= settings.lightTime || nowMinutes < settings.darkTime); + + Logger::debug(L"[LightSwitchService] shouldBeLight = {}", shouldBeLight); + + if ((settings.changeSystem && (currentSystemTheme != shouldBeLight)) || + (settings.changeApps && (currentAppsTheme != shouldBeLight))) + { + Logger::debug(L"[LightSwitchService] External theme change detected - enabling manual override"); + + if (!hManualOverride) + { + hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + if (!hManualOverride) + hManualOverride = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + } + + if (hManualOverride) + { + SetEvent(hManualOverride); + Logger::info(L"[LightSwitchService] Detected manual theme change outside of LightSwitch. Triggering manual override."); + skipRest = true; + } + } + } } else { - Logger::info(L"[LightSwitchService] Skipping schedule due to manual override"); - goto sleep_until_next_minute; + Logger::debug(L"[LightSwitchService] Skipping external-change detection (schedule off, recent reload, or just enabled)."); } } - applyTheme(nowMinutes, currentSettings.lightTime + currentSettings.sunrise_offset, currentSettings.darkTime + currentSettings.sunset_offset, currentSettings); + // ─── Apply Schedule Logic ─────────────────────────────────────────────────── + if (!skipRest) + { + bool modeChangedToSunset = (prevMode != settings.scheduleMode && + settings.scheduleMode == ScheduleMode::SunsetToSunrise); + bool coordsChanged = (prevLat != settings.latitude || prevLon != settings.longitude); - sleep_until_next_minute: + if ((modeChangedToSunset || coordsChanged) && settings.scheduleMode == ScheduleMode::SunsetToSunrise) + { + Logger::info(L"[LightSwitchService] Mode or coordinates changed, recalculating sun times."); + update_sun_times(settings); + SYSTEMTIME st; + GetLocalTime(&st); + g_lastUpdatedDay = st.wDay; + prevMode = settings.scheduleMode; + prevLat = settings.latitude; + prevLon = settings.longitude; + } + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + if ((g_lastUpdatedDay != st.wDay) && (settings.scheduleMode == ScheduleMode::SunsetToSunrise)) + { + update_sun_times(settings); + g_lastUpdatedDay = st.wDay; + Logger::info(L"[LightSwitchService] Recalculated sun times at new day boundary."); + } + + LightSwitchSettings::instance().LoadSettings(); + const auto& currentSettings = LightSwitchSettings::instance().settings(); + + wchar_t msg[160]; + swprintf_s(msg, + L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d | mode=%d", + st.wHour, + st.wMinute, + currentSettings.lightTime / 60, + currentSettings.lightTime % 60, + currentSettings.darkTime / 60, + currentSettings.darkTime % 60, + static_cast(currentSettings.scheduleMode)); + Logger::info(msg); + + bool manualOverrideActive = false; + if (hManualOverride) + manualOverrideActive = (WaitForSingleObject(hManualOverride, 0) == WAIT_OBJECT_0); + + if (manualOverrideActive) + { + if (nowMinutes == (currentSettings.lightTime + currentSettings.sunrise_offset) % 1440 || + nowMinutes == (currentSettings.darkTime + currentSettings.sunset_offset) % 1440) + { + ResetEvent(hManualOverride); + Logger::info(L"[LightSwitchService] Manual override cleared at boundary"); + } + else + { + Logger::info(L"[LightSwitchService] Skipping schedule due to manual override"); + skipRest = true; + } + } + + if (!skipRest) + applyTheme(nowMinutes, + currentSettings.lightTime + currentSettings.sunrise_offset, + currentSettings.darkTime + currentSettings.sunset_offset, + currentSettings); + } + + // ─── Wait For Next Minute Tick Or Stop Event ──────────────────────────────── + SYSTEMTIME st; GetLocalTime(&st); int msToNextMinute = (60 - st.wSecond) * 1000 - st.wMilliseconds; if (msToNextMinute < 50) @@ -390,6 +443,7 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) } } +cleanup: if (hManualOverride) CloseHandle(hManualOverride); if (hParent) @@ -398,6 +452,53 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) return 0; } +void ApplyThemeNow() +{ + LightSwitchSettings::instance().LoadSettings(); + const auto& settings = LightSwitchSettings::instance().settings(); + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + bool shouldBeLight = false; + if (settings.lightTime < settings.darkTime) + shouldBeLight = (nowMinutes >= settings.lightTime && nowMinutes < settings.darkTime); + else + shouldBeLight = (nowMinutes >= settings.lightTime || nowMinutes < settings.darkTime); + + bool isSystemCurrentlyLight = GetCurrentSystemTheme(); + bool isAppsCurrentlyLight = GetCurrentAppsTheme(); + + Logger::info(L"[LightSwitchService] Applying (if needed) theme immediately due to schedule change."); + + if (shouldBeLight) + { + if (settings.changeSystem && !isSystemCurrentlyLight) + { + SetSystemTheme(true); + Logger::info(L"[LightSwitchService] Changing system theme to light mode."); + } + if (settings.changeApps && !isAppsCurrentlyLight) + { + SetAppsTheme(true); + Logger::info(L"[LightSwitchService] Changing apps theme to light mode."); + } + } + else + { + if (settings.changeSystem && isSystemCurrentlyLight) + { + SetSystemTheme(false); + Logger::info(L"[LightSwitchService] Changing system theme to dark mode."); + } + if (settings.changeApps && isAppsCurrentlyLight) + { + SetAppsTheme(false); + Logger::info(L"[LightSwitchService] Changing apps theme to dark mode."); + } + } +} int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index b082250f61..832f8ab50c 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -74,6 +74,7 @@ + @@ -84,6 +85,7 @@ + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters index f5aa05afc3..7aa39aa0c2 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -33,6 +33,9 @@ Source Files + + Source Files + @@ -53,6 +56,9 @@ Header Files + + Header Files + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.cpp new file mode 100644 index 0000000000..e28a2625fe --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.cpp @@ -0,0 +1,29 @@ +#include "LightSwitchServiceObserver.h" +#include +#include "LightSwitchSettings.h" + +// These are defined elsewhere in your service module (ServiceWorkerThread.cpp) +extern int g_lastUpdatedDay; +void ApplyThemeNow(); + +void LightSwitchServiceObserver::SettingsUpdate(SettingId id) +{ + Logger::info(L"[LightSwitchService] Setting changed: {}", static_cast(id)); + g_lastUpdatedDay = -1; + ApplyThemeNow(); +} + +bool LightSwitchServiceObserver::WantsToBeNotified(SettingId id) const noexcept +{ + switch (id) + { + case SettingId::LightTime: + case SettingId::DarkTime: + case SettingId::ScheduleMode: + case SettingId::Sunrise_Offset: + case SettingId::Sunset_Offset: + return true; + default: + return false; + } +} diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.h new file mode 100644 index 0000000000..14c88e656d --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchServiceObserver.h @@ -0,0 +1,16 @@ +#pragma once + +#include "SettingsObserver.h" + +// The LightSwitchServiceObserver reacts when LightSwitchSettings changes. +class LightSwitchServiceObserver : public SettingsObserver +{ +public: + explicit LightSwitchServiceObserver(std::unordered_set observedSettings) : + SettingsObserver(std::move(observedSettings)) + { + } + + void SettingsUpdate(SettingId id) override; + bool WantsToBeNotified(SettingId id) const noexcept override; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp index a7f44cca6d..2e00417001 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -39,6 +39,7 @@ void LightSwitchSettings::InitFileWatcher() GetSettingsFileName(), [this]() { Logger::info(L"[LightSwitchSettings] Settings file changed, signaling event."); + LoadSettings(); SetEvent(m_settingsChangedEvent); }); } @@ -65,6 +66,11 @@ void LightSwitchSettings::NotifyObservers(SettingId id) const } } +HANDLE LightSwitchSettings::GetSettingsChangedEvent() const +{ + return m_settingsChangedEvent; +} + void LightSwitchSettings::LoadSettings() { try diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h index 32d011313f..e5e993c696 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -79,7 +79,7 @@ public: void LoadSettings(); - HANDLE GetSettingsChangedEvent() const { return m_settingsChangedEvent; } + HANDLE GetSettingsChangedEvent() const; private: LightSwitchSettings(); diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h index 88d0194eef..b0ddde72ec 100644 --- a/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h +++ b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h @@ -2,6 +2,7 @@ #include #include "SettingsConstants.h" +#include "LightSwitchSettings.h" class LightSwitchSettings; @@ -22,7 +23,7 @@ public: // Override this in your class to respond to updates virtual void SettingsUpdate(SettingId type) {} - bool WantsToBeNotified(SettingId type) const noexcept + virtual bool WantsToBeNotified(SettingId type) const noexcept { return m_observedSettings.contains(type); } From 103429b4d7b362d602aa529c3bdde40fb6d4e035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 28 Oct 2025 20:26:01 +0100 Subject: [PATCH 21/59] CmdPal: Add hidden window as owner for tool windows (#42902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR changes the method used to hide tool windows from the taskbar and Alt+Tab to a more reliable approach. Previously, this was achieved by adding `WS_EX_TOOLWINDOW` to an unowned top-level window, which proved unreliable in several scenarios. The new implementation assigns a hidden window as the owner of each tool window. This ensures that the window does not appear on the taskbar even when the Windows setting **Settings → System → Multitasking → On the taskbar, show all opened windows** is set to **On all desktops**. ## Change log one-liner Fixes Command Palette windows occasionally appearing on the taskbar under certain system settings. ## PR Checklist - [x] Closes: #42395 - [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 - [x] **Dev docs:** Added/updated - [x] **New binaries:** none - [x] **Documentation updated:** no need ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed Tested alongside the stable CmdPal on a system with --- .../Helpers/WindowExtensions.cs | 2 +- .../HiddenOwnerWindowBehavior.cs | 89 +++++++++++++++++++ .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 16 ++-- .../Microsoft.CmdPal.UI/NativeMethods.txt | 7 +- .../Microsoft.CmdPal.UI/ToastWindow.xaml.cs | 11 +-- 5 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/HiddenOwnerWindowBehavior.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs index 0866a57589..ee782766bc 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs @@ -22,7 +22,7 @@ internal static class WindowExtensions appWindow.SetIcon(@"Assets\icon.ico"); } - private static HWND GetWindowHwnd(this Window window) + public static HWND GetWindowHwnd(this Window window) { return window is null ? throw new ArgumentNullException(nameof(window)) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/HiddenOwnerWindowBehavior.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/HiddenOwnerWindowBehavior.cs new file mode 100644 index 0000000000..7b01afd20a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/HiddenOwnerWindowBehavior.cs @@ -0,0 +1,89 @@ +// 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 Microsoft.CmdPal.UI.Helpers; +using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Microsoft.CmdPal.UI; + +/// +/// Provides behavior to control taskbar and Alt+Tab presence by assigning a hidden owner +/// and toggling extended window styles for a target window. +/// +internal sealed class HiddenOwnerWindowBehavior +{ + private HWND _hiddenOwnerHwnd; + private Window? _hiddenWindow; + + /// + /// Shows or hides a window in the taskbar (and Alt+Tab) by updating ownership and extended window styles. + /// + /// The to update. + /// True to show the window in the taskbar (and Alt+Tab); false to hide it from both. + /// + /// When hiding the window, a hidden owner is assigned and + /// is enabled to keep it out of the taskbar and Alt+Tab. When showing, the owner is cleared and + /// is enabled to ensure taskbar presence. Since tool + /// windows use smaller corner radii, the normal rounded corners are enforced via + /// . + /// + /// + public void ShowInTaskbar(Window target, bool isVisibleInTaskbar) + { + /* + * There are the three main ways to control whether a window appears on the taskbar: + * https://learn.microsoft.com/en-us/windows/win32/shell/taskbar#managing-taskbar-buttons + * + * 1. Set the window's owner. Owned windows do not appear on the taskbar: + * Turns out this is the most reliable way to hide a window from the taskbar and ALT+TAB. WinForms and WPF uses this method + * to back their ShowInTaskbar property as well. + * + * 2. Use the WS_EX_TOOLWINDOW extended window style: + * This mostly works, with some reports that it silently fails in some cases. The biggest issue + * is that for certain Windows settings (like Multitasking -> Show taskbar buttons on all displays = On all desktops), + * the taskbar button is always shown even for tool windows. + * + * 3. Using ITaskbarList: + * This is what AppWindow.IsShownInSwitchers uses, but it's COM-based and more complex, and can + * fail if Explorer isn't running or responding. It could be a good backup, if needed. + */ + + var visibleHwnd = target.GetWindowHwnd(); + + if (isVisibleInTaskbar) + { + // remove any owner window + PInvoke.SetWindowLongPtr(visibleHwnd, WINDOW_LONG_PTR_INDEX.GWLP_HWNDPARENT, HWND.Null); + } + else + { + // Set the hidden window as the owner of the target window + var hiddenHwnd = EnsureHiddenOwner(); + PInvoke.SetWindowLongPtr(visibleHwnd, WINDOW_LONG_PTR_INDEX.GWLP_HWNDPARENT, hiddenHwnd); + } + + // Tool windows don't show up in ALT+TAB, and don't show up in the taskbar + // Tool window and app window styles are mutually exclusive, change both just to be safe + target.ToggleExtendedWindowStyle(WINDOW_EX_STYLE.WS_EX_TOOLWINDOW, !isVisibleInTaskbar); + target.ToggleExtendedWindowStyle(WINDOW_EX_STYLE.WS_EX_APPWINDOW, isVisibleInTaskbar); + + // Since tool windows have smaller corner radii, we need to force the normal ones + target.SetCornerPreference(DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND); + } + + private HWND EnsureHiddenOwner() + { + if (_hiddenOwnerHwnd.IsNull) + { + _hiddenWindow = new Window(); + _hiddenOwnerHwnd = _hiddenWindow.GetWindowHwnd(); + } + + return _hiddenOwnerHwnd; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index b33feedc28..d8d2649b59 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -57,6 +57,7 @@ public sealed partial class MainWindow : WindowEx, private readonly List _hotkeys = []; private readonly KeyboardListener _keyboardListener; private readonly LocalKeyboardListener _localKeyboardListener; + private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new(); private bool _ignoreHotKeyWhenFullScreen = true; private DesktopAcrylicController? _acrylicController; @@ -65,6 +66,7 @@ public sealed partial class MainWindow : WindowEx, public MainWindow() { InitializeComponent(); + HideWindow(); _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); @@ -73,6 +75,8 @@ public sealed partial class MainWindow : WindowEx, CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value); } + _hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached); + _keyboardListener = new KeyboardListener(); _keyboardListener.Start(); @@ -126,16 +130,6 @@ public sealed partial class MainWindow : WindowEx, // Force window to be created, and then cloaked. This will offset initial animation when the window is shown. HideWindow(); - - ApplyWindowStyle(); - } - - private void ApplyWindowStyle() - { - // Tool windows don't show up in ALT+TAB, and don't show up in the taskbar - // Since tool windows have smaller corner radii, we need to force the normal ones - this.ToggleExtendedWindowStyle(WINDOW_EX_STYLE.WS_EX_TOOLWINDOW, !Debugger.IsAttached); - this.SetCornerPreference(DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND); } private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) @@ -264,7 +258,7 @@ public sealed partial class MainWindow : WindowEx, // because that would make it hard to debug the app if (Debugger.IsAttached) { - ApplyWindowStyle(); + _hiddenOwnerBehavior.ShowInTaskbar(this, true); } // Just to be sure, SHOW our hwnd. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt index a653eb726a..fc5a608199 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt @@ -58,4 +58,9 @@ GetModuleHandle GetWindowLong SetWindowLong -WINDOW_EX_STYLE \ No newline at end of file +WINDOW_EX_STYLE +CreateWindowEx +WNDCLASSEXW +RegisterClassEx +GetStockObject +GetModuleHandle \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs index 9f0e63edcc..1a49fec8e0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs @@ -25,11 +25,11 @@ public sealed partial class ToastWindow : WindowEx, IRecipient { private readonly HWND _hwnd; + private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + private readonly HiddenOwnerWindowBehavior _hiddenOwnerWindowBehavior = new(); public ToastViewModel ViewModel { get; } = new(); - private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); - public ToastWindow() { this.InitializeComponent(); @@ -39,12 +39,7 @@ public sealed partial class ToastWindow : WindowEx, this.SetIcon(); AppWindow.Title = RS_.GetString("ToastWindowTitle"); AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; - - // Tool windows don't show up in ALT+TAB, and don't show up in the taskbar - // Since tool windows have smaller corner radii, we need to force the normal ones - // to visually match system toasts. - this.ToggleExtendedWindowStyle(WINDOW_EX_STYLE.WS_EX_TOOLWINDOW, true); - this.SetCornerPreference(DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND); + _hiddenOwnerWindowBehavior.ShowInTaskbar(this, false); _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); PInvoke.EnableWindow(_hwnd, false); From c4e96c7ee959e106649e88b4cc8137ee2a646053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Tue, 28 Oct 2025 20:28:46 +0100 Subject: [PATCH 22/59] CmdPal: Add hints about bookmark placeholders to the Add/Edit Bookmark form (#42793) ## Summary of the Pull Request This PR adds a short explanation to the Add/Edit Bookmark form, describing how to use placeholders in bookmark URLs or paths image ## PR Checklist - [x] Closes: #42265 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 2 +- .../Pages/AddBookmarkForm.cs | 65 ++++++++++++++++--- .../Properties/Resources.Designer.cs | 50 +++++++++++++- .../Properties/Resources.resx | 18 ++++- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 37ce368394..cd757f1552 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -750,7 +750,7 @@ INITDIALOG INITGUID INITTOLOGFONTSTRUCT INLINEPREFIX -Inlines +inlines INPC inproc INPUTHARDWARE diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs index 6931064a90..e165bfd0d4 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs @@ -30,25 +30,72 @@ internal sealed partial class AddBookmarkForm : FormContent "type": "Input.Text", "style": "text", "id": "bookmark", - "value": {{JsonSerializer.Serialize(url, BookmarkSerializationContext.Default.String)}}, - "label": "{{Resources.bookmarks_form_bookmark_label}}", + "value": {{EncodeString(url)}}, + "label": {{EncodeString(Resources.bookmarks_form_bookmark_label)}}, "isRequired": true, - "errorMessage": "{{Resources.bookmarks_form_bookmark_required}}" + "errorMessage": {{EncodeString(Resources.bookmarks_form_bookmark_required)}}, + "placeholder": {{EncodeString(Resources.bookmarks_form_bookmark_placeholder)}} }, { "type": "Input.Text", "style": "text", "id": "name", - "label": "{{Resources.bookmarks_form_name_label}}", - "value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}}, - "isRequired": false, - "errorMessage": "{{Resources.bookmarks_form_name_required}}" + "label": {{EncodeString(Resources.bookmarks_form_name_label)}}, + "value": {{EncodeString(name)}}, + "isRequired": false + }, + { + "type": "RichTextBlock", + "inlines": [ + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text1)}}, + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text2)}}, + "fontType": "Monospace", + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text3)}}, + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text4)}}, + "fontType": "Monospace", + "size": "Small" + } + ] } ], "actions": [ { "type": "Action.Submit", - "title": "{{Resources.bookmarks_form_save}}", + "title": {{EncodeString(Resources.bookmarks_form_save)}}, "data": { "name": "name", "bookmark": "bookmark" @@ -59,6 +106,8 @@ internal sealed partial class AddBookmarkForm : FormContent """; } + private static string EncodeString(string s) => JsonSerializer.Serialize(s, BookmarkSerializationContext.Default.String); + public override CommandResult SubmitForm(string payload) { var formInput = JsonNode.Parse(payload); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs index e5a65f2db3..02f95cf479 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -177,6 +177,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Enter URL or file path, you can use {placeholders}, e.g. https://www.bing.com/search?q={Query}. + /// + public static string bookmarks_form_bookmark_placeholder { + get { + return ResourceManager.GetString("bookmarks_form_bookmark_placeholder", resourceCulture); + } + } + /// /// Looks up a localized string similar to URL or file path is required. /// @@ -187,7 +196,44 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } /// - /// Looks up a localized string similar to Name. + /// Looks up a localized string similar to You can add placeholders to bookmarks, and Command Palette will prompt you to enter their values when you open the bookmark. + ///A placeholder looks like this:. + /// + public static string bookmarks_form_hint_text1 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {placeholder}. + /// + public static string bookmarks_form_hint_text2 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to — for example:. + /// + public static string bookmarks_form_hint_text3 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text3", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to https://www.bing.com/search?q={Query}. + /// + public static string bookmarks_form_hint_text4 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text4", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name (optional). /// public static string bookmarks_form_name_label { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx index 763c697f2e..45f57c0d77 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx @@ -140,7 +140,7 @@ "Terminal" should be the localized name of the Windows Terminal - Name + Name (optional) Save @@ -185,4 +185,20 @@ Failed to open {0} + + You can add placeholders to bookmarks, and Command Palette will prompt you to enter their values when you open the bookmark. +A placeholder looks like this: + + + {placeholder} + + + — for example: + + + https://www.bing.com/search?q={Query} + + + Enter URL or file path, you can use {placeholders}, e.g. https://www.bing.com/search?q={Query} + \ No newline at end of file From de00cbf20abf5178e27c8d4147d153602708e76f Mon Sep 17 00:00:00 2001 From: Guilherme <57814418+DevLGuilherme@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:32:09 -0300 Subject: [PATCH 23/59] [CmdPal] Fix filters visibility on non-ListPage (#42828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR aims to fix the issue where filters from a ListPage remain visible when navigating to other pages. ## PR Checklist - [x] Closes: #42827 - [ ] **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 ### Before: ![FiltersIssue](https://github.com/user-attachments/assets/b0ad6059-9a11-4e12-821d-7202358e25bb) ### After: ![FiltersFix](https://github.com/user-attachments/assets/b9ee71ee-cb5d-4ef9-b9fc-bc2e2a710b5c) ## Validation Steps Performed --------- Co-authored-by: Jiří Polášek --- .../ListViewModel.cs | 21 ++++- .../PageViewModel.cs | 2 + .../Controls/FiltersDropDown.xaml | 2 +- .../AllIssueSamplesIndexPage.cs | 29 +++++++ ...ibleAfterSwitchingFromListToContentPage.cs | 76 +++++++++++++++++++ .../SamplePagesExtension/SamplesListPage.cs | 6 ++ 6 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/AllIssueSamplesIndexPage.cs create mode 100644 src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/SamplePageForIssue42827_FilterDropDownStaysVisibleAfterSwitchingFromListToContentPage.cs diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs index ebfe80533f..f9d58d0e68 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -97,6 +97,17 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent = new(new(null), PageContext); } + private void FiltersPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(FiltersViewModel.Filters)) + { + var filtersViewModel = sender as FiltersViewModel; + var hasFilters = filtersViewModel?.Filters.Length > 0; + HasFilters = hasFilters; + UpdateProperty(nameof(HasFilters)); + } + } + // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(); @@ -586,8 +597,11 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent.SlowInitializeProperties(); + Filters?.PropertyChanged -= FiltersPropertyChanged; Filters = new(new(model.Filters), PageContext); - Filters.InitializeProperties(); + Filters?.PropertyChanged += FiltersPropertyChanged; + + Filters?.InitializeProperties(); UpdateProperty(nameof(Filters)); FetchItems(); @@ -686,8 +700,10 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent.SlowInitializeProperties(); break; case nameof(Filters): + Filters?.PropertyChanged -= FiltersPropertyChanged; Filters = new(new(model.Filters), PageContext); - Filters.InitializeProperties(); + Filters?.PropertyChanged += FiltersPropertyChanged; + Filters?.InitializeProperties(); break; case nameof(IsLoading): UpdateEmptyContent(); @@ -757,6 +773,7 @@ public partial class ListViewModel : PageViewModel, IDisposable FilteredItems.Clear(); } + Filters?.PropertyChanged -= FiltersPropertyChanged; Filters?.SafeCleanup(); var model = _model.Unsafe; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 2d750c7df3..62434a632a 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -70,6 +70,8 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public bool HasSearchBox { get; protected set; } = true; + public bool HasFilters { get; protected set; } + public IconInfoViewModel Icon { get; protected set; } public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml index 36a14965a1..f8c888e8f8 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml @@ -77,7 +77,7 @@ SelectedValue="{x:Bind ViewModel.CurrentFilter, Mode=OneWay}" SelectionChanged="FiltersComboBox_SelectionChanged" Style="{StaticResource ComboBoxStyle}" - Visibility="{x:Bind ViewModel.ShouldShowFilters, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + Visibility="{x:Bind ViewModel.ShouldShowFilters, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Collapsed}"> + diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png index 73621edfc0f883365e89a3a26a6dfd9d5f6d36d8..78a9a18606333acba281b372b402348e62b3f36d 100644 GIT binary patch literal 26499 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%0D+>il$3#TE6&x(GbE>4= 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 Tko-lx00000NkvXXu0mjfoFv2( diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs new file mode 100644 index 0000000000..ba7d33f4fb --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs @@ -0,0 +1,54 @@ +// 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 AdvancedPaste.Models; +using Microsoft.SemanticKernel; + +namespace AdvancedPaste.Helpers; + +/// +/// Helper class for extracting AI service usage information from chat messages. +/// +public static class AIServiceUsageHelper +{ + /// + /// Extracts AI service usage information from OpenAI chat message metadata. + /// + /// The chat message containing usage metadata. + /// AI service usage information or AIServiceUsage.None if extraction fails. + public static AIServiceUsage GetOpenAIServiceUsage(ChatMessageContent chatMessage) + { + // Try to get usage information from metadata + if (chatMessage.Metadata?.TryGetValue("Usage", out var usageObj) == true) + { + // Handle different possible usage types through reflection to be version-agnostic + var usageType = usageObj.GetType(); + + try + { + // Try common property names for prompt tokens + var promptTokensProp = usageType.GetProperty("PromptTokens") ?? + usageType.GetProperty("InputTokens") ?? + usageType.GetProperty("InputTokenCount"); + + var completionTokensProp = usageType.GetProperty("CompletionTokens") ?? + usageType.GetProperty("OutputTokens") ?? + usageType.GetProperty("OutputTokenCount"); + + if (promptTokensProp != null && completionTokensProp != null) + { + var promptTokens = (int)(promptTokensProp.GetValue(usageObj) ?? 0); + var completionTokens = (int)(completionTokensProp.GetValue(usageObj) ?? 0); + return new AIServiceUsage(promptTokens, completionTokens); + } + } + catch + { + // If reflection fails, fall back to no usage + } + } + + return AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs new file mode 100644 index 0000000000..9f824d3399 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs @@ -0,0 +1,84 @@ +// 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.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; + +namespace AdvancedPaste.Helpers +{ + internal static class ClipboardItemHelper + { + /// + /// Creates a ClipboardItem from current clipboard data. + /// + public static async Task CreateFromCurrentClipboardAsync( + DataPackageView clipboardData, + ClipboardFormat availableFormats, + DateTimeOffset? timestamp = null, + BitmapImage existingImage = null) + { + if (clipboardData == null || availableFormats == ClipboardFormat.None) + { + return null; + } + + var clipboardItem = new ClipboardItem + { + Format = availableFormats, + Timestamp = timestamp, + }; + + // Text or HTML content + if (availableFormats.HasFlag(ClipboardFormat.Text) || availableFormats.HasFlag(ClipboardFormat.Html)) + { + clipboardItem.Content = await clipboardData.GetTextOrEmptyAsync(); + } + + // Image content + else if (availableFormats.HasFlag(ClipboardFormat.Image)) + { + // Reuse existing image if provided + if (existingImage != null) + { + clipboardItem.Image = existingImage; + } + else + { + clipboardItem.Image = await TryCreateBitmapImageAsync(clipboardData); + } + } + + return clipboardItem; + } + + /// + /// Creates a BitmapImage from clipboard data. + /// + private static async Task TryCreateBitmapImageAsync(DataPackageView clipboardData) + { + try + { + var imageReference = await clipboardData.GetBitmapAsync(); + if (imageReference != null) + { + using (var imageStream = await imageReference.OpenReadAsync()) + { + var bitmapImage = new BitmapImage(); + await bitmapImage.SetSourceAsync(imageStream); + return bitmapImage; + } + } + } + catch + { + // Silently fail - caller can check for null + } + + return null; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs index 529773f9a6..2cd7554a50 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Models; using ManagedCommon; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.Data.Html; @@ -180,6 +182,46 @@ internal static class DataPackageHelpers } } + internal static async Task GetClipboardTextOrThrowAsync(this DataPackageView dataPackageView, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dataPackageView); + + try + { + if (dataPackageView.Contains(StandardDataFormats.Text)) + { + return await dataPackageView.GetTextAsync(); + } + + if (dataPackageView.Contains(StandardDataFormats.Html)) + { + var html = await dataPackageView.GetHtmlFormatAsync(); + return HtmlUtilities.ConvertToText(html); + } + + if (dataPackageView.Contains(StandardDataFormats.Bitmap)) + { + var bitmap = await dataPackageView.GetImageContentAsync(); + if (bitmap != null) + { + return await OcrHelpers.ExtractTextAsync(bitmap, cancellationToken); + } + } + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + throw CreateClipboardTextMissingException(ex); + } + + throw CreateClipboardTextMissingException(); + } + + private static PasteActionException CreateClipboardTextMissingException(Exception innerException = null) + { + var message = ResourceLoaderInstance.ResourceLoader.GetString("ClipboardEmptyWarning"); + return new PasteActionException(message, innerException ?? new InvalidOperationException("Clipboard does not contain text content.")); + } + internal static async Task GetHtmlContentAsync(this DataPackageView dataPackageView) => dataPackageView.Contains(StandardDataFormats.Html) ? await dataPackageView.GetHtmlFormatAsync() : string.Empty; @@ -195,6 +237,22 @@ internal static class DataPackageHelpers return null; } + internal static async Task GetPreviewBitmapAsync(this DataPackageView dataPackageView) + { + var stream = await dataPackageView.GetImageStreamAsync(); + if (stream == null) + { + return null; + } + + using (stream) + { + var bitmapImage = new BitmapImage(); + bitmapImage.SetSource(stream); + return bitmapImage; + } + } + private static async Task GetImageStreamAsync(this DataPackageView dataPackageView) { if (dataPackageView.Contains(StandardDataFormats.StorageItems)) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index 105fe2c0d8..e32cf61af4 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using AdvancedPaste.Models; using Microsoft.PowerToys.Settings.UI.Library; @@ -12,7 +13,7 @@ namespace AdvancedPaste.Settings { public interface IUserSettings { - public bool IsAdvancedAIEnabled { get; } + public bool IsAIEnabled { get; } public bool ShowCustomPreview { get; } @@ -22,6 +23,10 @@ namespace AdvancedPaste.Settings public IReadOnlyList AdditionalActions { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } + public event EventHandler Changed; + + Task SetActiveAIProviderAsync(string providerId); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 8a25b70f07..b6b6c19734 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -13,6 +13,7 @@ using AdvancedPaste.Models; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Utilities; +using Windows.Security.Credentials; namespace AdvancedPaste.Settings { @@ -33,7 +34,7 @@ namespace AdvancedPaste.Settings public event EventHandler Changed; - public bool IsAdvancedAIEnabled { get; private set; } + public bool IsAIEnabled { get; private set; } public bool ShowCustomPreview { get; private set; } @@ -43,13 +44,16 @@ namespace AdvancedPaste.Settings public IReadOnlyList CustomActions => _customActions; + public PasteAIConfiguration PasteAIConfiguration { get; private set; } + public UserSettings(IFileSystem fileSystem) { _settingsUtils = new SettingsUtils(fileSystem); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + PasteAIConfiguration = new PasteAIConfiguration(); _additionalActions = []; _customActions = []; _taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); @@ -94,13 +98,16 @@ namespace AdvancedPaste.Settings var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); if (settings != null) { + bool migratedLegacyEnablement = TryMigrateLegacyAIEnablement(settings); + void UpdateSettings() { var properties = settings.Properties; - IsAdvancedAIEnabled = properties.IsAdvancedAIEnabled; + IsAIEnabled = properties.IsAIEnabled; ShowCustomPreview = properties.ShowCustomPreview; CloseAfterLosingFocus = properties.CloseAfterLosingFocus; + PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); var sourceAdditionalActions = properties.AdditionalActions; (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = @@ -126,6 +133,11 @@ namespace AdvancedPaste.Settings Task.Factory .StartNew(UpdateSettings, CancellationToken.None, TaskCreationOptions.None, _taskScheduler) .Wait(); + + if (migratedLegacyEnablement) + { + settings.Save(_settingsUtils); + } } retry = false; @@ -144,6 +156,114 @@ namespace AdvancedPaste.Settings } } + private static bool TryMigrateLegacyAIEnablement(AdvancedPasteSettings settings) + { + if (settings?.Properties is null) + { + return false; + } + + if (settings.Properties.IsAIEnabled || !LegacyOpenAIKeyExists()) + { + return false; + } + + settings.Properties.IsAIEnabled = true; + return true; + } + + private static bool LegacyOpenAIKeyExists() + { + try + { + PasswordVault vault = new(); + return vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey") is not null; + } + catch (Exception) + { + return false; + } + } + + public async Task SetActiveAIProviderAsync(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return; + } + + await Task.Run(() => + { + lock (_loadingSettingsLock) + { + var settings = _settingsUtils.GetSettingsOrDefault(AdvancedPasteModuleName); + var configuration = settings?.Properties?.PasteAIConfiguration; + var providers = configuration?.Providers; + + if (configuration == null || providers == null || providers.Count == 0) + { + return; + } + + var target = providers.FirstOrDefault(provider => string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase)); + if (target == null) + { + return; + } + + if (string.Equals(configuration.ActiveProvider?.Id, providerId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + configuration.ActiveProviderId = providerId; + + foreach (var provider in providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + + try + { + settings.Save(_settingsUtils); + } + catch (Exception ex) + { + Logger.LogError("Failed to set active AI provider", ex); + return; + } + + try + { + Task.Factory + .StartNew( + () => + { + PasteAIConfiguration.ActiveProviderId = providerId; + + if (PasteAIConfiguration.Providers is not null) + { + foreach (var provider in PasteAIConfiguration.Providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + } + + Changed?.Invoke(this, EventArgs.Empty); + }, + CancellationToken.None, + TaskCreationOptions.None, + _taskScheduler) + .Wait(); + } + catch (Exception ex) + { + Logger.LogError("Failed to dispatch active AI provider change", ex); + } + } + }); + } + public void Dispose() { Dispose(true); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs index 1013108bc9..16814e7001 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs @@ -2,6 +2,7 @@ // 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 AdvancedPaste.Helpers; using Microsoft.UI.Xaml.Media.Imaging; using Windows.ApplicationModel.DataTransfer; @@ -12,10 +13,15 @@ public class ClipboardItem { public string Content { get; set; } - public ClipboardHistoryItem Item { get; set; } - public BitmapImage Image { get; set; } + public ClipboardFormat Format { get; set; } + + public DateTimeOffset? Timestamp { get; set; } + + // Only used for clipboard history items that have a ClipboardHistoryItem + public ClipboardHistoryItem Item { get; set; } + public string Description => !string.IsNullOrEmpty(Content) ? Content : Image is not null ? ResourceLoaderInstance.ResourceLoader.GetString("ClipboardHistoryImage") : string.Empty; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs new file mode 100644 index 0000000000..b9566ed481 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs @@ -0,0 +1,227 @@ +// 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.Linq; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Amazon; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.HuggingFace; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services; + +public sealed class AdvancedAIKernelService : KernelServiceBase +{ + private sealed record RuntimeConfiguration( + AIServiceType ServiceType, + string ModelName, + string Endpoint, + string DeploymentName, + string ModelPath, + string SystemPrompt, + bool ModerationEnabled) : IKernelRuntimeConfiguration; + + private readonly IAICredentialsProvider credentialsProvider; + + public AdvancedAIKernelService( + IAICredentialsProvider credentialsProvider, + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) + : base(queryCacheService, promptModerationService, userSettings, customActionTransformService) + { + ArgumentNullException.ThrowIfNull(credentialsProvider); + + this.credentialsProvider = credentialsProvider; + } + + protected override string AdvancedAIModelName => GetRuntimeConfiguration().ModelName; + + protected override PromptExecutionSettings PromptExecutionSettings => CreatePromptExecutionSettings(); + + protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) + { + ArgumentNullException.ThrowIfNull(kernelBuilder); + + var runtimeConfig = GetRuntimeConfiguration(); + var serviceType = runtimeConfig.ServiceType; + var modelName = runtimeConfig.ModelName; + var requiresApiKey = RequiresApiKey(serviceType); + var apiKey = string.Empty; + if (requiresApiKey) + { + this.credentialsProvider.Refresh(); + apiKey = (this.credentialsProvider.GetKey() ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"An API key is required for {serviceType} but none was found in the credential vault."); + } + } + + var endpoint = string.IsNullOrWhiteSpace(runtimeConfig.Endpoint) ? null : runtimeConfig.Endpoint.Trim(); + var deployment = string.IsNullOrWhiteSpace(runtimeConfig.DeploymentName) ? modelName : runtimeConfig.DeploymentName; + + switch (serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(modelName, apiKey, serviceId: modelName); + break; + case AIServiceType.AzureOpenAI: + kernelBuilder.AddAzureOpenAIChatCompletion(deployment, RequireEndpoint(endpoint, serviceType), apiKey, serviceId: modelName); + break; + default: + throw new NotSupportedException($"Service type '{runtimeConfig.ServiceType}' is not supported"); + } + } + + protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) + { + return AIServiceUsageHelper.GetOpenAIServiceUsage(chatMessage); + } + + protected override bool ShouldModerateAdvancedAI() + { + if (!TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return false; + } + + return runtimeConfig.ModerationEnabled && (runtimeConfig.ServiceType == AIServiceType.OpenAI || runtimeConfig.ServiceType == AIServiceType.AzureOpenAI); + } + + private static string GetModelName(PasteAIProviderDefinition config) + { + if (!string.IsNullOrWhiteSpace(config?.ModelName)) + { + return config.ModelName; + } + + return "gpt-4o"; + } + + protected override IKernelRuntimeConfiguration GetRuntimeConfiguration() + { + if (TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return runtimeConfig; + } + + throw new InvalidOperationException("No Advanced AI provider is configured."); + } + + private bool TryGetRuntimeConfiguration(out IKernelRuntimeConfiguration runtimeConfig) + { + runtimeConfig = null; + + if (!TryResolveAdvancedProvider(out var provider)) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + if (!IsServiceTypeSupported(serviceType)) + { + return false; + } + + runtimeConfig = new RuntimeConfiguration( + serviceType, + GetModelName(provider), + provider.EndpointUrl, + provider.DeploymentName, + provider.ModelPath, + provider.SystemPrompt, + provider.ModerationEnabled); + return true; + } + + private bool TryResolveAdvancedProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = this.UserSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedProvider(PasteAIProviderDefinition provider) + { + if (provider is null || !provider.EnableAdvancedAI) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + return IsServiceTypeSupported(serviceType); + } + + private static bool IsServiceTypeSupported(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI or AIServiceType.AzureOpenAI; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return true; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} configuration but was not provided."); + } + + private PromptExecutionSettings CreatePromptExecutionSettings() + { + var serviceType = GetRuntimeConfiguration().ServiceType; + return new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Required(), + Temperature = 0.01, + }; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs new file mode 100644 index 0000000000..562ea3976c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs @@ -0,0 +1,22 @@ +// 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 AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformResult + { + public CustomActionTransformResult(string content, AIServiceUsage usage) + { + Content = content; + Usage = usage; + } + + public string Content { get; } + + public AIServiceUsage Usage { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs new file mode 100644 index 0000000000..721a96070d --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -0,0 +1,200 @@ +// 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.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using AdvancedPaste.Telemetry; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformService : ICustomActionTransformService + { + private const string DefaultSystemPrompt = """ + You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. + Do not output anything else besides the reformatted clipboard content. + """; + + private readonly IPromptModerationService promptModerationService; + private readonly IPasteAIProviderFactory providerFactory; + private readonly IAICredentialsProvider credentialsProvider; + private readonly IUserSettings userSettings; + + public CustomActionTransformService(IPromptModerationService promptModerationService, IPasteAIProviderFactory providerFactory, IAICredentialsProvider credentialsProvider, IUserSettings userSettings) + { + this.promptModerationService = promptModerationService; + this.providerFactory = providerFactory; + this.credentialsProvider = credentialsProvider; + this.userSettings = userSettings; + } + + public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) + { + var pasteConfig = userSettings?.PasteAIConfiguration; + var providerConfig = BuildProviderConfig(pasteConfig); + + return await TransformAsync(prompt, inputText, providerConfig, cancellationToken, progress); + } + + private async Task TransformAsync(string prompt, string inputText, PasteAIConfig providerConfig, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(providerConfig); + + if (string.IsNullOrWhiteSpace(prompt)) + { + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + if (string.IsNullOrWhiteSpace(inputText)) + { + Logger.LogWarning("Clipboard has no usable text data"); + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + var systemPrompt = providerConfig.SystemPrompt ?? DefaultSystemPrompt; + + var fullPrompt = (systemPrompt ?? string.Empty) + "\n\n" + (inputText ?? string.Empty); + + if (ShouldModerate(providerConfig)) + { + await promptModerationService.ValidateAsync(fullPrompt, cancellationToken); + } + + try + { + var provider = providerFactory.CreateProvider(providerConfig); + + var request = new PasteAIRequest + { + Prompt = prompt, + InputText = inputText, + SystemPrompt = systemPrompt, + }; + + var providerContent = await provider.ProcessPasteAsync( + request, + cancellationToken, + progress); + + var usage = request.Usage; + var content = providerContent ?? string.Empty; + + // Log endpoint usage + var endpointEvent = new AdvancedPasteEndpointUsageEvent(providerConfig.ProviderType); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + + Logger.LogDebug($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} complete; ModelName={providerConfig.Model ?? string.Empty}, PromptTokens={usage.PromptTokens}, CompletionTokens={usage.CompletionTokens}"); + + return new CustomActionTransformResult(content, usage); + } + catch (Exception ex) + { + Logger.LogError($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} failed", ex); + + if (ex is PasteActionException or OperationCanceledException) + { + throw; + } + + var statusCode = ExtractStatusCode(ex); + var failureMessage = providerConfig.ProviderType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => ErrorHelpers.TranslateErrorText(statusCode), + _ => ResourceLoaderInstance.ResourceLoader.GetString("PasteError"), + }; + + throw new PasteActionException(failureMessage, ex); + } + } + + private static int ExtractStatusCode(Exception exception) + { + if (exception is HttpOperationException httpOperationException) + { + return (int?)httpOperationException.StatusCode ?? -1; + } + + if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode is HttpStatusCode statusCode) + { + return (int)statusCode; + } + + return -1; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config) + { + config ??= new PasteAIConfiguration(); + var provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition(); + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + var systemPrompt = string.IsNullOrWhiteSpace(provider.SystemPrompt) ? DefaultSystemPrompt : provider.SystemPrompt; + var apiKey = AcquireApiKey(serviceType); + var modelName = provider.ModelName; + + var providerConfig = new PasteAIConfig + { + ProviderType = serviceType, + ApiKey = apiKey, + Model = modelName, + Endpoint = provider.EndpointUrl, + DeploymentName = provider.DeploymentName, + LocalModelPath = provider.ModelPath, + ModelPath = provider.ModelPath, + SystemPrompt = systemPrompt, + ModerationEnabled = provider.ModerationEnabled, + }; + + return providerConfig; + } + + private string AcquireApiKey(AIServiceType serviceType) + { + if (!RequiresApiKey(serviceType)) + { + return string.Empty; + } + + credentialsProvider.Refresh(); + return credentialsProvider.GetKey() ?? string.Empty; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + AIServiceType.Anthropic => false, + AIServiceType.AmazonBedrock => false, + _ => true, + }; + } + + private static bool ShouldModerate(PasteAIConfig providerConfig) + { + if (providerConfig is null || !providerConfig.ModerationEnabled) + { + return false; + } + + return providerConfig.ProviderType == AIServiceType.OpenAI || providerConfig.ProviderType == AIServiceType.AzureOpenAI; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs new file mode 100644 index 0000000000..4b4148f995 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using LanguageModelProvider; +using Microsoft.Extensions.AI; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions; + +public sealed class FoundryLocalPasteProvider : IPasteAIProvider +{ + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.FoundryLocal, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new FoundryLocalPasteProvider(config)); + + private static readonly LanguageModelService LanguageModels = LanguageModelService.CreateDefault(); + + private readonly PasteAIConfig _config; + + public FoundryLocalPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + } + + public string ProviderName => AIServiceType.FoundryLocal.ToNormalizedKey(); + + public string DisplayName => string.IsNullOrWhiteSpace(_config?.Model) ? "Foundry Local" : _config.Model; + + public async Task IsAvailableAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return await FoundryLocalModelProvider.Instance.IsAvailable().ConfigureAwait(false); + } + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new PasteActionException( + "System prompt is required for Foundry Local", + new ArgumentException("System prompt must be provided", nameof(request))); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new PasteActionException( + "Prompt and input text are required", + new ArgumentException("Prompt and input text must be provided", nameof(request))); + } + + var modelReference = _config?.Model; + if (string.IsNullOrWhiteSpace(modelReference)) + { + throw new PasteActionException( + "No Foundry Local model selected", + new InvalidOperationException("Model identifier is required"), + aiServiceMessage: "Please select a model in the AI provider settings. Model identifier should be in the format 'fl://model-name'."); + } + + cancellationToken.ThrowIfCancellationRequested(); + var chatClient = LanguageModels.GetClient(modelReference); + if (chatClient is null) + { + throw new PasteActionException( + $"Unable to load Foundry Local model: {modelReference}", + new InvalidOperationException("Chat client resolution failed"), + aiServiceMessage: "The model may not be downloaded or the Foundry Local service may not be running. Please check the model status in settings."); + } + + // Extract actual model ID from the URL (format: fl://modelId) + var actualModelId = modelReference.Replace("fl://", string.Empty).Trim('/'); + + var userMessageContent = $""" + User instructions: + {prompt} + + Text: + {inputText} + + Output: + """; + + var chatMessages = new List + { + new(ChatRole.System, systemPrompt), + new(ChatRole.User, userMessageContent), + }; + + var chatOptions = CreateChatOptions(_config?.SystemPrompt, actualModelId); + + progress?.Report(0.1); + + var response = await chatClient.GetResponseAsync(chatMessages, chatOptions, cancellationToken).ConfigureAwait(false); + + progress?.Report(0.8); + + var responseText = GetResponseText(response); + request.Usage = ToUsage(response.Usage); + + progress?.Report(1.0); + + return responseText ?? string.Empty; + } + catch (OperationCanceledException) + { + // Let cancellation exceptions pass through unchanged + throw; + } + catch (PasteActionException) + { + // Let our custom exceptions pass through unchanged + throw; + } + catch (Exception ex) + { + // Wrap any other exceptions with context + var modelInfo = !string.IsNullOrWhiteSpace(_config?.Model) ? $" (Model: {_config.Model})" : string.Empty; + throw new PasteActionException( + $"Failed to generate response using Foundry Local{modelInfo}", + ex, + aiServiceMessage: $"Error details: {ex.Message}"); + } + } + + private static ChatOptions CreateChatOptions(string systemPrompt, string modelReference) + { + var options = new ChatOptions + { + ModelId = modelReference, + }; + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + options.Instructions = systemPrompt; + } + + return options; + } + + private static string GetResponseText(ChatResponse response) + { + if (!string.IsNullOrWhiteSpace(response.Text)) + { + return response.Text; + } + + if (response.Messages is { Count: > 0 }) + { + var lastMessage = response.Messages.LastOrDefault(m => !string.IsNullOrWhiteSpace(m.Text)); + if (!string.IsNullOrWhiteSpace(lastMessage?.Text)) + { + return lastMessage.Text; + } + } + + return string.Empty; + } + + private static AIServiceUsage ToUsage(UsageDetails usageDetails) + { + if (usageDetails is null) + { + return AIServiceUsage.None; + } + + int promptTokens = (int)(usageDetails.InputTokenCount ?? 0); + int completionTokens = (int)(usageDetails.OutputTokenCount ?? 0); + + if (promptTokens == 0 && completionTokens == 0) + { + return AIServiceUsage.None; + } + + return new AIServiceUsage(promptTokens, completionTokens); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs new file mode 100644 index 0000000000..1c3ecb980c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs @@ -0,0 +1,17 @@ +// 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.Threading; +using System.Threading.Tasks; + +using AdvancedPaste.Settings; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface ICustomActionTransformService + { + Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs new file mode 100644 index 0000000000..764d99f942 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProvider + { + Task IsAvailableAsync(CancellationToken cancellationToken); + + Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs new file mode 100644 index 0000000000..aacc61bec9 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProviderFactory + { + IPasteAIProvider CreateProvider(PasteAIConfig config); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs new file mode 100644 index 0000000000..f4d45ccd74 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class LocalModelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.Onnx, + AIServiceType.ML, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new LocalModelPasteProvider(config)); + + private readonly PasteAIConfig _config; + + public LocalModelPasteProvider(PasteAIConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + // TODO: Implement local model inference logic using _config.LocalModelPath/_config.ModelPath + var content = request.InputText ?? string.Empty; + request.Usage = AIServiceUsage.None; + return Task.FromResult(content); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs new file mode 100644 index 0000000000..1d8a60f041 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs @@ -0,0 +1,32 @@ +// 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 AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace AdvancedPaste.Services.CustomActions +{ + public class PasteAIConfig + { + public AIServiceType ProviderType { get; set; } + + public string Model { get; set; } + + public string ApiKey { get; set; } + + public string Endpoint { get; set; } + + public string DeploymentName { get; set; } + + public string LocalModelPath { get; set; } + + public string ModelPath { get; set; } + + public string SystemPrompt { get; set; } + + public bool ModerationEnabled { get; set; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs new file mode 100644 index 0000000000..7339b4e4e3 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderFactory : IPasteAIProviderFactory + { + private static readonly IReadOnlyList ProviderRegistrations = new[] + { + SemanticKernelPasteProvider.Registration, + LocalModelPasteProvider.Registration, + FoundryLocalPasteProvider.Registration, + }; + + private static readonly IReadOnlyDictionary> ProviderFactories = CreateProviderFactories(); + + public IPasteAIProvider CreateProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + var serviceType = config.ProviderType; + if (serviceType == AIServiceType.Unknown) + { + serviceType = AIServiceType.OpenAI; + config.ProviderType = serviceType; + } + + if (!ProviderFactories.TryGetValue(serviceType, out var factory)) + { + throw new NotSupportedException($"Provider {config.ProviderType} not supported"); + } + + return factory(config); + } + + private static IReadOnlyDictionary> CreateProviderFactories() + { + var map = new Dictionary>(); + + foreach (var registration in ProviderRegistrations) + { + Register(map, registration.SupportedTypes, registration.Factory); + } + + return map; + } + + private static void Register(Dictionary> map, IReadOnlyCollection types, Func factory) + { + foreach (var type in types) + { + map[type] = factory; + } + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs new file mode 100644 index 0000000000..6bd78450e8 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderRegistration + { + public PasteAIProviderRegistration(IReadOnlyCollection supportedTypes, Func factory) + { + SupportedTypes = supportedTypes ?? throw new ArgumentNullException(nameof(supportedTypes)); + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public IReadOnlyCollection SupportedTypes { get; } + + public Func Factory { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs new file mode 100644 index 0000000000..0e15c93e05 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs @@ -0,0 +1,19 @@ +// 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 AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIRequest + { + public string Prompt { get; init; } + + public string InputText { get; init; } + + public string SystemPrompt { get; init; } + + public AIServiceUsage Usage { get; set; } = AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs new file mode 100644 index 0000000000..00517e96d8 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Amazon; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.HuggingFace; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class SemanticKernelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection SupportedTypes = new[] + { + AIServiceType.OpenAI, + AIServiceType.AzureOpenAI, + AIServiceType.Mistral, + AIServiceType.Google, + AIServiceType.HuggingFace, + AIServiceType.AzureAIInference, + AIServiceType.Ollama, + AIServiceType.Anthropic, + AIServiceType.AmazonBedrock, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new SemanticKernelPasteProvider(config)); + + private readonly PasteAIConfig _config; + private readonly AIServiceType _serviceType; + + public SemanticKernelPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + _serviceType = config.ProviderType; + if (_serviceType == AIServiceType.Unknown) + { + _serviceType = AIServiceType.OpenAI; + _config.ProviderType = _serviceType; + } + } + + public IReadOnlyCollection SupportedServiceTypes => SupportedTypes; + + public Task IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public async Task ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress progress) + { + ArgumentNullException.ThrowIfNull(request); + + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new ArgumentException("System prompt must be provided", nameof(request)); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new ArgumentException("Prompt and input text must be provided", nameof(request)); + } + + var userMessageContent = $""" + User instructions: + {prompt} + + Clipboard Content: + {inputText} + + Output: + """; + + var executionSettings = CreateExecutionSettings(); + var kernel = CreateKernel(); + var modelId = _config.Model; + + IChatCompletionService chatService; + if (!string.IsNullOrWhiteSpace(modelId)) + { + try + { + chatService = kernel.GetRequiredService(modelId); + } + catch (Exception) + { + chatService = kernel.GetRequiredService(); + } + } + else + { + chatService = kernel.GetRequiredService(); + } + + var chatHistory = new ChatHistory(); + chatHistory.AddSystemMessage(systemPrompt); + chatHistory.AddUserMessage(userMessageContent); + + var response = await chatService.GetChatMessageContentAsync(chatHistory, executionSettings, kernel, cancellationToken); + chatHistory.Add(response); + + request.Usage = AIServiceUsageHelper.GetOpenAIServiceUsage(response); + return response.Content; + } + + private Kernel CreateKernel() + { + var kernelBuilder = Kernel.CreateBuilder(); + var endpoint = string.IsNullOrWhiteSpace(_config.Endpoint) ? null : _config.Endpoint.Trim(); + var apiKey = _config.ApiKey?.Trim() ?? string.Empty; + + if (RequiresApiKey(_serviceType) && string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"API key is required for {_serviceType} but was not provided."); + } + + switch (_serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(_config.Model, apiKey, serviceId: _config.Model); + break; + case AIServiceType.AzureOpenAI: + var deploymentName = string.IsNullOrWhiteSpace(_config.DeploymentName) ? _config.Model : _config.DeploymentName; + kernelBuilder.AddAzureOpenAIChatCompletion(deploymentName, RequireEndpoint(endpoint, _serviceType), apiKey, serviceId: _config.Model); + break; + case AIServiceType.Mistral: + kernelBuilder.AddMistralChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.Google: + kernelBuilder.AddGoogleAIGeminiChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.HuggingFace: + kernelBuilder.AddHuggingFaceChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.AzureAIInference: + kernelBuilder.AddAzureAIInferenceChatCompletion(_config.Model, apiKey: apiKey, endpoint: new Uri(endpoint)); + break; + case AIServiceType.Ollama: + kernelBuilder.AddOllamaChatCompletion(_config.Model, endpoint: new Uri(endpoint)); + break; + case AIServiceType.Anthropic: + kernelBuilder.AddBedrockChatCompletionService(_config.Model); + break; + case AIServiceType.AmazonBedrock: + kernelBuilder.AddBedrockChatCompletionService(_config.Model); + break; + + default: + throw new NotSupportedException($"Provider '{_config.ProviderType}' is not supported by {nameof(SemanticKernelPasteProvider)}"); + } + + return kernelBuilder.Build(); + } + + private PromptExecutionSettings CreateExecutionSettings() + { + return _serviceType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings + { + Temperature = 0.01, + MaxTokens = 2000, + FunctionChoiceBehavior = null, + }, + _ => new PromptExecutionSettings(), + }; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Ollama => false, + AIServiceType.Anthropic => false, + AIServiceType.AmazonBedrock => false, + _ => true, + }; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} but was not provided."); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs new file mode 100644 index 0000000000..2542f7310e --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs @@ -0,0 +1,188 @@ +// 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.Linq; +using System.Threading; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Windows.Security.Credentials; + +namespace AdvancedPaste.Services; + +/// +/// Enhanced credentials provider that supports different AI service types +/// Keys are stored in Windows Credential Vault with service-specific identifiers +/// +public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider +{ + private sealed class CredentialSlot + { + public AIServiceType ServiceType { get; set; } = AIServiceType.Unknown; + + public string ProviderId { get; set; } = string.Empty; + + public (string Resource, string Username)? Entry { get; set; } + + public string Key { get; set; } = string.Empty; + } + + private readonly IUserSettings _userSettings; + private readonly CredentialSlot _slot; + private readonly Lock _syncRoot = new(); + + public EnhancedVaultCredentialsProvider(IUserSettings userSettings) + { + _userSettings = userSettings ?? throw new ArgumentNullException(nameof(userSettings)); + + _slot = new CredentialSlot(); + + Refresh(); + } + + public string GetKey() + { + using (_syncRoot.EnterScope()) + { + UpdateSlot(forceRefresh: false); + return _slot.Key; + } + } + + public bool IsConfigured() + { + return !string.IsNullOrEmpty(GetKey()); + } + + public bool Refresh() + { + using (_syncRoot.EnterScope()) + { + return UpdateSlot(forceRefresh: true); + } + } + + private bool UpdateSlot(bool forceRefresh) + { + var (serviceType, providerId) = ResolveCredentialTarget(); + var desiredServiceType = NormalizeServiceType(serviceType); + providerId ??= string.Empty; + + var hasChanged = false; + + if (_slot.ServiceType != desiredServiceType || !string.Equals(_slot.ProviderId, providerId, StringComparison.Ordinal)) + { + _slot.ServiceType = desiredServiceType; + _slot.ProviderId = providerId; + _slot.Entry = BuildCredentialEntry(desiredServiceType, providerId); + forceRefresh = true; + hasChanged = true; + } + + if (!forceRefresh) + { + return hasChanged; + } + + var newKey = LoadKey(_slot.Entry); + if (!string.Equals(_slot.Key, newKey, StringComparison.Ordinal)) + { + _slot.Key = newKey; + hasChanged = true; + } + + return hasChanged; + } + + private (AIServiceType ServiceType, string ProviderId) ResolveCredentialTarget() + { + var provider = _userSettings.PasteAIConfiguration?.ActiveProvider; + if (provider is null) + { + return (AIServiceType.OpenAI, string.Empty); + } + + return (provider.ServiceTypeKind, provider.Id ?? string.Empty); + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static string LoadKey((string Resource, string Username)? entry) + { + if (entry is null) + { + return string.Empty; + } + + try + { + var credential = new PasswordVault().Retrieve(entry.Value.Resource, entry.Value.Username); + return credential?.Password ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + + private static (string Resource, string Username)? BuildCredentialEntry(AIServiceType serviceType, string providerId) + { + string resource; + string serviceKey; + + switch (serviceType) + { + case AIServiceType.OpenAI: + resource = "https://platform.openai.com/api-keys"; + serviceKey = "openai"; + break; + case AIServiceType.AzureOpenAI: + resource = "https://azure.microsoft.com/products/ai-services/openai-service"; + serviceKey = "azureopenai"; + break; + case AIServiceType.AzureAIInference: + resource = "https://azure.microsoft.com/products/ai-services/ai-inference"; + serviceKey = "azureaiinference"; + break; + case AIServiceType.Mistral: + resource = "https://console.mistral.ai/account/api-keys"; + serviceKey = "mistral"; + break; + case AIServiceType.Google: + resource = "https://ai.google.dev/"; + serviceKey = "google"; + break; + case AIServiceType.HuggingFace: + resource = "https://huggingface.co/settings/tokens"; + serviceKey = "huggingface"; + break; + case AIServiceType.FoundryLocal: + case AIServiceType.ML: + case AIServiceType.Onnx: + case AIServiceType.Ollama: + case AIServiceType.Anthropic: + case AIServiceType.AmazonBedrock: + return null; + default: + return null; + } + + string username = $"PowerToys_AdvancedPaste_PasteAI_{serviceKey}_{NormalizeProviderIdentifier(providerId)}"; + return (resource, username); + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs index 54759b7dc8..7aa6f63b19 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs @@ -4,11 +4,26 @@ namespace AdvancedPaste.Services; +/// +/// Provides access to AI credentials stored for Advanced Paste scenarios. +/// public interface IAICredentialsProvider { - bool IsConfigured { get; } + /// + /// Gets a value indicating whether any credential is configured. + /// + /// when a non-empty credential exists for the active AI provider. + bool IsConfigured(); - string Key { get; } + /// + /// Retrieves the credential for the active AI provider. + /// + /// Credential string or when missing. + string GetKey(); + /// + /// Refreshes the cached credential for the active AI provider. + /// + /// when the credential changed. bool Refresh(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs deleted file mode 100644 index 75f1df259e..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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.Threading; -using System.Threading.Tasks; - -namespace AdvancedPaste.Services; - -public interface ICustomTextTransformService -{ - Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress); -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs new file mode 100644 index 0000000000..d634c13e30 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services; + +/// +/// Represents runtime information required to configure an AI kernel service. +/// +public interface IKernelRuntimeConfiguration +{ + AIServiceType ServiceType { get; } + + string ModelName { get; } + + string Endpoint { get; } + + string DeploymentName { get; } + + string ModelPath { get; } + + string SystemPrompt { get; } + + bool ModerationEnabled { get; } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index e921b21e54..0ea9ef40bc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -5,15 +5,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Models.KernelQueryCache; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; using AdvancedPaste.Telemetry; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -21,15 +22,20 @@ using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheService, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : IKernelService +public abstract class KernelServiceBase( + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) : IKernelService { private const string PromptParameterName = "prompt"; private readonly IKernelQueryCacheService _queryCacheService = queryCacheService; private readonly IPromptModerationService _promptModerationService = promptModerationService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly IUserSettings _userSettings = userSettings; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; - protected abstract string ModelName { get; } + protected abstract string AdvancedAIModelName { get; } protected abstract PromptExecutionSettings PromptExecutionSettings { get; } @@ -37,6 +43,8 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi protected abstract AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage); + protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(); + public async Task TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress progress) { Logger.LogTrace(); @@ -132,21 +140,20 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, CancellationToken cancellationToken) { + var runtimeConfig = GetRuntimeConfiguration(); + ChatHistory chatHistory = []; - chatHistory.AddSystemMessage(""" - You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. - You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. - The user will put in a request to format their clipboard data and you will fulfill it. - You will not directly see the output clipboard content, and do not need to provide it in the chat. You just need to do the transform operations as needed. - If you are unable to fulfill the request, end with an error message in the language of the user's request. - """); + chatHistory.AddSystemMessage(runtimeConfig.SystemPrompt); chatHistory.AddSystemMessage($"Available clipboard formats: {await kernel.GetDataFormatsAsync()}"); chatHistory.AddUserMessage(prompt); - await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + if (ShouldModerateAdvancedAI()) + { + await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + } - var chatResult = await kernel.GetRequiredService() + var chatResult = await kernel.GetRequiredService(AdvancedAIModelName) .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel, cancellationToken); chatHistory.Add(chatResult); @@ -175,10 +182,18 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return ([], AIServiceUsage.None); } + protected IUserSettings UserSettings => _userSettings; + private void LogResult(bool cacheUsed, bool isSavedQuery, IEnumerable actionChain, AIServiceUsage usage) { - AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new(cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, ModelName, AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); + AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new(cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, AdvancedAIModelName, AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); PowerToysTelemetry.Log.WriteEvent(telemetryEvent); + + // Log endpoint usage + var runtimeConfig = GetRuntimeConfiguration(); + var endpointEvent = new AdvancedPasteEndpointUsageEvent(runtimeConfig.ServiceType); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + var logEvent = new AIServiceFormatEvent(telemetryEvent); Logger.LogDebug($"{nameof(TransformClipboardAsync)} complete; {logEvent.ToJsonString()}"); } @@ -191,20 +206,96 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return kernelBuilder.Build(); } - private IEnumerable GetKernelFunctions() => - from format in Enum.GetValues() - let metadata = PasteFormat.MetadataDict[format] - let coreDescription = metadata.KernelFunctionDescription - where !string.IsNullOrEmpty(coreDescription) - let requiresPrompt = metadata.RequiresPrompt - orderby requiresPrompt descending - select KernelFunctionFactory.CreateFromMethod( - method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) - : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), - functionName: format.ToString(), - description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", - parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, - returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + private IEnumerable GetKernelFunctions() + { + // Get standard format functions + var standardFunctions = + from format in Enum.GetValues() + let metadata = PasteFormat.MetadataDict[format] + let coreDescription = metadata.KernelFunctionDescription + where !string.IsNullOrEmpty(coreDescription) + let requiresPrompt = metadata.RequiresPrompt + orderby requiresPrompt descending + select KernelFunctionFactory.CreateFromMethod( + method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) + : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), + functionName: format.ToString(), + description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", + parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + + HashSet usedFunctionNames = new(Enum.GetNames(), StringComparer.OrdinalIgnoreCase); + + // Get custom action functions + var customActionFunctions = _userSettings.CustomActions + .Where(customAction => !string.IsNullOrWhiteSpace(customAction.Name) && !string.IsNullOrWhiteSpace(customAction.Prompt)) + .Select(customAction => + { + var sanitizedBaseName = SanitizeFunctionName(customAction.Name); + var functionName = GetUniqueFunctionName(sanitizedBaseName, usedFunctionNames, customAction.Id); + var description = string.IsNullOrWhiteSpace(customAction.Description) + ? $"Runs the \"{customAction.Name}\" custom action." + : customAction.Description; + return KernelFunctionFactory.CreateFromMethod( + method: async (Kernel kernel) => await ExecuteCustomActionAsync(kernel, customAction.Prompt), + functionName: functionName, + description: description, + parameters: null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + }); + + return standardFunctions.Concat(customActionFunctions); + } + + private static string GetUniqueFunctionName(string baseName, HashSet usedFunctionNames, int customActionId) + { + ArgumentNullException.ThrowIfNull(usedFunctionNames); + + var candidate = string.IsNullOrEmpty(baseName) ? "_CustomAction" : baseName; + + if (usedFunctionNames.Add(candidate)) + { + return candidate; + } + + int suffix = 1; + while (true) + { + var nextCandidate = $"{candidate}_{customActionId}_{suffix}"; + if (usedFunctionNames.Add(nextCandidate)) + { + return nextCandidate; + } + + suffix++; + } + } + + private static string SanitizeFunctionName(string name) + { + // Remove invalid characters and ensure the function name is valid for kernel + var sanitized = new string(name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); + + // Ensure it starts with a letter or underscore + if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]) && sanitized[0] != '_') + { + sanitized = "_" + sanitized; + } + + // Ensure it's not empty + return string.IsNullOrEmpty(sanitized) ? "_CustomAction" : sanitized; + } + + private Task ExecuteCustomActionAsync(Kernel kernel, string fixedPrompt) => + ExecuteTransformAsync( + kernel, + new ActionChainItem(PasteFormats.CustomTextTransformation, Arguments: new() { { PromptParameterName, fixedPrompt } }), + async dataPackageView => + { + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); + var result = await _customActionTransformService.TransformTextAsync(fixedPrompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); + return DataPackageHelpers.CreateFromText(result?.Content ?? string.Empty); + }); private Task ExecutePromptTransformAsync(Kernel kernel, PasteFormats format, string prompt) => ExecuteTransformAsync( @@ -212,7 +303,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi new ActionChainItem(format, Arguments: new() { { PromptParameterName, prompt } }), async dataPackageView => { - var input = await dataPackageView.GetTextAsync(); + var input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); string output = await GetPromptBasedOutput(format, prompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); return DataPackageHelpers.CreateFromText(output); }); @@ -220,7 +311,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress progress) => format switch { - PasteFormats.CustomTextTransformation => await _customTextTransformService.TransformTextAsync(prompt, input, cancellationToken, progress), + PasteFormats.CustomTextTransformation => (await _customActionTransformService.TransformTextAsync(prompt, input, cancellationToken, progress))?.Content ?? string.Empty, _ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)), }; @@ -281,4 +372,9 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi var usageString = usage.HasUsage ? $" [{usage}]" : string.Empty; return $"-> {role}: {redactedContent}{usageString}"; } + + protected virtual bool ShouldModerateAdvancedAI() + { + return false; + } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs deleted file mode 100644 index b6aa156b9d..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs +++ /dev/null @@ -1,113 +0,0 @@ -// 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.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -using AdvancedPaste.Helpers; -using AdvancedPaste.Models; -using AdvancedPaste.Telemetry; -using Azure; -using Azure.AI.OpenAI; -using ManagedCommon; -using Microsoft.PowerToys.Telemetry; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class CustomTextTransformService(IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService) : ICustomTextTransformService -{ - private const string ModelName = "gpt-3.5-turbo-instruct"; - - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - private readonly IPromptModerationService _promptModerationService = promptModerationService; - - private async Task GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken) - { - var fullPrompt = systemInstructions + "\n\n" + userMessage; - - await _promptModerationService.ValidateAsync(fullPrompt, cancellationToken); - - OpenAIClient azureAIClient = new(_aiCredentialsProvider.Key); - - var response = await azureAIClient.GetCompletionsAsync( - new() - { - DeploymentName = ModelName, - Prompts = - { - fullPrompt, - }, - Temperature = 0.01F, - MaxTokens = 2000, - }, - cancellationToken); - - if (response.Value.Choices[0].FinishReason == "length") - { - Logger.LogDebug("Cut off due to length constraints"); - } - - return response; - } - - public async Task TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress progress) - { - if (string.IsNullOrWhiteSpace(prompt)) - { - return string.Empty; - } - - if (string.IsNullOrWhiteSpace(inputText)) - { - Logger.LogWarning("Clipboard has no usable text data"); - return string.Empty; - } - - string systemInstructions = -$@"You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. -Do not output anything else besides the reformatted clipboard content."; - - string userMessage = -$@"User instructions: -{prompt} - -Clipboard Content: -{inputText} - -Output: -"; - - try - { - var response = await GetAICompletionAsync(systemInstructions, userMessage, cancellationToken); - - var usage = response.Usage; - AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.PromptTokens, usage.CompletionTokens, ModelName); - PowerToysTelemetry.Log.WriteEvent(telemetryEvent); - var logEvent = new AIServiceFormatEvent(telemetryEvent); - - Logger.LogDebug($"{nameof(TransformTextAsync)} complete; {logEvent.ToJsonString()}"); - - return response.Choices[0].Text; - } - catch (Exception ex) - { - Logger.LogError($"{nameof(TransformTextAsync)} failed", ex); - - AdvancedPasteGenerateCustomErrorEvent errorEvent = new(ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); - PowerToysTelemetry.Log.WriteEvent(errorEvent); - - if (ex is PasteActionException or OperationCanceledException) - { - throw; - } - else - { - throw new PasteActionException(ErrorHelpers.TranslateErrorText((ex as RequestFailedException)?.Status ?? -1), ex); - } - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs deleted file mode 100644 index b19a6d51cb..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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 AdvancedPaste.Models; -using Azure.AI.OpenAI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.OpenAI; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class KernelService(IKernelQueryCacheService queryCacheService, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : - KernelServiceBase(queryCacheService, promptModerationService, customTextTransformService) -{ - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - - protected override string ModelName => "gpt-4o"; - - protected override PromptExecutionSettings PromptExecutionSettings => - new OpenAIPromptExecutionSettings() - { - ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions, - Temperature = 0.01, - }; - - protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) => kernelBuilder.AddOpenAIChatCompletion(ModelName, _aiCredentialsProvider.Key); - - protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) => - chatMessage.Metadata?.GetValueOrDefault("Usage") is CompletionsUsage completionsUsage - ? new(PromptTokens: completionsUsage.PromptTokens, CompletionTokens: completionsUsage.CompletionTokens) - : AIServiceUsage.None; -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs index 0ca15e4161..2668300526 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; using ManagedCommon; using OpenAI.Moderations; @@ -23,7 +24,16 @@ public sealed class PromptModerationService(IAICredentialsProvider aiCredentials { try { - ModerationClient moderationClient = new(ModelName, _aiCredentialsProvider.Key); + _aiCredentialsProvider.Refresh(); + var apiKey = _aiCredentialsProvider.GetKey()?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(apiKey)) + { + Logger.LogWarning("Skipping OpenAI moderation because no credential is configured."); + return; + } + + ModerationClient moderationClient = new(ModelName, apiKey); var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt, cancellationToken); var moderationResult = moderationClientResult.Value; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs deleted file mode 100644 index 169c1c2422..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs +++ /dev/null @@ -1,37 +0,0 @@ -// 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 Windows.Security.Credentials; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class VaultCredentialsProvider : IAICredentialsProvider -{ - public VaultCredentialsProvider() => Refresh(); - - public string Key { get; private set; } - - public bool IsConfigured => !string.IsNullOrEmpty(Key); - - public bool Refresh() - { - var oldKey = Key; - Key = LoadKey(); - return oldKey != Key; - } - - private static string LoadKey() - { - try - { - return new PasswordVault().Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey")?.Password ?? string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index 5d6740977b..aef9e39bb9 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -8,15 +8,16 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; using Microsoft.PowerToys.Telemetry; using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTextTransformService customTextTransformService) : IPasteFormatExecutor +public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor { private readonly IKernelService _kernelService = kernelService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; public async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress progress) { @@ -36,7 +37,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTex pasteFormat.Format switch { PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), - PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText(await _customTextTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetTextAsync(), cancellationToken, progress)), + PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetClipboardTextOrThrowAsync(cancellationToken), cancellationToken, progress))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index 604cbf403b..f6c66154af 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -144,16 +144,67 @@ The paste operation was moderated due to sensitive content. Please try another query. - + Clipboard history Clipboard history + + AI provider selector + + + Select an AI provider + + + Active provider: {0} + + + AI providers + + + No AI providers configured + + + Configure models in Settings + Image data Label used to represent an image in the clipboard history + + Text + + + Image + + + Audio + + + Video + + + File + + + Clipboard + + + Copied just now + + + Copied {0} sec ago + + + Copied {0} min ago + + + Copied {0} hr ago + + + Copied {0} day ago + More options @@ -196,7 +247,7 @@ Transcode to .mp3 Option to transcode audio files to MP3 format - + Transcode to .mp4 (H.264/AAC) Option to transcode video files to MP4 format with H.264 video codec and AAC audio codec @@ -272,11 +323,11 @@ Next result - - OpenAI Privacy + + Privacy Policy - - OpenAI Terms + + Terms To custom with AI is disabled by your organization @@ -287,4 +338,27 @@ PowerToys_Paste_ + + Just now + + + 1 minute ago + + + {0} minutes ago + + + Today, {0} + + + Yesterday, {0} + + + {0}, {1} + (e.g., “Wednesday, 17:05”) + + + {0}, {1} + (e.g., “10/20/2025, 17:05” in the user’s locale) + \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs new file mode 100644 index 0000000000..04777eff79 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class AdvancedPasteEndpointUsageEvent : EventBase, IEvent +{ + /// + /// Gets or sets the AI provider type (e.g., OpenAI, AzureOpenAI, Anthropic). + /// + public string ProviderType { get; set; } + + public AdvancedPasteEndpointUsageEvent(AIServiceType providerType) + { + ProviderType = providerType.ToString(); + } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 688c3047e2..ede006e960 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Runtime.InteropServices; @@ -22,6 +23,8 @@ using CommunityToolkit.Mvvm.Input; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.System; @@ -37,12 +40,20 @@ namespace AdvancedPaste.ViewModels private readonly DispatcherTimer _clipboardTimer; private readonly IUserSettings _userSettings; private readonly IPasteFormatExecutor _pasteFormatExecutor; - private readonly IAICredentialsProvider _aiCredentialsProvider; + private readonly IAICredentialsProvider _credentialsProvider; private CancellationTokenSource _pasteActionCancellationTokenSource; + private string _currentClipboardHistoryId; + private DateTimeOffset? _currentClipboardTimestamp; + private ClipboardFormat _lastClipboardFormats = ClipboardFormat.None; + private bool _clipboardHistoryUnavailableLogged; + public DataPackageView ClipboardData { get; set; } + [ObservableProperty] + private ClipboardItem _currentClipboardItem; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] [NotifyPropertyChangedFor(nameof(ClipboardHasData))] @@ -58,6 +69,8 @@ namespace AdvancedPaste.ViewModels [NotifyPropertyChangedFor(nameof(CustomAIUnavailableErrorText))] [NotifyPropertyChangedFor(nameof(IsCustomAIServiceEnabled))] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] + [NotifyPropertyChangedFor(nameof(AllowedAIProviders))] + [NotifyPropertyChangedFor(nameof(ActiveAIProvider))] private bool _isAllowedByGPO; [ObservableProperty] @@ -79,11 +92,100 @@ namespace AdvancedPaste.ViewModels public ObservableCollection CustomActionPasteFormats { get; } = []; - public bool IsCustomAIServiceEnabled => IsAllowedByGPO && _aiCredentialsProvider.IsConfigured; + public bool IsCustomAIServiceEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + // Check if there are any allowed providers + if (!AllowedAIProviders.Any()) + { + return false; + } + + // We should handle the IsAIEnabled logic in settings, don't check again here. + // If setting says yes, and here should pass check, and if error happens, it happens. + return true; + } + } public bool IsCustomAIAvailable => IsCustomAIServiceEnabled && ClipboardHasDataForCustomAI; - public bool IsAdvancedAIEnabled => IsCustomAIServiceEnabled && _userSettings.IsAdvancedAIEnabled; + public bool IsAdvancedAIEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + if (!TryResolveAdvancedAIProvider(out _)) + { + return false; + } + + return _credentialsProvider.IsConfigured(); + } + } + + public ObservableCollection AIProviders => _userSettings?.PasteAIConfiguration?.Providers ?? new ObservableCollection(); + + public IEnumerable AllowedAIProviders + { + get + { + var providers = AIProviders; + if (providers is null || providers.Count == 0) + { + return Enumerable.Empty(); + } + + return providers.Where(IsProviderAllowedByGPO); + } + } + + public PasteAIProviderDefinition ActiveAIProvider + { + get + { + var provider = _userSettings?.PasteAIConfiguration?.ActiveProvider; + if (provider is null || !IsProviderAllowedByGPO(provider)) + { + return null; + } + + return provider; + } + } + + public string ActiveAIProviderTooltip + { + get + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var provider = ActiveAIProvider; + + if (provider is null) + { + return resourceLoader.GetString("AIProviderButtonTooltipEmpty"); + } + + var format = resourceLoader.GetString("AIProviderButtonTooltipFormat"); + var displayName = provider.DisplayName; + + if (!string.IsNullOrEmpty(format)) + { + return string.Format(CultureInfo.CurrentCulture, format, displayName); + } + + return displayName; + } + } public bool ClipboardHasData => AvailableClipboardFormats != ClipboardFormat.None; @@ -91,7 +193,10 @@ namespace AdvancedPaste.ViewModels public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress); - private PasteFormats CustomAIFormat => _userSettings.IsAdvancedAIEnabled ? PasteFormats.KernelQuery : PasteFormats.CustomTextTransformation; + private PasteFormats CustomAIFormat => + _userSettings.IsAIEnabled && TryResolveAdvancedAIProvider(out _) + ? PasteFormats.KernelQuery + : PasteFormats.CustomTextTransformation; private bool Visible { @@ -110,9 +215,9 @@ namespace AdvancedPaste.ViewModels public event EventHandler PreviewRequested; - public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider aiCredentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) + public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) { - _aiCredentialsProvider = aiCredentialsProvider; + _credentialsProvider = credentialsProvider; _userSettings = userSettings; _pasteFormatExecutor = pasteFormatExecutor; @@ -130,6 +235,7 @@ namespace AdvancedPaste.ViewModels _clipboardTimer.Start(); RefreshPasteFormats(); + UpdateAIProviderActiveFlags(); _userSettings.Changed += UserSettings_Changed; PropertyChanged += (_, e) => { @@ -158,15 +264,20 @@ namespace AdvancedPaste.ViewModels if (Visible) { await ReadClipboardAsync(); - UpdateAllowedByGPO(); } } private void UserSettings_Changed(object sender, EventArgs e) { + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); OnPropertyChanged(nameof(ClipboardHasDataForCustomAI)); OnPropertyChanged(nameof(IsCustomAIAvailable)); OnPropertyChanged(nameof(IsAdvancedAIEnabled)); + OnPropertyChanged(nameof(AIProviders)); + OnPropertyChanged(nameof(AllowedAIProviders)); + OnPropertyChanged(nameof(ActiveAIProvider)); + OnPropertyChanged(nameof(ActiveAIProviderTooltip)); EnqueueRefreshPasteFormats(); } @@ -192,6 +303,23 @@ namespace AdvancedPaste.ViewModels private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); + private void UpdateAIProviderActiveFlags() + { + var providers = _userSettings?.PasteAIConfiguration?.Providers; + if (providers is not null) + { + var activeId = ActiveAIProvider?.Id; + + foreach (var provider in providers) + { + provider.IsActive = !string.IsNullOrEmpty(activeId) && string.Equals(provider.Id, activeId, StringComparison.OrdinalIgnoreCase); + } + } + + OnPropertyChanged(nameof(ActiveAIProvider)); + OnPropertyChanged(nameof(ActiveAIProviderTooltip)); + } + private void RefreshPasteFormats() { var ctrlString = ResourceLoaderInstance.ResourceLoader.GetString("CtrlKey"); @@ -253,8 +381,96 @@ namespace AdvancedPaste.ViewModels return; } - ClipboardData = Clipboard.GetContent(); - AvailableClipboardFormats = await ClipboardData.GetAvailableFormatsAsync(); + try + { + ClipboardData = Clipboard.GetContent(); + AvailableClipboardFormats = ClipboardData != null ? await ClipboardData.GetAvailableFormatsAsync() : ClipboardFormat.None; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + // Logger.LogDebug("Failed to read clipboard content", ex); + ClipboardData = null; + AvailableClipboardFormats = ClipboardFormat.None; + } + + await UpdateClipboardPreviewAsync(); + } + + private async Task UpdateClipboardPreviewAsync() + { + if (ClipboardData is null || !ClipboardHasData) + { + ResetClipboardPreview(); + _currentClipboardHistoryId = null; + _currentClipboardTimestamp = null; + _lastClipboardFormats = ClipboardFormat.None; + return; + } + + var formatsChanged = AvailableClipboardFormats != _lastClipboardFormats; + _lastClipboardFormats = AvailableClipboardFormats; + + var clipboardChanged = await UpdateClipboardTimestampAsync(formatsChanged); + + // Create ClipboardItem directly from current clipboard data using helper + CurrentClipboardItem = await ClipboardItemHelper.CreateFromCurrentClipboardAsync( + ClipboardData, + AvailableClipboardFormats, + _currentClipboardTimestamp, + clipboardChanged ? null : CurrentClipboardItem?.Image); + } + + private async Task UpdateClipboardTimestampAsync(bool formatsChanged) + { + bool clipboardChanged = formatsChanged; + + if (Clipboard.IsHistoryEnabled()) + { + try + { + var historyItems = await Clipboard.GetHistoryItemsAsync(); + if (historyItems.Status == ClipboardHistoryItemsResultStatus.Success && historyItems.Items.Count > 0) + { + var latest = historyItems.Items[0]; + if (_currentClipboardHistoryId != latest.Id) + { + clipboardChanged = true; + _currentClipboardHistoryId = latest.Id; + } + + _currentClipboardTimestamp = latest.Timestamp; + _clipboardHistoryUnavailableLogged = false; + return clipboardChanged; + } + } + catch (Exception ex) + { + if (!_clipboardHistoryUnavailableLogged) + { + Logger.LogDebug("Failed to access clipboard history timestamp", ex.Message); + _clipboardHistoryUnavailableLogged = true; + } + } + } + + if (!_currentClipboardTimestamp.HasValue || clipboardChanged) + { + _currentClipboardTimestamp = DateTimeOffset.Now; + clipboardChanged = true; + } + + return clipboardChanged; + } + + private void ResetClipboardPreview() + { + // Clear to avoid leaks due to Garbage Collection not clearing the bitmap from memory + if (CurrentClipboardItem?.Image is not null) + { + CurrentClipboardItem.Image.ClearValue(BitmapImage.UriSourceProperty); + } + + CurrentClipboardItem = null; } public async Task OnShowAsync() @@ -270,7 +486,7 @@ namespace AdvancedPaste.ViewModels _dispatcherQueue.TryEnqueue(() => { - GetMainWindow()?.FinishLoading(_aiCredentialsProvider.IsConfigured); + GetMainWindow()?.FinishLoading(IsCustomAIServiceEnabled); OnPropertyChanged(nameof(InputTxtBoxPlaceholderText)); OnPropertyChanged(nameof(CustomAIUnavailableErrorText)); OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); @@ -319,7 +535,7 @@ namespace AdvancedPaste.ViewModels return ResourceLoaderInstance.ResourceLoader.GetString("OpenAIGpoDisabled"); } - if (!_aiCredentialsProvider.IsConfigured) + if (!IsCustomAIServiceEnabled) { return ResourceLoaderInstance.ResourceLoader.GetString("OpenAINotConfigured"); } @@ -515,11 +731,113 @@ namespace AdvancedPaste.ViewModels IsAllowedByGPO = PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOnlineAIModelsValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled; } + private bool IsProviderAllowedByGPO(PasteAIProviderDefinition provider) + { + if (provider is null) + { + return false; + } + + var serviceType = provider.ServiceType.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + + // Check global online AI GPO for online services + if (metadata.IsOnlineService && !IsAllowedByGPO) + { + return false; + } + + // Check individual endpoint GPO + return serviceType switch + { + AIServiceType.OpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureOpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureAIInference => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureAIInferenceValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Mistral => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteMistralValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Google => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteGoogleValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Anthropic => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAnthropicValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Ollama => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOllamaValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.FoundryLocal => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteFoundryLocalValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + _ => true, // Allow unknown types by default + }; + } + + private bool TryResolveAdvancedAIProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = _userSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedAIProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedAIProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedAIProvider(PasteAIProviderDefinition provider) + { + return provider is not null && provider.EnableAdvancedAI && SupportsAdvancedAI(provider.ServiceTypeKind); + } + + private static bool SupportsAdvancedAI(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI + or AIServiceType.AzureOpenAI; + } + private bool UpdateOpenAIKey() { UpdateAllowedByGPO(); - return IsAllowedByGPO && _aiCredentialsProvider.Refresh(); + return _credentialsProvider.Refresh(); + } + + [RelayCommand] + private async Task SetActiveProviderAsync(PasteAIProviderDefinition provider) + { + if (provider is null || string.IsNullOrEmpty(provider.Id)) + { + return; + } + + if (string.Equals(ActiveAIProvider?.Id, provider.Id, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + await _userSettings.SetActiveAIProviderAsync(provider.Id); + } + catch (Exception ex) + { + Logger.LogError("Failed to activate AI provider", ex); + return; + } + + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(AIProviders)); + EnqueueRefreshPasteFormats(); } public async Task CancelPasteActionAsync() diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj index 083aa868d3..2cf2920673 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj @@ -2,7 +2,7 @@ - + 15.0 diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 6af0d636ac..c7d22d474f 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -16,7 +16,8 @@ #include #include -#include +#include +#include #include BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) @@ -54,12 +55,14 @@ namespace const wchar_t JSON_KEY_ADVANCED_PASTE_UI_HOTKEY[] = L"advanced-paste-ui-hotkey"; const wchar_t JSON_KEY_PASTE_AS_MARKDOWN_HOTKEY[] = L"paste-as-markdown-hotkey"; const wchar_t JSON_KEY_PASTE_AS_JSON_HOTKEY[] = L"paste-as-json-hotkey"; - const wchar_t JSON_KEY_IS_ADVANCED_AI_ENABLED[] = L"IsAdvancedAIEnabled"; + const wchar_t JSON_KEY_IS_AI_ENABLED[] = L"IsAIEnabled"; + const wchar_t JSON_KEY_IS_OPEN_AI_ENABLED[] = L"IsOpenAIEnabled"; const wchar_t JSON_KEY_SHOW_CUSTOM_PREVIEW[] = L"ShowCustomPreview"; + const wchar_t JSON_KEY_PASTE_AI_CONFIGURATION[] = L"paste-ai-configuration"; + const wchar_t JSON_KEY_PROVIDERS[] = L"providers"; + const wchar_t JSON_KEY_SERVICE_TYPE[] = L"service-type"; + const wchar_t JSON_KEY_ENABLE_ADVANCED_AI[] = L"enable-advanced-ai"; const wchar_t JSON_KEY_VALUE[] = L"value"; - - const wchar_t OPENAI_VAULT_RESOURCE[] = L"https://platform.openai.com/api-keys"; - const wchar_t OPENAI_VAULT_USERNAME[] = L"PowerToys_AdvancedPaste_OpenAIKey"; } class AdvancedPaste : public PowertoyModuleIface @@ -94,6 +97,7 @@ private: using CustomAction = ActionData; std::vector m_custom_actions; + bool m_is_ai_enabled = false; bool m_is_advanced_ai_enabled = false; bool m_preview_custom_format_output = true; @@ -145,32 +149,11 @@ private: return jsonObject; } - static bool open_ai_key_exists() - { - try - { - winrt::Windows::Security::Credentials::PasswordVault().Retrieve(OPENAI_VAULT_RESOURCE, OPENAI_VAULT_USERNAME); - return true; - } - catch (const winrt::hresult_error& ex) - { - // Looks like the only way to access the PasswordVault is through an API that throws an exception in case the resource doesn't exist. - // If the debugger breaks here, just continue. - // If you want to disable breaking here in a more permanent way, just add a condition in Visual Studio's Exception Settings to not break on win::hresult_error, but that might make you not hit other exceptions you might want to catch. - if (ex.code() == HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) - { - return false; // Credential doesn't exist. - } - Logger::error("Unexpected error while retrieving OpenAI key from vault: {}", winrt::to_string(ex.message())); - return false; - } - } - - bool is_open_ai_enabled() + bool is_ai_enabled() { return gpo_policy_enabled_configuration() != powertoys_gpo::gpo_rule_configured_disabled && powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue() != powertoys_gpo::gpo_rule_configured_disabled && - open_ai_key_exists(); + m_is_ai_enabled; } static std::wstring kebab_to_pascal_case(const std::wstring& kebab_str) @@ -201,6 +184,13 @@ private: return result; } + static std::wstring to_lower_case(const std::wstring& value) + { + std::wstring result = value; + std::transform(result.begin(), result.end(), result.begin(), [](wchar_t ch) { return std::towlower(ch); }); + return result; + } + bool migrate_data_and_remove_data_file(Hotkey& old_paste_as_plain_hotkey) { const wchar_t OLD_JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; @@ -267,6 +257,61 @@ private: } } + bool has_advanced_ai_provider(const winrt::Windows::Data::Json::JsonObject& propertiesObject) + { + if (!propertiesObject.HasKey(JSON_KEY_PASTE_AI_CONFIGURATION)) + { + return false; + } + + const auto configValue = propertiesObject.GetNamedValue(JSON_KEY_PASTE_AI_CONFIGURATION); + if (configValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + return false; + } + + const auto configObject = configValue.GetObjectW(); + if (!configObject.HasKey(JSON_KEY_PROVIDERS)) + { + return false; + } + + const auto providersValue = configObject.GetNamedValue(JSON_KEY_PROVIDERS); + if (providersValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Array) + { + return false; + } + + const auto providers = providersValue.GetArray(); + for (const auto providerValue : providers) + { + if (providerValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + continue; + } + + const auto providerObject = providerValue.GetObjectW(); + if (!providerObject.GetNamedBoolean(JSON_KEY_ENABLE_ADVANCED_AI, false)) + { + continue; + } + + if (!providerObject.HasKey(JSON_KEY_SERVICE_TYPE)) + { + continue; + } + + const std::wstring serviceType = providerObject.GetNamedString(JSON_KEY_SERVICE_TYPE, L"").c_str(); + const auto normalizedServiceType = to_lower_case(serviceType); + if (normalizedServiceType == L"openai" || normalizedServiceType == L"azureopenai") + { + return true; + } + } + + return false; + } + void read_settings(PowerToysSettings::PowerToyValues& settings) { const auto settingsObject = settings.get_raw_json(); @@ -341,7 +386,7 @@ private: if (propertiesObject.HasKey(JSON_KEY_CUSTOM_ACTIONS)) { const auto customActions = propertiesObject.GetNamedObject(JSON_KEY_CUSTOM_ACTIONS).GetNamedArray(JSON_KEY_VALUE); - if (customActions.Size() > 0 && is_open_ai_enabled()) + if (customActions.Size() > 0 && is_ai_enabled()) { for (const auto& customAction : customActions) { @@ -365,9 +410,19 @@ private: { const auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); - if (propertiesObject.HasKey(JSON_KEY_IS_ADVANCED_AI_ENABLED)) + m_is_advanced_ai_enabled = has_advanced_ai_provider(propertiesObject); + + if (propertiesObject.HasKey(JSON_KEY_IS_AI_ENABLED)) { - m_is_advanced_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_ADVANCED_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE); + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else if (propertiesObject.HasKey(JSON_KEY_IS_OPEN_AI_ENABLED)) + { + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_OPEN_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else + { + m_is_ai_enabled = false; } if (propertiesObject.HasKey(JSON_KEY_SHOW_CUSTOM_PREVIEW)) diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json index 31ad05c701..bc0803796e 100644 --- a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json @@ -1 +1 @@ -{"properties":{"IsAdvancedAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file +{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/nuget.config b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/nuget.config deleted file mode 100644 index e6a17ffdfe..0000000000 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/nuget.config +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/settings-ui/Settings.UI.Library/AIProviderConfigurationSnapshot.cs b/src/settings-ui/Settings.UI.Library/AIProviderConfigurationSnapshot.cs new file mode 100644 index 0000000000..456632545f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIProviderConfigurationSnapshot.cs @@ -0,0 +1,35 @@ +// 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.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Stores provider-specific configuration overrides so each AI service can keep distinct settings. + /// + public class AIProviderConfigurationSnapshot + { + [JsonPropertyName("model-name")] + public string ModelName { get; set; } = string.Empty; + + [JsonPropertyName("endpoint-url")] + public string EndpointUrl { get; set; } = string.Empty; + + [JsonPropertyName("api-version")] + public string ApiVersion { get; set; } = string.Empty; + + [JsonPropertyName("deployment-name")] + public string DeploymentName { get; set; } = string.Empty; + + [JsonPropertyName("model-path")] + public string ModelPath { get; set; } = string.Empty; + + [JsonPropertyName("system-prompt")] + public string SystemPrompt { get; set; } = string.Empty; + + [JsonPropertyName("moderation-enabled")] + public bool ModerationEnabled { get; set; } = true; + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceType.cs b/src/settings-ui/Settings.UI.Library/AIServiceType.cs new file mode 100644 index 0000000000..5cbd6a855c --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceType.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Supported AI service types for PowerToys AI experiences. + /// + public enum AIServiceType + { + Unknown = 0, + OpenAI, + AzureOpenAI, + Onnx, + ML, + FoundryLocal, + Mistral, + Google, + HuggingFace, + AzureAIInference, + Ollama, + Anthropic, + AmazonBedrock, + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs new file mode 100644 index 0000000000..91f035d23a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs @@ -0,0 +1,88 @@ +// 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; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public static class AIServiceTypeExtensions + { + /// + /// Convert a persisted string value into an . + /// Supports historical casing and aliases. + /// + public static AIServiceType ToAIServiceType(this string serviceType) + { + if (string.IsNullOrWhiteSpace(serviceType)) + { + return AIServiceType.OpenAI; + } + + var normalized = serviceType.Trim().ToLowerInvariant(); + return normalized switch + { + "openai" => AIServiceType.OpenAI, + "azureopenai" or "azure" => AIServiceType.AzureOpenAI, + "onnx" => AIServiceType.Onnx, + "foundrylocal" or "foundry" or "fl" => AIServiceType.FoundryLocal, + "ml" or "windowsml" or "winml" => AIServiceType.ML, + "mistral" => AIServiceType.Mistral, + "google" or "googleai" or "googlegemini" => AIServiceType.Google, + "huggingface" => AIServiceType.HuggingFace, + "azureaiinference" or "azureinference" => AIServiceType.AzureAIInference, + "ollama" => AIServiceType.Ollama, + "anthropic" => AIServiceType.Anthropic, + "amazonbedrock" or "bedrock" => AIServiceType.AmazonBedrock, + _ => AIServiceType.Unknown, + }; + } + + /// + /// Convert an to the canonical string used for persistence. + /// + public static string ToConfigurationString(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "OpenAI", + AIServiceType.AzureOpenAI => "AzureOpenAI", + AIServiceType.Onnx => "Onnx", + AIServiceType.FoundryLocal => "FoundryLocal", + AIServiceType.ML => "ML", + AIServiceType.Mistral => "Mistral", + AIServiceType.Google => "Google", + AIServiceType.HuggingFace => "HuggingFace", + AIServiceType.AzureAIInference => "AzureAIInference", + AIServiceType.Ollama => "Ollama", + AIServiceType.Anthropic => "Anthropic", + AIServiceType.AmazonBedrock => "AmazonBedrock", + AIServiceType.Unknown => string.Empty, + _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, "Unsupported AI service type."), + }; + } + + /// + /// Convert an into the normalized key used internally. + /// + public static string ToNormalizedKey(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "openai", + AIServiceType.AzureOpenAI => "azureopenai", + AIServiceType.Onnx => "onnx", + AIServiceType.FoundryLocal => "foundrylocal", + AIServiceType.ML => "ml", + AIServiceType.Mistral => "mistral", + AIServiceType.Google => "google", + AIServiceType.HuggingFace => "huggingface", + AIServiceType.AzureAIInference => "azureaiinference", + AIServiceType.Ollama => "ollama", + AIServiceType.Anthropic => "anthropic", + AIServiceType.AmazonBedrock => "amazonbedrock", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs new file mode 100644 index 0000000000..df01b1816a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Metadata information for an AI service type. + /// + public class AIServiceTypeMetadata + { + public AIServiceType ServiceType { get; init; } + + public string DisplayName { get; init; } + + public string IconPath { get; init; } + + public bool IsOnlineService { get; init; } + + public bool IsAvailableInUI { get; init; } = true; + + public bool IsLocalModel { get; init; } + + public string LegalDescription { get; init; } + + public string TermsLabel { get; init; } + + public Uri TermsUri { get; init; } + + public string PrivacyLabel { get; init; } + + public Uri PrivacyUri { get; init; } + + public bool HasLegalInfo => !string.IsNullOrWhiteSpace(LegalDescription); + + public bool HasTermsLink => TermsUri is not null && !string.IsNullOrEmpty(TermsLabel); + + public bool HasPrivacyLink => PrivacyUri is not null && !string.IsNullOrEmpty(PrivacyLabel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs new file mode 100644 index 0000000000..ca0bd9e011 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library; + +/// +/// Centralized registry for AI service type metadata. +/// +public static class AIServiceTypeRegistry +{ + private static readonly Dictionary MetadataMap = new() + { + [AIServiceType.AmazonBedrock] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AmazonBedrock, + DisplayName = "Amazon Bedrock", + IsAvailableInUI = false, // Currently disabled in UI + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Bedrock.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AmazonBedrock_LegalDescription", + TermsLabel = "AdvancedPaste_AmazonBedrock_TermsLabel", + TermsUri = new Uri("https://aws.amazon.com/service-terms/"), + PrivacyLabel = "AdvancedPaste_AmazonBedrock_PrivacyLabel", + PrivacyUri = new Uri("https://aws.amazon.com/privacy/"), + }, + [AIServiceType.Anthropic] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Anthropic, + DisplayName = "Anthropic", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Anthropic.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Anthropic_LegalDescription", + TermsLabel = "AdvancedPaste_Anthropic_TermsLabel", + TermsUri = new Uri("https://www.anthropic.com/legal/terms-of-service"), + PrivacyLabel = "AdvancedPaste_Anthropic_PrivacyLabel", + PrivacyUri = new Uri("https://www.anthropic.com/legal/privacy"), + }, + [AIServiceType.AzureAIInference] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureAIInference, + DisplayName = "Azure AI Inference", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", // No icon for Azure AI Inference, use Foundry Local temporarily + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureAIInference_LegalDescription", + TermsLabel = "AdvancedPaste_AzureAIInference_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureAIInference_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.AzureOpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureOpenAI, + DisplayName = "Azure OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/AzureAI.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureOpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_AzureOpenAI_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureOpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.FoundryLocal] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.FoundryLocal, + DisplayName = "Foundry Local", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_FoundryLocal_LegalDescription", // Resource key for localized description + }, + [AIServiceType.Google] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Google, + DisplayName = "Google", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Gemini.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Google_LegalDescription", + TermsLabel = "AdvancedPaste_Google_TermsLabel", + TermsUri = new Uri("https://policies.google.com/terms"), + PrivacyLabel = "AdvancedPaste_Google_PrivacyLabel", + PrivacyUri = new Uri("https://policies.google.com/privacy"), + }, + [AIServiceType.HuggingFace] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.HuggingFace, + DisplayName = "Hugging Face", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/HuggingFace.svg", + IsOnlineService = true, + IsAvailableInUI = false, // Currently disabled in UI + }, + [AIServiceType.Mistral] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Mistral, + DisplayName = "Mistral", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Mistral.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Mistral_LegalDescription", + TermsLabel = "AdvancedPaste_Mistral_TermsLabel", + TermsUri = new Uri("https://mistral.ai/terms-of-service/"), + PrivacyLabel = "AdvancedPaste_Mistral_PrivacyLabel", + PrivacyUri = new Uri("https://mistral.ai/privacy-policy/"), + }, + [AIServiceType.ML] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.ML, + DisplayName = "Windows ML", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/WindowsML.svg", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IsAvailableInUI = false, + IsOnlineService = false, + IsLocalModel = true, + }, + [AIServiceType.Ollama] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Ollama, + DisplayName = "Ollama", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Ollama.svg", + + // Ollama provide online service, but we treat it as local model at first version since it can is known for local model. + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + TermsLabel = "AdvancedPaste_Ollama_TermsLabel", + TermsUri = new Uri("https://ollama.com/terms"), + PrivacyLabel = "AdvancedPaste_Ollama_PrivacyLabel", + PrivacyUri = new Uri("https://ollama.com/privacy"), + }, + [AIServiceType.Onnx] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Onnx, + DisplayName = "ONNX", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Onnx.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + [AIServiceType.OpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.OpenAI, + DisplayName = "OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_OpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_OpenAI_TermsLabel", + TermsUri = new Uri("https://openai.com/terms"), + PrivacyLabel = "AdvancedPaste_OpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://openai.com/privacy"), + }, + [AIServiceType.Unknown] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Unknown, + DisplayName = "Unknown", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + }; + + /// + /// Get metadata for a specific service type. + /// + public static AIServiceTypeMetadata GetMetadata(AIServiceType serviceType) + { + return MetadataMap.TryGetValue(serviceType, out var metadata) + ? metadata + : MetadataMap[AIServiceType.Unknown]; + } + + /// + /// Get metadata for a service type from its string representation. + /// + public static AIServiceTypeMetadata GetMetadata(string serviceType) + { + var type = serviceType.ToAIServiceType(); + return GetMetadata(type); + } + + /// + /// Get icon path for a service type. + /// + public static string GetIconPath(AIServiceType serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get icon path for a service type from its string representation. + /// + public static string GetIconPath(string serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// + /// Get all service types available in the UI. + /// + public static IEnumerable GetAvailableServiceTypes() + { + return MetadataMap.Values.Where(m => m.IsAvailableInUI); + } + + /// + /// Get all online service types available in the UI. + /// + public static IEnumerable GetOnlineServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsOnlineService); + } + + /// + /// Get all local service types available in the UI. + /// + public static IEnumerable GetLocalServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsLocalModel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index 43baf89351..c981295906 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -14,6 +14,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { private int _id; private string _name = string.Empty; + private string _description = string.Empty; private string _prompt = string.Empty; private HotkeySettings _shortcut = new(); private bool _isShown; @@ -43,6 +44,13 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction } } + [JsonPropertyName("description")] + public string Description + { + get => _description; + set => Set(ref _description, value ?? string.Empty); + } + [JsonPropertyName("prompt")] public string Prompt { @@ -128,6 +136,7 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { Id = other.Id; Name = other.Name; + Description = other.Description; Prompt = other.Prompt; Shortcut = other.GetShortcutClone(); IsShown = other.IsShown; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index d40bd686d3..200e5e459d 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; @@ -23,13 +24,38 @@ namespace Microsoft.PowerToys.Settings.UI.Library PasteAsJsonShortcut = new(); CustomActions = new(); AdditionalActions = new(); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + PasteAIConfiguration = new(); } [JsonConverter(typeof(BoolPropertyJsonConverter))] - public bool IsAdvancedAIEnabled { get; set; } + public bool IsAIEnabled { get; set; } + + [JsonExtensionData] + public Dictionary ExtensionData + { + get => _extensionData; + set + { + _extensionData = value; + + if (_extensionData != null && _extensionData.TryGetValue("IsOpenAIEnabled", out var legacyElement) && legacyElement.ValueKind == JsonValueKind.Object && legacyElement.TryGetProperty("value", out var valueElement)) + { + IsAIEnabled = valueElement.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => IsAIEnabled, + }; + + _extensionData.Remove("IsOpenAIEnabled"); + } + } + } + + private Dictionary _extensionData; [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool ShowCustomPreview { get; set; } @@ -57,6 +83,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library [CmdConfigureIgnoreAttribute] public AdvancedPasteAdditionalActions AdditionalActions { get; init; } + [JsonPropertyName("paste-ai-configuration")] + [CmdConfigureIgnoreAttribute] + public PasteAIConfiguration PasteAIConfiguration { get; set; } + public override string ToString() => JsonSerializer.Serialize(this); } diff --git a/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs new file mode 100644 index 0000000000..3ed0937280 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Configuration for Paste AI features (custom action transformations like custom prompt processing) + /// + public class PasteAIConfiguration : INotifyPropertyChanged + { + private string _activeProviderId = string.Empty; + private ObservableCollection _providers = new(); + private bool _useSharedCredentials = true; + private Dictionary _legacyProviderConfigurations; + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("active-provider-id")] + public string ActiveProviderId + { + get => _activeProviderId; + set => SetProperty(ref _activeProviderId, value ?? string.Empty); + } + + [JsonPropertyName("providers")] + public ObservableCollection Providers + { + get => _providers; + set => SetProperty(ref _providers, value ?? new ObservableCollection()); + } + + [JsonPropertyName("use-shared-credentials")] + public bool UseSharedCredentials + { + get => _useSharedCredentials; + set => SetProperty(ref _useSharedCredentials, value); + } + + [JsonPropertyName("provider-configurations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary LegacyProviderConfigurations + { + get => _legacyProviderConfigurations; + set => _legacyProviderConfigurations = value; + } + + [JsonIgnore] + public PasteAIProviderDefinition ActiveProvider + { + get + { + if (_providers is null || _providers.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(_activeProviderId)) + { + var match = _providers.FirstOrDefault(provider => string.Equals(provider.Id, _activeProviderId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + return match; + } + } + + return _providers[0]; + } + } + + [JsonIgnore] + public AIServiceType ActiveServiceTypeKind => ActiveProvider?.ServiceTypeKind ?? AIServiceType.OpenAI; + + public override string ToString() + => JsonSerializer.Serialize(this); + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs new file mode 100644 index 0000000000..0fbb3328e7 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Represents a single Paste AI provider configuration entry. + /// + public class PasteAIProviderDefinition : INotifyPropertyChanged + { + private string _id = Guid.NewGuid().ToString("N"); + private string _serviceType = "OpenAI"; + private string _modelName = string.Empty; + private string _endpointUrl = string.Empty; + private string _apiVersion = string.Empty; + private string _deploymentName = string.Empty; + private string _modelPath = string.Empty; + private string _systemPrompt = string.Empty; + private bool _moderationEnabled = true; + private bool _isActive; + private bool _enableAdvancedAI; + private bool _isLocalModel; + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("id")] + public string Id + { + get => _id; + set => SetProperty(ref _id, value); + } + + [JsonPropertyName("service-type")] + public string ServiceType + { + get => _serviceType; + set + { + if (SetProperty(ref _serviceType, string.IsNullOrWhiteSpace(value) ? "OpenAI" : value)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonIgnore] + public AIServiceType ServiceTypeKind + { + get => ServiceType.ToAIServiceType(); + set => ServiceType = value.ToConfigurationString(); + } + + [JsonPropertyName("model-name")] + public string ModelName + { + get => _modelName; + set + { + if (SetProperty(ref _modelName, value ?? string.Empty)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonPropertyName("endpoint-url")] + public string EndpointUrl + { + get => _endpointUrl; + set => SetProperty(ref _endpointUrl, value ?? string.Empty); + } + + [JsonPropertyName("api-version")] + public string ApiVersion + { + get => _apiVersion; + set => SetProperty(ref _apiVersion, value ?? string.Empty); + } + + [JsonPropertyName("deployment-name")] + public string DeploymentName + { + get => _deploymentName; + set => SetProperty(ref _deploymentName, value ?? string.Empty); + } + + [JsonPropertyName("model-path")] + public string ModelPath + { + get => _modelPath; + set => SetProperty(ref _modelPath, value ?? string.Empty); + } + + [JsonPropertyName("system-prompt")] + public string SystemPrompt + { + get => _systemPrompt; + set => SetProperty(ref _systemPrompt, value?.Trim() ?? string.Empty); + } + + [JsonPropertyName("moderation-enabled")] + public bool ModerationEnabled + { + get => _moderationEnabled; + set => SetProperty(ref _moderationEnabled, value); + } + + [JsonPropertyName("enable-advanced-ai")] + public bool EnableAdvancedAI + { + get => _enableAdvancedAI; + set => SetProperty(ref _enableAdvancedAI, value); + } + + [JsonPropertyName("is-local-model")] + public bool IsLocalModel + { + get => _isLocalModel; + set => SetProperty(ref _isLocalModel, value); + } + + [JsonIgnore] + public bool IsActive + { + get => _isActive; + set => SetProperty(ref _isActive, value); + } + + [JsonIgnore] + public string DisplayName => string.IsNullOrWhiteSpace(ModelName) ? ServiceType : ModelName; + + public PasteAIProviderDefinition Clone() + { + return new PasteAIProviderDefinition + { + Id = Id, + ServiceType = ServiceType, + ModelName = ModelName, + EndpointUrl = EndpointUrl, + ApiVersion = ApiVersion, + DeploymentName = DeploymentName, + ModelPath = ModelPath, + SystemPrompt = SystemPrompt, + ModerationEnabled = ModerationEnabled, + EnableAdvancedAI = EnableAdvancedAI, + IsLocalModel = IsLocalModel, + IsActive = IsActive, + }; + } + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Anthropic.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Anthropic.svg new file mode 100644 index 0000000000..f990d4650f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Anthropic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg new file mode 100644 index 0000000000..7497187ad7 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg new file mode 100644 index 0000000000..e6fd7121b2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Bedrock.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Bedrock.svg new file mode 100644 index 0000000000..d7a3d800d9 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Bedrock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg new file mode 100644 index 0000000000..7066f294f9 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg new file mode 100644 index 0000000000..56a5fe461b --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/HuggingFace.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/HuggingFace.svg new file mode 100644 index 0000000000..9fe0aff336 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/HuggingFace.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg new file mode 100644 index 0000000000..ce2471552e --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg new file mode 100644 index 0000000000..e44dda654d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg new file mode 100644 index 0000000000..301a40fd55 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg new file mode 100644 index 0000000000..87aacb3a4f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg new file mode 100644 index 0000000000..f72a3c64d1 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg new file mode 100644 index 0000000000..fafc16b59f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs new file mode 100644 index 0000000000..7d632906c2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public partial class ServiceTypeToIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string serviceType || string.IsNullOrWhiteSpace(serviceType)) + { + return new ImageIcon { Source = new SvgImageSource(new Uri(AIServiceTypeRegistry.GetIconPath(AIServiceType.OpenAI))) }; + } + + var iconPath = AIServiceTypeRegistry.GetIconPath(serviceType); + return new ImageIcon { Source = new SvgImageSource(new Uri(iconPath)) }; + } + + 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 dd70af7533..057413a408 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -20,6 +20,12 @@ PowerToys.Settings.pri + + + + + + @@ -53,6 +59,13 @@ + + + + PreserveNewest + + + @@ -105,6 +118,7 @@ + @@ -197,4 +211,4 @@ - \ No newline at end of file + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml new file mode 100644 index 0000000000..f695301e3a --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs new file mode 100644 index 0000000000..3d1c4c2159 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs @@ -0,0 +1,457 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using LanguageModelProvider; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class FoundryLocalModelPicker : UserControl +{ + private INotifyCollectionChanged _cachedModelsSubscription; + private INotifyCollectionChanged _downloadableModelsSubscription; + private bool _suppressSelection; + + public FoundryLocalModelPicker() + { + InitializeComponent(); + Loaded += (_, _) => UpdateVisualStates(); + } + + public delegate void ModelSelectionChangedEventHandler(object sender, ModelDetails model); + + public delegate void DownloadRequestedEventHandler(object sender, object payload); + + public delegate void LoadRequestedEventHandler(object sender, FoundryLoadRequestedEventArgs args); + + public event ModelSelectionChangedEventHandler SelectionChanged; + + public event LoadRequestedEventHandler LoadRequested; + + public IEnumerable CachedModels + { + get => (IEnumerable)GetValue(CachedModelsProperty); + set => SetValue(CachedModelsProperty, value); + } + + public static readonly DependencyProperty CachedModelsProperty = + DependencyProperty.Register(nameof(CachedModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnCachedModelsChanged)); + + public IEnumerable DownloadableModels + { + get => (IEnumerable)GetValue(DownloadableModelsProperty); + set => SetValue(DownloadableModelsProperty, value); + } + + public static readonly DependencyProperty DownloadableModelsProperty = + DependencyProperty.Register(nameof(DownloadableModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnDownloadableModelsChanged)); + + public ModelDetails SelectedModel + { + get => (ModelDetails)GetValue(SelectedModelProperty); + set => SetValue(SelectedModelProperty, value); + } + + public static readonly DependencyProperty SelectedModelProperty = + DependencyProperty.Register(nameof(SelectedModel), typeof(ModelDetails), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnSelectedModelChanged)); + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public static readonly DependencyProperty IsLoadingProperty = + DependencyProperty.Register(nameof(IsLoading), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public bool IsAvailable + { + get => (bool)GetValue(IsAvailableProperty); + set => SetValue(IsAvailableProperty, value); + } + + public static readonly DependencyProperty IsAvailableProperty = + DependencyProperty.Register(nameof(IsAvailable), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public string StatusText + { + get => (string)GetValue(StatusTextProperty); + set => SetValue(StatusTextProperty, value); + } + + public static readonly DependencyProperty StatusTextProperty = + DependencyProperty.Register(nameof(StatusText), typeof(string), typeof(FoundryLocalModelPicker), new PropertyMetadata(string.Empty, OnStatePropertyChanged)); + + public bool HasCachedModels => CachedModels?.Any() ?? false; + + public bool HasDownloadableModels => DownloadableModels?.Cast().Any() ?? false; + + public void RequestLoad(bool refresh) + { + if (IsLoading) + { + // Allow refresh requests to continue even if already loading by cancelling via host. + } + else + { + IsLoading = true; + } + + IsAvailable = false; + StatusText = "Loading Foundry Local status..."; + LoadRequested?.Invoke(this, new FoundryLoadRequestedEventArgs(refresh)); + } + + private static void OnCachedModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToCachedModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnDownloadableModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToDownloadableModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnSelectedModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + if (control._suppressSelection) + { + return; + } + + try + { + control._suppressSelection = true; + if (control.CachedModelsComboBox is not null) + { + control.CachedModelsComboBox.SelectedItem = e.NewValue; + } + } + finally + { + control._suppressSelection = false; + } + + control.UpdateSelectedModelDetails(); + } + + private static void OnStatePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.UpdateVisualStates(); + } + + private void SubscribeToCachedModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_cachedModelsSubscription is not null) + { + _cachedModelsSubscription.CollectionChanged -= CachedModels_CollectionChanged; + _cachedModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += CachedModels_CollectionChanged; + _cachedModelsSubscription = observable; + } + } + + private void SubscribeToDownloadableModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_downloadableModelsSubscription is not null) + { + _downloadableModelsSubscription.CollectionChanged -= DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = observable; + } + } + + private void CachedModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void DownloadableModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void CachedModelsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressSelection) + { + return; + } + + try + { + _suppressSelection = true; + var selected = CachedModelsComboBox.SelectedItem as ModelDetails; + SetValue(SelectedModelProperty, selected); + SelectionChanged?.Invoke(this, selected); + } + finally + { + _suppressSelection = false; + } + + UpdateSelectedModelDetails(); + } + + private void UpdateSelectedModelDetails() + { + if (SelectedModelDetailsPanel is null || SelectedModelDescriptionText is null || SelectedModelTagsPanel is null) + { + return; + } + + if (!HasCachedModels || SelectedModel is not ModelDetails model) + { + SelectedModelDetailsPanel.Visibility = Visibility.Collapsed; + SelectedModelDescriptionText.Text = string.Empty; + SelectedModelTagsPanel.Children.Clear(); + SelectedModelTagsPanel.Visibility = Visibility.Collapsed; + return; + } + + SelectedModelDetailsPanel.Visibility = Visibility.Visible; + SelectedModelDescriptionText.Text = string.IsNullOrWhiteSpace(model.Description) + ? "No description provided." + : model.Description; + + SelectedModelTagsPanel.Children.Clear(); + + AddTag(GetModelSizeText(model.Size)); + AddTag(GetLicenseShortText(model.License), model.License); + + foreach (var deviceTag in GetDeviceTags(model.HardwareAccelerators)) + { + AddTag(deviceTag); + } + + SelectedModelTagsPanel.Visibility = SelectedModelTagsPanel.Children.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + void AddTag(string text, string tooltip = null) + { + if (string.IsNullOrWhiteSpace(text) || SelectedModelTagsPanel is null) + { + return; + } + + Border tag = new(); + if (Resources.TryGetValue("TagBorderStyle", out var borderStyleObj) && borderStyleObj is Style borderStyle) + { + tag.Style = borderStyle; + } + + TextBlock label = new() + { + Text = text, + }; + + if (Resources.TryGetValue("TagTextStyle", out var textStyleObj) && textStyleObj is Style textStyle) + { + label.Style = textStyle; + } + + tag.Child = label; + + if (!string.IsNullOrWhiteSpace(tooltip)) + { + ToolTipService.SetToolTip(tag, new TextBlock + { + Text = tooltip, + TextWrapping = TextWrapping.Wrap, + }); + } + + SelectedModelTagsPanel.Children.Add(tag); + } + } + + private void LaunchFoundryModelListButton_Click(object sender, RoutedEventArgs e) + { + try + { + ProcessStartInfo processInfo = new() + { + FileName = "powershell.exe", + Arguments = "-NoExit -Command \"foundry model list\"", + UseShellExecute = true, + }; + + Process.Start(processInfo); + StatusText = "Opening PowerShell and running 'foundry model list'..."; + } + catch (Exception ex) + { + StatusText = $"Unable to start PowerShell. {ex.Message}"; + Debug.WriteLine($"[FoundryLocalModelPicker] Failed to run 'foundry model list': {ex}"); + } + } + + private void RefreshModelsButton_Click(object sender, RoutedEventArgs e) + { + RequestLoad(refresh: true); + } + + private void UpdateVisualStates() + { + LoadingIndicator.IsActive = IsLoading; + + if (IsLoading) + { + VisualStateManager.GoToState(this, "ShowLoading", true); + } + else if (!IsAvailable) + { + VisualStateManager.GoToState(this, "ShowNotAvailable", true); + } + else + { + VisualStateManager.GoToState(this, "ShowModels", true); + } + + if (LoadingStatusTextBlock is not null) + { + LoadingStatusTextBlock.Text = string.IsNullOrWhiteSpace(StatusText) + ? "Loading Foundry Local status..." + : StatusText; + } + + NoModelsPanel.Visibility = HasCachedModels ? Visibility.Collapsed : Visibility.Visible; + if (CachedModelsComboBox is not null) + { + CachedModelsComboBox.Visibility = HasCachedModels ? Visibility.Visible : Visibility.Collapsed; + CachedModelsComboBox.IsEnabled = HasCachedModels; + } + + UpdateSelectedModelDetails(); + + Bindings.Update(); + } + + public static string GetModelSizeText(long size) + { + if (size <= 0) + { + return string.Empty; + } + + const long kiloByte = 1024; + const long megaByte = kiloByte * 1024; + const long gigaByte = megaByte * 1024; + + if (size >= gigaByte) + { + return $"{size / (double)gigaByte:0.##} GB"; + } + + if (size >= megaByte) + { + return $"{size / (double)megaByte:0.##} MB"; + } + + if (size >= kiloByte) + { + return $"{size / (double)kiloByte:0.##} KB"; + } + + return $"{size} B"; + } + + public static Visibility GetModelSizeVisibility(long size) + { + return size > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + public static IEnumerable GetDeviceTags(IReadOnlyCollection accelerators) + { + if (accelerators is null || accelerators.Count == 0) + { + return Array.Empty(); + } + + HashSet tags = new(StringComparer.OrdinalIgnoreCase); + + foreach (var accelerator in accelerators) + { + switch (accelerator) + { + case HardwareAccelerator.CPU: + tags.Add("CPU"); + break; + case HardwareAccelerator.GPU: + case HardwareAccelerator.DML: + tags.Add("GPU"); + break; + case HardwareAccelerator.NPU: + case HardwareAccelerator.QNN: + tags.Add("NPU"); + break; + } + } + + return tags.Count > 0 ? tags.ToArray() : Array.Empty(); + } + + public static Visibility GetDeviceVisibility(IReadOnlyCollection accelerators) + { + return GetDeviceTags(accelerators).Any() ? Visibility.Visible : Visibility.Collapsed; + } + + public static string GetLicenseShortText(string license) + { + if (string.IsNullOrWhiteSpace(license)) + { + return string.Empty; + } + + var trimmed = license.Trim(); + int separatorIndex = trimmed.IndexOfAny(['(', '[', ':']); + if (separatorIndex > 0) + { + trimmed = trimmed[..separatorIndex].Trim(); + } + + if (trimmed.Length > 24) + { + trimmed = $"{trimmed[..24].TrimEnd()}…"; + } + + return trimmed; + } + + public static Visibility GetLicenseVisibility(string license) + { + return string.IsNullOrWhiteSpace(license) ? Visibility.Collapsed : Visibility.Visible; + } + + public sealed class FoundryLoadRequestedEventArgs : EventArgs + { + public FoundryLoadRequestedEventArgs(bool refresh) + { + Refresh = refresh; + } + + public bool Refresh { get; } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 628df84c01..8d6b0afa68 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -3,28 +3,43 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" x:Name="RootPage" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> + + + ms-appx:///Assets/Settings/Modules/APDialog.dark.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.dark.svg ms-appx:///Assets/Settings/Modules/APDialog.light.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg ms-appx:///Assets/Settings/Modules/APDialog.light.png + ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg + + + - + - + IsEnabled="{x:Bind ViewModel.IsOnlineAIModelsDisallowedByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" + IsExpanded="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.PasteAIConfiguration.Providers, Mode=OneWay}"> + - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + DataContext="{x:Bind ViewModel.AdditionalActions.ImageToText, Mode=OneWay}" + HeaderIcon="{ui:FontIcon Glyph=}"> + + + + + + + + + + + + - - - - - - - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + 900 + 700 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs index 72de0843d1..19375d90f7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs @@ -18,6 +18,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views InitializeComponent(); var settingsUtils = new SettingsUtils(); ViewModel = new HostsViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); + BackupsCountInputSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Description"); + BackupsCountInputAgeSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputAgeSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Age_Description"); } public void RefreshEnabledState() 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 7c6d401ac1..95ce0b5f19 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -5599,4 +5599,48 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Learn more + + Backup + + + Backup hosts file + "Hosts" refers to the system hosts file, do not loc + + + Automatically create a backup of the hosts file when you save for the first time in a session + "Hosts" refers to the system hosts file, do not loc + + + Location + + + Select location + + + Automatically delete backups + + + Days + + + Set the number of backups to keep. Older backups will be deleted once the limit is reached. + + + Set the number of days to keep backups. Older backups will be deleted once the limit is reached. + + + Never + + + Based on count + + + Based on age and count + + + Set an optional number of backups to always keep despite their age + + + Backup count + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs index 34b6157d63..04eea7c1e4 100644 --- a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs @@ -5,8 +5,8 @@ using System; using System.Runtime.CompilerServices; using System.Threading; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -33,6 +33,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch); + public ButtonClickCommand SelectBackupPathEventHandler => new ButtonClickCommand(SelectBackupPath); + public bool IsEnabled { get => _isEnabled; @@ -144,6 +146,74 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool BackupHosts + { + get => Settings.Properties.BackupHosts; + set + { + if (value != Settings.Properties.BackupHosts) + { + Settings.Properties.BackupHosts = value; + NotifyPropertyChanged(); + } + } + } + + public string BackupPath + { + get => Settings.Properties.BackupPath; + set + { + if (value != Settings.Properties.BackupPath) + { + Settings.Properties.BackupPath = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsMode + { + get => (int)Settings.Properties.DeleteBackupsMode; + set + { + if (value != (int)Settings.Properties.DeleteBackupsMode) + { + Settings.Properties.DeleteBackupsMode = (HostsDeleteBackupMode)value; + NotifyPropertyChanged(); + OnPropertyChanged(nameof(MinimumBackupsCount)); + } + } + } + + public int DeleteBackupsDays + { + get => Settings.Properties.DeleteBackupsDays; + set + { + if (value != Settings.Properties.DeleteBackupsDays) + { + Settings.Properties.DeleteBackupsDays = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsCount + { + get => Settings.Properties.DeleteBackupsCount; + set + { + if (value != Settings.Properties.DeleteBackupsCount) + { + Settings.Properties.DeleteBackupsCount = value; + NotifyPropertyChanged(); + } + } + } + + public int MinimumBackupsCount => DeleteBackupsMode == 1 ? 1 : 0; + public HostsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository moduleSettingsRepository, Func ipcMSGCallBackFunc, bool isElevated) { SettingsUtils = settingsUtils; @@ -192,5 +262,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels InitializeEnabledValue(); OnPropertyChanged(nameof(IsEnabled)); } + + public void SelectBackupPath() + { + // This function was changed to use the shell32 API to open folder dialog + // as the old one (PickSingleFolderAsync) can't work when the process is elevated + // TODO: go back PickSingleFolderAsync when it's fixed + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow()); + var result = ShellGetFolder.GetFolderDialog(hwnd); + if (!string.IsNullOrEmpty(result)) + { + BackupPath = result; + } + } } } From 1ad468641be12977cd37799f18dc28c05d0640e8 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 5 Nov 2025 10:42:24 +0100 Subject: [PATCH 38/59] [UX] Dashboard utilities sorting (#42065) ## Summary of the Pull Request This PR adds a sorting button to the module list so it can be sorted alphabetically (default) or by status. Fixes: #41837 image @yeelam-gordon When running the runner, I do see the settings value is being updated. But when running the runner again the setting doesn't seem to be saved? Is that because of a bug in my implementation, or am I testing it wrong :)? image ## PR Checklist - [x] 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 --------- Signed-off-by: Shawn Yuan (from Dev Box) Co-authored-by: Shawn Yuan (from Dev Box) --- src/runner/general_settings.cpp | 30 +++++++++ src/runner/general_settings.h | 7 ++ .../Settings.UI.Library/GeneralSettings.cs | 10 +++ .../Converters/EnumToBooleanConverter.cs | 31 +++++++++ .../SettingsXAML/Views/DashboardPage.xaml | 30 ++++++++- .../SettingsXAML/Views/DashboardPage.xaml.cs | 10 +++ .../Settings.UI/Strings/en-us/Resources.resw | 16 ++++- .../ViewModels/DashboardViewModel.cs | 67 ++++++++++++++----- 8 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs diff --git a/src/runner/general_settings.cpp b/src/runner/general_settings.cpp index 50dd8dbbc8..9f8ceeb8ad 100644 --- a/src/runner/general_settings.cpp +++ b/src/runner/general_settings.cpp @@ -35,6 +35,31 @@ namespace ensure_ignored_conflict_properties_shape(obj); return obj; } + + DashboardSortOrder parse_dashboard_sort_order(const json::JsonObject& obj, DashboardSortOrder fallback) + { + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::Number)) + { + const auto raw_value = static_cast(obj.GetNamedNumber(L"dashboard_sort_order", static_cast(static_cast(fallback)))); + return raw_value == static_cast(DashboardSortOrder::ByStatus) ? DashboardSortOrder::ByStatus : DashboardSortOrder::Alphabetical; + } + + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::String)) + { + const auto raw = obj.GetNamedString(L"dashboard_sort_order"); + if (raw == L"ByStatus") + { + return DashboardSortOrder::ByStatus; + } + + if (raw == L"Alphabetical") + { + return DashboardSortOrder::Alphabetical; + } + } + + return fallback; + } } // TODO: would be nice to get rid of these globals, since they're basically cached json settings @@ -46,6 +71,7 @@ static bool download_updates_automatically = true; static bool show_whats_new_after_updates = true; static bool enable_experimentation = true; static bool enable_warnings_elevated_apps = true; +static DashboardSortOrder dashboard_sort_order = DashboardSortOrder::Alphabetical; static json::JsonObject ignored_conflict_properties = create_default_ignored_conflict_properties(); json::JsonObject GeneralSettings::to_json() @@ -75,6 +101,7 @@ json::JsonObject GeneralSettings::to_json() result.SetNamedValue(L"download_updates_automatically", json::value(downloadUpdatesAutomatically)); result.SetNamedValue(L"show_whats_new_after_updates", json::value(showWhatsNewAfterUpdates)); result.SetNamedValue(L"enable_experimentation", json::value(enableExperimentation)); + result.SetNamedValue(L"dashboard_sort_order", json::value(static_cast(dashboardSortOrder))); result.SetNamedValue(L"is_admin", json::value(isAdmin)); result.SetNamedValue(L"enable_warnings_elevated_apps", json::value(enableWarningsElevatedApps)); result.SetNamedValue(L"theme", json::value(theme)); @@ -99,6 +126,7 @@ json::JsonObject load_general_settings() show_whats_new_after_updates = loaded.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true); enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true); + dashboard_sort_order = parse_dashboard_sort_order(loaded, dashboard_sort_order); if (json::has(loaded, L"ignored_conflict_properties", json::JsonValueType::Object)) { @@ -128,6 +156,7 @@ GeneralSettings get_general_settings() .downloadUpdatesAutomatically = download_updates_automatically && is_user_admin, .showWhatsNewAfterUpdates = show_whats_new_after_updates, .enableExperimentation = enable_experimentation, + .dashboardSortOrder = dashboard_sort_order, .theme = settings_theme, .systemTheme = WindowsColors::is_dark_mode() ? L"dark" : L"light", .powerToysVersion = get_product_version(), @@ -159,6 +188,7 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) show_whats_new_after_updates = general_configs.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = general_configs.GetNamedBoolean(L"enable_experimentation", true); + dashboard_sort_order = parse_dashboard_sort_order(general_configs, dashboard_sort_order); // apply_general_settings is called by the runner's WinMain, so we can just force the run at startup gpo rule here. auto gpo_run_as_startup = powertoys_gpo::getConfiguredRunAtStartupValue(); diff --git a/src/runner/general_settings.h b/src/runner/general_settings.h index 38fbd5789a..b4f7638846 100644 --- a/src/runner/general_settings.h +++ b/src/runner/general_settings.h @@ -2,6 +2,12 @@ #include +enum class DashboardSortOrder +{ + Alphabetical = 0, + ByStatus = 1, +}; + struct GeneralSettings { bool isStartupEnabled; @@ -16,6 +22,7 @@ struct GeneralSettings bool downloadUpdatesAutomatically; bool showWhatsNewAfterUpdates; bool enableExperimentation; + DashboardSortOrder dashboardSortOrder; std::wstring theme; std::wstring systemTheme; std::wstring powerToysVersion; diff --git a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs index 24ff4584fe..0f380aca78 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs @@ -13,6 +13,12 @@ using Settings.UI.Library.Attributes; namespace Microsoft.PowerToys.Settings.UI.Library { + public enum DashboardSortOrder + { + Alphabetical, + ByStatus, + } + public class GeneralSettings : ISettingsConfig { // Gets or sets a value indicating whether run powertoys on start-up. @@ -76,6 +82,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("enable_experimentation")] public bool EnableExperimentation { get; set; } + [JsonPropertyName("dashboard_sort_order")] + public DashboardSortOrder DashboardSortOrder { get; set; } + [JsonPropertyName("ignored_conflict_properties")] public ShortcutConflictProperties IgnoredConflictProperties { get; set; } @@ -89,6 +98,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library ShowNewUpdatesToastNotification = true; AutoDownloadUpdates = false; EnableExperimentation = true; + DashboardSortOrder = DashboardSortOrder.Alphabetical; Theme = "system"; SystemTheme = "light"; try diff --git a/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.cs new file mode 100644 index 0000000000..27689435cc --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/EnumToBooleanConverter.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 Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class EnumToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value == null || parameter == null) + { + return false; + } + + // Get the enum value as string + var enumString = value.ToString(); + var parameterString = parameter.ToString(); + + return enumString.Equals(parameterString, StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml index 545122a56b..643811d2fc 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml @@ -19,6 +19,7 @@ x:Key="ModuleItemTemplateSelector" ActivationTemplate="{StaticResource ModuleItemActivationTemplate}" ShortcutTemplate="{StaticResource ModuleItemShortcutTemplate}" /> + @@ -276,9 +277,36 @@ Padding="0" VerticalAlignment="Top" DividerVisibility="Collapsed"> + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs index a06e5838a4..0d7273f924 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs @@ -66,5 +66,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views App.GetOobeWindow().Activate(); } + + private void SortAlphabetical_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.Alphabetical; + } + + private void SortByStatus_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.ByStatus; + } } } 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 95ce0b5f19..5f22c7e4dc 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2999,7 +2999,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Crosshairs fixed length (px) px = pixels - + Crosshairs orientation @@ -4462,6 +4462,18 @@ Activate by holding the key for the character you want to add an accent to, then Home + + Sort utilities + + + Alphabetically + + + By status + + + Sort utilities + Preview @@ -4770,7 +4782,7 @@ Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or s Smooth zoomed image - + Specify the initial level of magnification when zooming in diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs index 344eaa183f..15a50b5dbd 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs @@ -62,6 +62,23 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + private DashboardSortOrder _dashboardSortOrder = DashboardSortOrder.Alphabetical; + + public DashboardSortOrder DashboardSortOrder + { + get => generalSettingsConfig.DashboardSortOrder; + set + { + if (Set(ref _dashboardSortOrder, value)) + { + generalSettingsConfig.DashboardSortOrder = value; + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(generalSettingsConfig); + SendConfigMSG(outgoing.ToString()); + RefreshModuleList(); + } + } + } + private ISettingsRepository _settingsRepository; private GeneralSettings generalSettingsConfig; private Windows.ApplicationModel.Resources.ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; @@ -73,14 +90,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels generalSettingsConfig = settingsRepository.SettingsConfig; generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChangedOnSettingsPage); + // Initialize dashboard sort order from settings + _dashboardSortOrder = generalSettingsConfig.DashboardSortOrder; + // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; - foreach (ModuleType moduleType in Enum.GetValues()) - { - AddDashboardListItem(moduleType); - } - + RefreshModuleList(); GetShortcutModules(); } @@ -113,21 +129,39 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); } - private void AddDashboardListItem(ModuleType moduleType) + private void RefreshModuleList() { - GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); - var newItem = new DashboardListItem() + AllModules.Clear(); + + var moduleItems = new List(); + + foreach (ModuleType moduleType in Enum.GetValues()) { - Tag = moduleType, - Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), - IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), - IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, - Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), - DashboardModuleItems = GetModuleItems(moduleType), + GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); + var newItem = new DashboardListItem() + { + Tag = moduleType, + Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), + IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), + IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, + Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), + DashboardModuleItems = GetModuleItems(moduleType), + }; + newItem.EnabledChangedCallback = EnabledChangedOnUI; + moduleItems.Add(newItem); + } + + // Sort based on current sort order + var sortedItems = DashboardSortOrder switch + { + DashboardSortOrder.ByStatus => moduleItems.OrderByDescending(x => x.IsEnabled).ThenBy(x => x.Label), + _ => moduleItems.OrderBy(x => x.Label), // Default alphabetical }; - AllModules.Add(newItem); - newItem.EnabledChangedCallback = EnabledChangedOnUI; + foreach (var item in sortedItems) + { + AllModules.Add(item); + } } private void EnabledChangedOnUI(DashboardListItem dashboardListItem) @@ -149,6 +183,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { try { + RefreshModuleList(); GetShortcutModules(); OnPropertyChanged(nameof(ShortcutModules)); From cd988b798b787b3e95c19d4d96614d5647cd1be6 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Wed, 5 Nov 2025 03:28:25 -0800 Subject: [PATCH 39/59] Add Cursor Wrap functionality to Powertoys Mouse Utils (#41826) ## Summary of the Pull Request Cursor Wrap makes it simple to move the mouse from one edge of a display (or set of displays) to the opposite edge of the display stack - on a single display Cursor Wrap will wrap top/bottom and left/right edges. https://github.com/user-attachments/assets/3feb606c-142b-4dab-9824-7597833d3ba4 ## PR Checklist - [x] Closes: CursorWrap #41759 - [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 - [x] **New binaries:** Added on the required places - [x] [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 PR adds a new mouse utils module, this is 'Cursor Wrap' - Cursor Wrap works with 1-9 monitors based on the logical monitor layout of the PC - for a single monitor device the cursor is wrapped for the top/bottom and left/right edges of the display - for a multi-monitor setup the cursor is wrapped on the top/bottom left/right of the displays in the logical display layout. ## Validation Steps Performed Validation has been performed on a Surface Laptop 7 Pro (Intel) with a single display and with an HDMI USB-C second display configured to be a second monitor in top/left/right/bottom configuration - there are also tests that run as part of the build to validate logical monitor layout and cursor positioning. --------- Co-authored-by: Niels Laute Co-authored-by: Kai Tao (from Dev Box) Co-authored-by: Gordon Lam (SH) --- .github/actions/spell-check/expect.txt | 3 + .pipelines/ESRPSigning_core.json | 1 + PowerToys.sln | 11 + src/common/GPOWrapper/GPOWrapper.cpp | 4 + src/common/GPOWrapper/GPOWrapper.h | 1 + src/common/GPOWrapper/GPOWrapper.idl | 1 + src/common/ManagedCommon/ModuleType.cs | 1 + src/common/logger/logger_settings.h | 1 + src/common/utils/gpo.h | 7 + .../MouseUtils/CursorWrap/CursorWrap.rc | 46 + .../MouseUtils/CursorWrap/CursorWrap.vcxproj | 130 ++ .../MouseUtils/CursorWrap/CursorWrapTests.h | 213 ++++ src/modules/MouseUtils/CursorWrap/dllmain.cpp | 1045 +++++++++++++++++ .../MouseUtils/CursorWrap/packages.config | 4 + src/modules/MouseUtils/CursorWrap/pch.cpp | 1 + src/modules/MouseUtils/CursorWrap/pch.h | 13 + src/modules/MouseUtils/CursorWrap/resource.h | 4 + src/modules/MouseUtils/CursorWrap/trace.cpp | 31 + src/modules/MouseUtils/CursorWrap/trace.h | 11 + .../SamplePagesExtension.csproj | 10 +- src/runner/main.cpp | 1 + .../CursorWrapProperties.cs | 32 + .../Settings.UI.Library/CursorWrapSettings.cs | 53 + .../Settings.UI.Library/EnabledModules.cs | 16 + .../SndCursorWrapSettings.cs | 29 + .../Assets/Settings/Icons/CursorWrap.png | Bin 0 -> 1124 bytes .../Settings.UI/Helpers/ModuleHelper.cs | 7 +- .../SettingsXAML/Views/MouseUtilsPage.xaml | 39 +- .../SettingsXAML/Views/MouseUtilsPage.xaml.cs | 1 + .../Settings.UI/Strings/en-us/Resources.resw | 37 +- .../ViewModels/MouseUtilsViewModel.cs | 144 ++- 31 files changed, 1888 insertions(+), 9 deletions(-) create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrap.rc create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapTests.h create mode 100644 src/modules/MouseUtils/CursorWrap/dllmain.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/packages.config create mode 100644 src/modules/MouseUtils/CursorWrap/pch.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/pch.h create mode 100644 src/modules/MouseUtils/CursorWrap/resource.h create mode 100644 src/modules/MouseUtils/CursorWrap/trace.cpp create mode 100644 src/modules/MouseUtils/CursorWrap/trace.h create mode 100644 src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs create mode 100644 src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs create mode 100644 src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs create mode 100644 src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c50b54b1d9..491347d5ca 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -22,6 +22,7 @@ ADate ADDSTRING ADDUNDORECORD ADifferent +adjacents ADMINS adml admx @@ -313,6 +314,8 @@ CURRENTDIR CURSORINFO cursorpos CURSORSHOWING +CURSORWRAP +CursorWrap customaction CUSTOMACTIONTEST CUSTOMFORMATPLACEHOLDER diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 5b6dd50bb5..c419d1b588 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -181,6 +181,7 @@ "PowerToys.MousePointerCrosshairs.dll", "PowerToys.MouseJumpUI.dll", "PowerToys.MouseJumpUI.exe", + "PowerToys.CursorWrap.dll", "PowerToys.MouseWithoutBorders.dll", "PowerToys.MouseWithoutBorders.exe", diff --git a/PowerToys.sln b/PowerToys.sln index b343993b68..e34779c5bb 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -822,6 +822,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CursorWrap", "src\modules\MouseUtils\CursorWrap\CursorWrap.vcxproj", "{48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DCCD936-D085-4869-A1DE-CA6A64152C94}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\modules\LightSwitch\Tests\LightSwitch.UITests\LightSwitch.UITests.csproj", "{F5333ED7-06D8-4AB3-953A-36D63F08CB6F}" @@ -2990,6 +2992,14 @@ Global {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|ARM64.Build.0 = Debug|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.ActiveCfg = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Debug|x64.Build.0 = Debug|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.ActiveCfg = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|ARM64.Build.0 = Release|ARM64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.ActiveCfg = Release|x64 + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5}.Release|x64.Build.0 = Release|x64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.ActiveCfg = Debug|ARM64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Build.0 = Debug|ARM64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Debug|ARM64.Deploy.0 = Debug|ARM64 @@ -3351,6 +3361,7 @@ Global {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {48A1DB8C-5DF8-4FB3-9E14-2B67F3F2D8B5} = {322566EF-20DC-43A6-B9F8-616AF942579A} {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index b8df132fe4..52c91a0795 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -112,6 +112,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredMousePointerCrosshairsEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredCursorWrapEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredCursorWrapEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredPowerRenameEnabledValue() { return static_cast(powertoys_gpo::getConfiguredPowerRenameEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index 4f60a989db..846fba0a61 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -35,6 +35,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); static GpoRuleConfigured GetConfiguredQuickAccentEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index d1af719998..ab6dbf0c29 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -38,6 +38,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredMouseWithoutBordersEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index aa741e2f3a..d7ae386191 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -12,6 +12,7 @@ namespace ManagedCommon ColorPicker, CmdPal, CropAndLock, + CursorWrap, EnvironmentVariables, FancyZones, FileLocksmith, diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index b2e05fadfe..881633e05e 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -59,6 +59,7 @@ struct LogSettings inline const static std::string mouseHighlighterLoggerName = "mouse-highlighter"; inline const static std::string mouseJumpLoggerName = "mouse-jump"; inline const static std::string mousePointerCrosshairsLoggerName = "mouse-pointer-crosshairs"; + inline const static std::string cursorWrapLoggerName = "cursor-wrap"; inline const static std::string imageResizerLoggerName = "imageresizer"; inline const static std::string powerRenameLoggerName = "powerrename"; inline const static std::string alwaysOnTopLoggerName = "always-on-top"; diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index db1e0f72e8..ecf338d212 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace powertoys_gpo { @@ -51,6 +52,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_HIGHLIGHTER = L"ConfigureEnabledUtilityMouseHighlighter"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_JUMP = L"ConfigureEnabledUtilityMouseJump"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS = L"ConfigureEnabledUtilityMousePointerCrosshairs"; + const std::wstring POLICY_CONFIGURE_ENABLED_CURSOR_WRAP = L"ConfigureEnabledUtilityCursorWrap"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_RENAME = L"ConfigureEnabledUtilityPowerRename"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER = L"ConfigureEnabledUtilityPowerLauncher"; const std::wstring POLICY_CONFIGURE_ENABLED_QUICK_ACCENT = L"ConfigureEnabledUtilityQuickAccent"; @@ -409,6 +411,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS); } + inline gpo_rule_configured_t getConfiguredCursorWrapEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CURSOR_WRAP); + } + inline gpo_rule_configured_t getConfiguredPowerRenameEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_POWER_RENAME); diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.rc b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc new file mode 100644 index 0000000000..37752edae0 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc @@ -0,0 +1,46 @@ +#include +#include "resource.h" +#include "../../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +#include "winres.h" +#undef APSTUDIO_READONLY_SYMBOLS + +1 VERSIONINFO + FILEVERSION FILE_VERSION + PRODUCTVERSION PRODUCT_VERSION + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS_NT_WINDOWS32 + FILETYPE VFT_DLL + FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", "PowerToys CursorWrap" + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", "CursorWrap" + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", "PowerToys.CursorWrap.dll" + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +STRINGTABLE +BEGIN + IDS_CURSORWRAP_NAME L"CursorWrap" + IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG L"Disable wrapping during drag" +END \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj new file mode 100644 index 0000000000..59e2095ca7 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -0,0 +1,130 @@ + + + + + 15.0 + {48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5} + Win32Proj + CursorWrap + CursorWrap + + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + PowerToys.CursorWrap + + + true + + + false + + + + Level3 + Disabled + true + _DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreadedDebug + stdcpplatest + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Level3 + MaxSpeed + true + true + true + NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + MultiThreaded + stdcpplatest + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + + + + + + + + + + + Create + + + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h new file mode 100644 index 0000000000..4274ad714f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +// Test case structure for comprehensive monitor layout testing +struct MonitorTestCase +{ + std::string name; + std::string description; + int grid[3][3]; // 3x3 grid representing monitor layout (0 = no monitor, 1-9 = monitor ID) + + // Test scenarios to validate + struct TestScenario + { + int sourceMonitor; // Which monitor to start cursor on (1-based) + int edgeDirection; // 0=top, 1=right, 2=bottom, 3=left + int expectedTargetMonitor; // Expected destination monitor (1-based, -1 = wrap within same monitor) + std::string description; + }; + + std::vector scenarios; +}; + +// Comprehensive test cases for all possible 3x3 monitor grid configurations +class CursorWrapTestSuite +{ +public: + static std::vector GetAllTestCases() + { + std::vector testCases; + + // Test Case 1: Single monitor (center) + testCases.push_back({ + "Single_Center", + "Single monitor in center position", + { + {0, 0, 0}, + {0, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, -1, "Top edge wraps to bottom of same monitor"}, + {1, 1, -1, "Right edge wraps to left of same monitor"}, + {1, 2, -1, "Bottom edge wraps to top of same monitor"}, + {1, 3, -1, "Left edge wraps to right of same monitor"} + } + }); + + // Test Case 2: Two monitors horizontal (left + right) + testCases.push_back({ + "Dual_Horizontal_Left_Right", + "Two monitors: left + right", + { + {0, 0, 0}, + {1, 0, 2}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom of monitor 1"}, + {1, 1, 2, "Monitor 1 right edge moves to monitor 2 left"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left edge wraps to right of monitor 1"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right edge wraps to left of monitor 2"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, 1, "Monitor 2 left edge moves to monitor 1 right"} + } + }); + + // Test Case 3: Two monitors vertical (Monitor 2 above Monitor 1) - CORRECTED FOR USER'S SETUP + testCases.push_back({ + "Dual_Vertical_2_Above_1", + "Two monitors: Monitor 2 (top) above Monitor 1 (bottom/main)", + { + {0, 2, 0}, // Row 0: Monitor 2 (physically top monitor) + {0, 0, 0}, // Row 1: Empty + {0, 1, 0} // Row 2: Monitor 1 (physically bottom/main monitor) + }, + { + // Monitor 1 (bottom/main monitor) tests + {1, 0, 2, "Monitor 1 (bottom) top edge should move to Monitor 2 (top) bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left wraps to right of monitor 1"}, + + // Monitor 2 (top monitor) tests + {2, 0, -1, "Monitor 2 (top) top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right wraps to left of monitor 2"}, + {2, 2, 1, "Monitor 2 (top) bottom edge should move to Monitor 1 (bottom) top"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"} + } + }); + + // Test Case 4: Three monitors L-shape (center + left + top) + testCases.push_back({ + "Triple_L_Shape", + "Three monitors in L-shape: center + left + top", + { + {0, 3, 0}, + {2, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, 3, "Monitor 1 top moves to monitor 3 bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, 2, "Monitor 1 left moves to monitor 2 right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, 1, "Monitor 2 right moves to monitor 1 left"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"}, + {3, 0, -1, "Monitor 3 top wraps to bottom of monitor 3"}, + {3, 1, -1, "Monitor 3 right wraps to left of monitor 3"}, + {3, 2, 1, "Monitor 3 bottom moves to monitor 1 top"}, + {3, 3, -1, "Monitor 3 left wraps to right of monitor 3"} + } + }); + + // Test Case 5: Three monitors horizontal (left + center + right) + testCases.push_back({ + "Triple_Horizontal", + "Three monitors horizontal: left + center + right", + { + {0, 0, 0}, + {1, 2, 3}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, 2, "Monitor 1 right moves to monitor 2"}, + {1, 2, -1, "Monitor 1 bottom wraps to top"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom"}, + {2, 1, 3, "Monitor 2 right moves to monitor 3"}, + {2, 2, -1, "Monitor 2 bottom wraps to top"}, + {2, 3, 1, "Monitor 2 left moves to monitor 1"}, + {3, 0, -1, "Monitor 3 top wraps to bottom"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, 2, "Monitor 3 left moves to monitor 2"} + } + }); + + // Test Case 6: Three monitors vertical (top + center + bottom) + testCases.push_back({ + "Triple_Vertical", + "Three monitors vertical: top + center + bottom", + { + {0, 1, 0}, + {0, 2, 0}, + {0, 3, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left"}, + {1, 2, 2, "Monitor 1 bottom moves to monitor 2"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, 1, "Monitor 2 top moves to monitor 1"}, + {2, 1, -1, "Monitor 2 right wraps to left"}, + {2, 2, 3, "Monitor 2 bottom moves to monitor 3"}, + {2, 3, -1, "Monitor 2 left wraps to right"}, + {3, 0, 2, "Monitor 3 top moves to monitor 2"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, -1, "Monitor 3 left wraps to right"} + } + }); + + return testCases; + } + + // Helper function to print test case in a readable format + static std::string FormatTestCase(const MonitorTestCase& testCase) + { + std::string result = "Test Case: " + testCase.name + "\n"; + result += "Description: " + testCase.description + "\n"; + result += "Layout:\n"; + + for (int row = 0; row < 3; row++) + { + result += " "; + for (int col = 0; col < 3; col++) + { + if (testCase.grid[row][col] == 0) + { + result += ". "; + } + else + { + result += std::to_string(testCase.grid[row][col]) + " "; + } + } + result += "\n"; + } + + result += "Test Scenarios:\n"; + for (const auto& scenario : testCase.scenarios) + { + result += " - " + scenario.description + "\n"; + } + + return result; + } + + // Helper function to validate a specific test case against actual behavior + static bool ValidateTestCase(const MonitorTestCase& testCase) + { + // This would be called with actual CursorWrap instance to validate behavior + // For now, just return true - this would need actual implementation + return true; + } +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp new file mode 100644 index 0000000000..ece1948d01 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -0,0 +1,1045 @@ +#include "pch.h" +#include "../../../interface/powertoy_module_interface.h" +#include "../../../common/SettingsAPI/settings_objects.h" +#include "trace.h" +#include "../../../common/utils/process_path.h" +#include "../../../common/utils/resources.h" +#include "../../../common/logger/logger.h" +#include "../../../common/utils/logger_helper.h" +#include +#include +#include +#include +#include +#include +#include +#include "resource.h" +#include "CursorWrapTests.h" + +// Disable C26451 arithmetic overflow warning for this file since the operations are safe in this context +#pragma warning(disable: 26451) + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +// Non-Localizable strings +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_VALUE[] = L"value"; + 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"; +} + +// The PowerToy name that will be shown in the settings. +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; +}; + +// Forward declaration +class CursorWrap; + +// Global instance pointer for the mouse hook +static CursorWrap* g_cursorWrapInstance = nullptr; + +// Implement the PowerToy Module Interface and all the required methods. +class CursorWrap : public PowertoyModuleIface +{ +private: + // The PowerToy state. + bool m_enabled = false; + bool m_autoActivate = false; + bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag + + // Mouse hook + HHOOK m_mouseHook = nullptr; + std::atomic m_hookActive{ false }; + + // Monitor information + std::vector m_monitors; + MonitorTopology m_topology; + + // Hotkey + Hotkey m_activationHotkey{}; + +public: + // Constructor + CursorWrap() + { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::cursorWrapLoggerName); + init_settings(); + UpdateMonitorInfo(); + g_cursorWrapInstance = this; // Set global instance pointer + }; + + // Destroy the powertoy and free memory + virtual void destroy() override + { + StopMouseHook(); + g_cursorWrapInstance = nullptr; // Clear global instance pointer + delete this; + } + + // Return the localized display name of the powertoy + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the non localized key of the powertoy, this will be cached by the runner + virtual const wchar_t* get_key() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredCursorWrapEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + PowerToysSettings::Settings settings(hinstance, get_name()); + + settings.set_description(IDS_CURSORWRAP_NAME); + settings.set_icon_key(L"pt-cursor-wrap"); + + // Create HotkeyObject from the Hotkey struct for the settings + auto hotkey_object = PowerToysSettings::HotkeyObject::from_settings( + m_activationHotkey.win, + m_activationHotkey.ctrl, + m_activationHotkey.alt, + m_activationHotkey.shift, + m_activationHotkey.key); + + settings.add_hotkey(JSON_KEY_ACTIVATION_SHORTCUT, IDS_CURSORWRAP_NAME, hotkey_object); + settings.add_bool_toggle(JSON_KEY_AUTO_ACTIVATE, IDS_CURSORWRAP_NAME, m_autoActivate); + settings.add_bool_toggle(JSON_KEY_DISABLE_WRAP_DURING_DRAG, IDS_CURSORWRAP_NAME, m_disableWrapDuringDrag); + + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + virtual void call_custom_action(const wchar_t* /*action*/) override {} + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + // Parse the input JSON string. + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_settings(values); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to parse CursorWrap settings json."); + } + } + + // Enable the powertoy + virtual void enable() + { + m_enabled = true; + Trace::EnableCursorWrap(true); + + if (m_autoActivate) + { + StartMouseHook(); + } + } + + // Disable the powertoy + virtual void disable() + { + m_enabled = false; + Trace::EnableCursorWrap(false); + StopMouseHook(); + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Returns whether the PowerToys should be enabled by default + virtual bool is_enabled_by_default() const override + { + return false; + } + + // Legacy hotkey support + virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override + { + if (buffer && buffer_size >= 1) + { + buffer[0] = m_activationHotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (!m_enabled || hotkeyId != 0) + { + return false; + } + + // Toggle cursor wrapping + if (m_hookActive) + { + StopMouseHook(); + } + else + { + StartMouseHook(); +#ifdef _DEBUG + // Run comprehensive tests when hook is started in debug builds + RunComprehensiveTests(); +#endif + } + + return true; + } + +private: + // Load the settings file. + void init_settings() + { + try + { + // Load and parse the settings file for this PowerToy. + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(CursorWrap::get_key()); + parse_settings(settings); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to load the CursorWrap settings json from file."); + } + } + + void parse_settings(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + // Parse activation HotKey + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); + + m_activationHotkey.win = hotkey.win_pressed(); + m_activationHotkey.ctrl = hotkey.ctrl_pressed(); + m_activationHotkey.shift = hotkey.shift_pressed(); + m_activationHotkey.alt = hotkey.alt_pressed(); + m_activationHotkey.key = static_cast(hotkey.get_code()); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap activation shortcut"); + } + + try + { + // Parse auto activate + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE); + m_autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap auto activate from settings. Will use default value"); + } + + try + { + // Parse disable wrap during drag + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_DISABLE_WRAP_DURING_DRAG)) + { + auto disableDragObject = propertiesObject.GetNamedObject(JSON_KEY_DISABLE_WRAP_DURING_DRAG); + m_disableWrapDuringDrag = disableDragObject.GetNamedBoolean(JSON_KEY_VALUE); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap disable wrap during drag from settings. Will use default value (true)"); + } + } + else + { + Logger::info("CursorWrap settings are empty"); + } + + // Set default hotkey if not configured + if (m_activationHotkey.key == 0) + { + m_activationHotkey.win = true; + m_activationHotkey.alt = true; + m_activationHotkey.ctrl = false; + m_activationHotkey.shift = false; + m_activationHotkey.key = 'U'; // Win+Alt+U + } + } + + 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) + { + Logger::info("CursorWrap mouse hook already active"); + return; + } + + UpdateMonitorInfo(); + + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(nullptr), 0); + if (m_mouseHook) + { + m_hookActive = true; + Logger::info("CursorWrap mouse hook started successfully"); +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Hook installed"); +#endif + } + else + { + DWORD error = GetLastError(); + Logger::error(L"Failed to install CursorWrap mouse hook, error: {}", error); + } + } + + void StopMouseHook() + { + if (m_mouseHook) + { + UnhookWindowsHookEx(m_mouseHook); + m_mouseHook = nullptr; + m_hookActive = false; + Logger::info("CursorWrap mouse hook stopped"); +#ifdef _DEBUG + Logger::info("CursorWrap DEBUG: Mouse hook stopped"); +#endif + } + } + + static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) + { + if (nCode >= 0 && wParam == WM_MOUSEMOVE) + { + auto* pMouseStruct = reinterpret_cast(lParam); + POINT currentPos = { pMouseStruct->pt.x, pMouseStruct->pt.y }; + + if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) + { + POINT newPos = g_cursorWrapInstance->HandleMouseMove(currentPos); + if (newPos.x != currentPos.x || newPos.y != currentPos.y) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Wrapping cursor from ({}, {}) to ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); +#endif + SetCursorPos(newPos.x, newPos.y); + return 1; // Suppress the original message + } + } + } + + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } + + // *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC *** + // Implements vertical scrolling to bottom/top of vertical stack as requested + 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 + if (currentPos.y <= currentMonitorInfo.rcMonitor.top) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED ======="); +#endif + + // 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 + + // 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 handle horizontal wrapping if we haven't already wrapped vertically + if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left) + { +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED ======="); +#endif + + // 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 + + // 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 the general algorithm + RECT totalBounds = monitors[0].rect; + for (const auto& monitor : monitors) + { + totalBounds.left = min(totalBounds.left, monitor.rect.left); + totalBounds.top = min(totalBounds.top, monitor.rect.top); + totalBounds.right = max(totalBounds.right, monitor.rect.right); + totalBounds.bottom = max(totalBounds.bottom, monitor.rect.bottom); + } + + int totalWidth = totalBounds.right - totalBounds.left; + int totalHeight = totalBounds.bottom - totalBounds.top; + int gridWidth = max(1, totalWidth / 3); + int gridHeight = max(1, totalHeight / 3); + + // Place monitors in the 3x3 grid based on their center points + for (const auto& monitor : monitors) + { + HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); + + // Calculate center point of monitor + int centerX = (monitor.rect.left + monitor.rect.right) / 2; + int centerY = (monitor.rect.top + monitor.rect.bottom) / 2; + + // Map to grid position + int col = (centerX - totalBounds.left) / gridWidth; + int row = (centerY - totalBounds.top) / gridHeight; + + // Ensure we stay within bounds + col = max(0, min(2, col)); + row = max(0, min(2, row)); + + grid[row][col] = hMonitor; + monitorToPosition[hMonitor] = {row, col, true}; + positionToMonitor[{row, col}] = hMonitor; + +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}], center=({}, {})", + monitor.monitorId, row, col, centerX, centerY); +#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/modules/MouseUtils/CursorWrap/packages.config b/src/modules/MouseUtils/CursorWrap/packages.config new file mode 100644 index 0000000000..2c5d71ae86 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.cpp b/src/modules/MouseUtils/CursorWrap/pch.cpp new file mode 100644 index 0000000000..17305716aa --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.h b/src/modules/MouseUtils/CursorWrap/pch.h new file mode 100644 index 0000000000..86f11c99ba --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.h @@ -0,0 +1,13 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#include + +#include +#include +#include + +// Note: Common includes moved to individual source files due to include path issues +// #include +// #include +// #include \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/resource.h b/src/modules/MouseUtils/CursorWrap/resource.h new file mode 100644 index 0000000000..9b49c0e3cc --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/resource.h @@ -0,0 +1,4 @@ +#pragma once + +#define IDS_CURSORWRAP_NAME 101 +#define IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG 102 diff --git a/src/modules/MouseUtils/CursorWrap/trace.cpp b/src/modules/MouseUtils/CursorWrap/trace.cpp new file mode 100644 index 0000000000..ebfe32c23c --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.cpp @@ -0,0 +1,31 @@ +#include "pch.h" +#include "trace.h" + +#include "../../../../common/Telemetry/TraceBase.h" + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::EnableCursorWrap(const bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "CursorWrap_EnableCursorWrap", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/trace.h b/src/modules/MouseUtils/CursorWrap/trace.h new file mode 100644 index 0000000000..b2f6a9a8eb --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +class Trace : public telemetry::TraceBase +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void EnableCursorWrap(const bool enabled) noexcept; +}; \ No newline at end of file diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj index 4007e6a986..964211ddff 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj @@ -62,10 +62,18 @@ true - + + true true true + + + false + false + false + + diff --git a/src/runner/main.cpp b/src/runner/main.cpp index 4b29149f78..c20293f9ed 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -161,6 +161,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.MouseJump.dll", L"PowerToys.AlwaysOnTopModuleInterface.dll", L"PowerToys.MousePointerCrosshairs.dll", + L"PowerToys.CursorWrap.dll", L"PowerToys.PowerAccentModuleInterface.dll", L"PowerToys.PowerOCRModuleInterface.dll", L"PowerToys.AdvancedPasteModuleInterface.dll", diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs new file mode 100644 index 0000000000..cf66b4ba09 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -0,0 +1,32 @@ +// 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.Text.Json.Serialization; + +using Settings.UI.Library.Attributes; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapProperties + { + [CmdConfigureIgnore] + public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x55); // Win + Alt + U + + [JsonPropertyName("activation_shortcut")] + public HotkeySettings ActivationShortcut { get; set; } + + [JsonPropertyName("auto_activate")] + public BoolProperty AutoActivate { get; set; } + + [JsonPropertyName("disable_wrap_during_drag")] + public BoolProperty DisableWrapDuringDrag { get; set; } + + public CursorWrapProperties() + { + ActivationShortcut = DefaultActivationShortcut; + AutoActivate = new BoolProperty(false); + DisableWrapDuringDrag = new BoolProperty(true); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs new file mode 100644 index 0000000000..8c9059123c --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig + { + public const string ModuleName = "CursorWrap"; + + [JsonPropertyName("properties")] + public CursorWrapProperties Properties { get; set; } + + public CursorWrapSettings() + { + Name = ModuleName; + Properties = new CursorWrapProperties(); + Version = "1.0"; + } + + public string GetModuleName() + { + return Name; + } + + public ModuleType GetModuleType() => ModuleType.CursorWrap; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_CursorWrap_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + // This can be utilized in the future if the settings.json file is to be modified/deleted. + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 977c03b839..d7100d9ae4 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -513,6 +513,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool cursorWrap; // defaulting to off + + [JsonPropertyName("CursorWrap")] + public bool CursorWrap + { + get => cursorWrap; + set + { + if (cursorWrap != value) + { + LogTelemetryEvent(value); + cursorWrap = value; + } + } + } + private bool lightSwitch; [JsonPropertyName("LightSwitch")] diff --git a/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs new file mode 100644 index 0000000000..3d6d781d03 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs @@ -0,0 +1,29 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndCursorWrapSettings + { + [JsonPropertyName("CursorWrap")] + public CursorWrapSettings CursorWrap { get; set; } + + public SndCursorWrapSettings() + { + } + + public SndCursorWrapSettings(CursorWrapSettings settings) + { + CursorWrap = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png new file mode 100644 index 0000000000000000000000000000000000000000..4374dfdc829f56c9fe800403950cb4b4e912b79a GIT binary patch literal 1124 zcmV-q1e^PbP)M020V? z9*!@Q{Bv$ZC9b@#_vn^7mCgmEnF!IHpXMdN3^y!A|kqs?M7g#`T~++~_rKw%Km0B`cW$49b@2*8BwVDIX|(m}GHnhAI=}`E zcaoywJ$U&?5Go#je4+4|J989it)P$)lJibrCwjUzCN zab4BabiR!hU2QCL$ImWv)PP$-upgGCWk4C0>i4<(qJG* zgj0eDkQTDB-A={BAMr?}#ExA%k<>x-VGP*z@W>(nApvp+)NXKbTtWa8T8wi9k@H;S zYZOE**4kj8qDV*}Xd7d-Urq@|Bg(QwNsKqg3*Ip9?EvRShW#b-yOS*mFTRa0C&HKDXn@^YJ3Kh?qx5T@R0}-T2bs zq~}71DdG0S%Unh3-q*4<10(BtNf`Qzy0=bN3+Rs-elCmXpTCHl7rlKn%raQ_oo<2O zd=3Sm2)dv;hv+sOs~n0TTHU;n%_a305$=PncB!wM5hy~I5FH^vAF3~631A6GgMlK9 zWcpB+U^%uGV5{RA2t*hp3b0}d@pkSmu&Fr;|@9Jxt3ut0N$~t z`b{ZS3Ebe_r^?O2>8u$JL{Z>WkR;YpyuLB- zZR~x~I_iTIXH@D*rF3anNE&_|>X*VaW%;d~^z-!thu$0oFUEQ}Vv9vpz?D)Q q-{jHnqe typeof(CmdPalPage), ModuleType.ColorPicker => typeof(ColorPickerPage), ModuleType.CropAndLock => typeof(CropAndLockPage), + ModuleType.CursorWrap => typeof(MouseUtilsPage), ModuleType.LightSwitch => typeof(LightSwitchPage), ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), ModuleType.FancyZones => typeof(FancyZonesPage), diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index 498adf4803..fc7fb9c39f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -273,6 +273,44 @@ + + + + + + + + + + + + + + + + + + + + + + + - .GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), + SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; 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 5f22c7e4dc..4852d42e40 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2695,12 +2695,47 @@ From there, simply click on one of the supported files in the File Explorer and Use a keyboard shortcut to highlight left and right mouse clicks. Mouse as in the hardware peripheral. + + + + + Enable CursorWrap + + CursorWrap + + + Wrap the mouse cursor between monitor edges + + + + + Activation shortcut + + + Hotkey to toggle cursor wrapping on/off + + + Set shortcut + + + + Disable wrapping while dragging + + + + + Auto-activate on startup + + + Automatically activate on utility startup + + Mouse Pointer Crosshairs Mouse as in the hardware peripheral. - + Draw crosshairs centered around the mouse pointer. 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 eae4f932d6..518b2a6fa4 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs @@ -29,7 +29,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private MousePointerCrosshairsSettings MousePointerCrosshairsSettingsConfig { get; set; } - public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository findMyMouseSettingsRepository, ISettingsRepository mouseHighlighterSettingsRepository, ISettingsRepository mouseJumpSettingsRepository, ISettingsRepository mousePointerCrosshairsSettingsRepository, Func ipcMSGCallBackFunc) + private CursorWrapSettings CursorWrapSettingsConfig { get; set; } + + public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository findMyMouseSettingsRepository, ISettingsRepository mouseHighlighterSettingsRepository, ISettingsRepository mouseJumpSettingsRepository, ISettingsRepository mousePointerCrosshairsSettingsRepository, ISettingsRepository cursorWrapSettingsRepository, Func ipcMSGCallBackFunc) { SettingsUtils = settingsUtils; @@ -103,6 +105,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _mousePointerCrosshairsOrientation = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value; _mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value; + ArgumentNullException.ThrowIfNull(cursorWrapSettingsRepository); + + CursorWrapSettingsConfig = cursorWrapSettingsRepository.SettingsConfig; + _cursorWrapAutoActivate = CursorWrapSettingsConfig.Properties.AutoActivate.Value; + + // Null-safe access in case property wasn't upgraded yet - default to TRUE + _cursorWrapDisableWrapDuringDrag = CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag?.Value ?? true; + int isEnabled = 0; Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); @@ -144,13 +154,25 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { // Get the enabled state from GPO. - _mousePointerCrosshairsEnabledStateIsGPOConfigured = true; + _mousePointerCrosshairsEnabledStateGPOConfigured = true; _isMousePointerCrosshairsEnabled = _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; } else { _isMousePointerCrosshairsEnabled = GeneralSettingsConfig.Enabled.MousePointerCrosshairs; } + + _cursorWrapEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredCursorWrapEnabledValue(); + if (_cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + _cursorWrapEnabledStateIsGPOConfigured = true; + _isCursorWrapEnabled = _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + _isCursorWrapEnabled = GeneralSettingsConfig.Enabled.CursorWrap; + } } public override Dictionary GetAllHotkeySettings() @@ -163,6 +185,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels MousePointerCrosshairsActivationShortcut, GlidingCursorActivationShortcut], [MouseJumpSettings.ModuleName] = [MouseJumpActivationShortcut], + [CursorWrapSettings.ModuleName] = [CursorWrapActivationShortcut], }; return hotkeysDict; @@ -663,7 +686,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels get => _isMousePointerCrosshairsEnabled; set { - if (_mousePointerCrosshairsEnabledStateIsGPOConfigured) + if (_mousePointerCrosshairsEnabledStateGPOConfigured) { // If it's GPO configured, shouldn't be able to change this state. return; @@ -686,7 +709,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public bool IsMousePointerCrosshairsEnabledGpoConfigured { - get => _mousePointerCrosshairsEnabledStateIsGPOConfigured; + get => _mousePointerCrosshairsEnabledStateGPOConfigured; } public HotkeySettings MousePointerCrosshairsActivationShortcut @@ -959,6 +982,110 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SettingsUtils.SaveSettings(MousePointerCrosshairsSettingsConfig.ToJsonString(), MousePointerCrosshairsSettings.ModuleName); } + public bool IsCursorWrapEnabled + { + get => _isCursorWrapEnabled; + set + { + if (_cursorWrapEnabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (_isCursorWrapEnabled != value) + { + _isCursorWrapEnabled = value; + + GeneralSettingsConfig.Enabled.CursorWrap = value; + OnPropertyChanged(nameof(IsCursorWrapEnabled)); + + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig); + SendConfigMSG(outgoing.ToString()); + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool IsCursorWrapEnabledGpoConfigured + { + get => _cursorWrapEnabledStateIsGPOConfigured; + } + + public HotkeySettings CursorWrapActivationShortcut + { + get + { + return CursorWrapSettingsConfig.Properties.ActivationShortcut; + } + + set + { + if (CursorWrapSettingsConfig.Properties.ActivationShortcut != value) + { + CursorWrapSettingsConfig.Properties.ActivationShortcut = value ?? CursorWrapSettingsConfig.Properties.DefaultActivationShortcut; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapAutoActivate + { + get + { + return _cursorWrapAutoActivate; + } + + set + { + if (value != _cursorWrapAutoActivate) + { + _cursorWrapAutoActivate = value; + CursorWrapSettingsConfig.Properties.AutoActivate.Value = value; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapDisableWrapDuringDrag + { + get + { + return _cursorWrapDisableWrapDuringDrag; + } + + set + { + if (value != _cursorWrapDisableWrapDuringDrag) + { + _cursorWrapDisableWrapDuringDrag = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag == null) + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag = new BoolProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public void NotifyCursorWrapPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + + SndCursorWrapSettings outsettings = new SndCursorWrapSettings(CursorWrapSettingsConfig); + SndModuleSettings ipcMessage = new SndModuleSettings(outsettings); + SendConfigMSG(ipcMessage.ToJsonString()); + SettingsUtils.SaveSettings(CursorWrapSettingsConfig.ToJsonString(), CursorWrapSettings.ModuleName); + } + public void RefreshEnabledState() { InitializeEnabledValues(); @@ -966,6 +1093,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsMouseHighlighterEnabled)); OnPropertyChanged(nameof(IsMouseJumpEnabled)); OnPropertyChanged(nameof(IsMousePointerCrosshairsEnabled)); + OnPropertyChanged(nameof(IsCursorWrapEnabled)); } private Func SendConfigMSG { get; } @@ -999,7 +1127,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _highlighterAutoActivate; private GpoRuleConfigured _mousePointerCrosshairsEnabledGpoRuleConfiguration; - private bool _mousePointerCrosshairsEnabledStateIsGPOConfigured; + private bool _mousePointerCrosshairsEnabledStateGPOConfigured; private bool _isMousePointerCrosshairsEnabled; private string _mousePointerCrosshairsColor; private int _mousePointerCrosshairsOpacity; @@ -1013,5 +1141,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private int _mousePointerCrosshairsOrientation; private bool _mousePointerCrosshairsAutoActivate; private bool _isAnimationEnabledBySystem; + + private GpoRuleConfigured _cursorWrapEnabledGpoRuleConfiguration; + private bool _cursorWrapEnabledStateIsGPOConfigured; + private bool _isCursorWrapEnabled; + private bool _cursorWrapAutoActivate; + private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings } } From b5b73618558dd79119617937dee5ca193539430d Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Wed, 5 Nov 2025 12:26:55 +0000 Subject: [PATCH 40/59] [General] Include high-volume bugs in Issue Template header (#43134) ## Summary of the Pull Request Adds references to 3 bugs which are logged by those who are unfamiliar with the issue search function. It is hoped that this will help ease the amount of triaging. ## 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 - Checked the YML preview on my own fork. --- .github/ISSUE_TEMPLATE/bug_report.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f9389b8d91..1a85de1e06 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,6 +7,13 @@ body: - type: markdown attributes: value: Please make sure to [search for existing issues](https://github.com/microsoft/PowerToys/issues) before filing a new one! +- type: markdown + attributes: + value: | + We are aware of the following high-volume issues and are actively working on them. Please check if your issue is one of these before filing a new bug report: + * **PowerToys Run crash related to "Desktop composition is disabled"**: This may appear as `COMException: 0x80263001`. For more details, see issue [#31226](https://github.com/microsoft/PowerToys/issues/31226). + * **PowerToys Run crash with `COMException (0xD0000701)`**: For more details, see issue [#30769](https://github.com/microsoft/PowerToys/issues/30769). + * **PowerToys Run crash with a "Cyclic reference" error**: This `System.InvalidOperationException` is detailed in issue [#36451](https://github.com/microsoft/PowerToys/issues/36451). - id: version type: input attributes: From b7b6ae64857915d2c3e6c92e945ea8979d9adb17 Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 5 Nov 2025 14:23:17 +0100 Subject: [PATCH 41/59] Tweaking the focus state for AP (#43306) Adding the AI underline to the inputbox image --- .../AdvancedPasteXAML/Controls/PromptBox.xaml | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index 4450f7fdb1..9a6b8d04c9 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -14,28 +14,13 @@ mc:Ignorable="d"> - - - #65C8F2 - - - - - - - - #005FB8 - - - - - - - - #48B1E9 - - - + + + + + + + 44