From 23bc278cc89b24e999e275d02b746555e8f5cf6f Mon Sep 17 00:00:00 2001
From: Kai Tao <69313318+vanzue@users.noreply.github.com>
Date: Tue, 9 Dec 2025 14:22:18 +0800
Subject: [PATCH 1/9] Cmdpal: Fix cmdpal toolkit restore failure for slnx in
release pipeline (#44152)
## Summary of the Pull Request
Error from pipeline:
Invalid input 'PowerToys.slnx'. The file type was not recognized.
MSBuild version 17.14.23+b0019275e for .NET Framework
Build started 12/8/2025 6:33:14 AM.
Nuget support for slnx will be ready in nuget version 7, so use msbuild
to restore
## 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
---
.pipelines/ESRPSigning_core.json | 5 +++++
.pipelines/versionAndSignCheck.ps1 | 7 ++++++-
.../Microsoft.CommandPalette.Extensions.vcxproj | 2 +-
.../Microsoft.CommandPalette.Extensions/packages.config | 4 ++--
src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1 | 8 +++++++-
5 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index f4e3e1ba38..e3ebffc20c 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -353,6 +353,11 @@
"Microsoft.SemanticKernel.Connectors.Ollama.dll",
"OllamaSharp.dll",
+ "boost_regex-vc143-mt-gd-x32-1_87.dll",
+ "boost_regex-vc143-mt-gd-x64-1_87.dll",
+ "boost_regex-vc143-mt-x32-1_87.dll",
+ "boost_regex-vc143-mt-x64-1_87.dll",
+
"UnitsNet.dll",
"UtfUnknown.dll",
"Wpf.Ui.dll"
diff --git a/.pipelines/versionAndSignCheck.ps1 b/.pipelines/versionAndSignCheck.ps1
index 1bb271300d..f90e59afd6 100644
--- a/.pipelines/versionAndSignCheck.ps1
+++ b/.pipelines/versionAndSignCheck.ps1
@@ -52,7 +52,12 @@ $nullVersionExceptions = @(
"System.Diagnostics.EventLog.Messages.dll",
"Microsoft.Windows.Widgets.dll",
"AdaptiveCards.ObjectModel.WinUI3.dll",
- "AdaptiveCards.Rendering.WinUI3.dll") -join '|';
+ "AdaptiveCards.Rendering.WinUI3.dll",
+ "boost_regex_vc143_mt_gd_x32_1_87.dll",
+ "boost_regex_vc143_mt_gd_x64_1_87.dll",
+ "boost_regex_vc143_mt_x32_1_87.dll",
+ "boost_regex_vc143_mt_x64_1_87.dll"
+ ) -join '|';
$totalFailure = 0;
Write-Host $DirPath;
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..fb647cc444 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
@@ -4,7 +4,7 @@
..\..\..\..\..\
$(PathToRoot)packages\Microsoft.WindowsAppSDK.1.8.250907003
$(PathToRoot)packages\Microsoft.Windows.CppWinRT.2.0.240111.5
- $(PathToRoot)packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188
+ $(PathToRoot)packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.6901
$(PathToRoot)packages\Microsoft.Web.WebView2.1.0.2903.40
diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config
index e945c5824d..091ef0782d 100644
--- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config
+++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config
@@ -12,6 +12,6 @@
-
+
-
\ No newline at end of file
+
diff --git a/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1 b/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1
index 4390f0120e..2afad38df5 100644
--- a/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1
+++ b/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1
@@ -54,9 +54,15 @@ if ($IsAzurePipelineBuild) {
} else {
$nugetPath = (Join-Path $PSScriptRoot "NugetWrapper.cmd")
}
+$solutionPath = (Join-Path $PSScriptRoot "..\..\..\..\..\PowerToys.slnx")
if (($BuildStep -ieq "all") -Or ($BuildStep -ieq "build")) {
- & $nugetPath restore (Join-Path $PSScriptRoot "..\..\..\..\..\PowerToys.slnx")
+ $restoreArgs = @(
+ $solutionPath
+ "/t:Restore"
+ "/p:RestorePackagesConfig=true"
+ )
+ & $msbuildPath $restoreArgs
Try {
foreach ($config in $Configuration.Split(",")) {
From 620f67a3ba86eb7822df4146daf537513fb090d8 Mon Sep 17 00:00:00 2001
From: Niels Laute
Date: Tue, 9 Dec 2025 17:50:45 +0100
Subject: [PATCH 2/9] [UX] Misc consistency improvements in Settings (#44174)
## Summary of the Pull Request
- Minor text changes (e.g. removing "Enable")
- Fixing a few bugs where textblocks did not look disabled
- Sorted mouse utils alphabetically
- Auto-collapsing expanders on the mouse utils to reduce visual clutter
## 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
---
.../SettingsXAML/Panels/MouseJumpPanel.xaml | 40 ++--------
.../SettingsXAML/Views/MouseUtilsPage.xaml | 68 ++++++++---------
.../SettingsXAML/Views/PowerLauncherPage.xaml | 4 +-
.../Settings.UI/Strings/en-us/Resources.resw | 73 +++++++++----------
4 files changed, 76 insertions(+), 109 deletions(-)
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml
index 32fa3d19d5..a0725c0149 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml
@@ -48,39 +48,13 @@
HeaderIcon="{ui:FontIcon Glyph=}"
IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}">
@@ -114,8 +141,7 @@
x:Uid="Appearance_Behavior"
AutomationProperties.AutomationId="MouseUtils_FindMyMouseAppearanceBehaviorId"
HeaderIcon="{ui:FontIcon Glyph=}"
- IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}"
- IsExpanded="False">
+ IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}">
@@ -206,8 +232,7 @@
x:Uid="MouseUtils_MouseHighlighter_ActivationShortcut"
AutomationProperties.AutomationId="MouseUtils_MouseHighlighterActivationShortcutId"
HeaderIcon="{ui:FontIcon Glyph=}"
- IsEnabled="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=OneWay}"
- IsExpanded="True">
+ IsEnabled="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=OneWay}">
@@ -273,34 +298,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ IsEnabled="{x:Bind ViewModel.IsMousePointerCrosshairsEnabled, Mode=OneWay}">
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml
index dd84aa28ec..5bcdf7195c 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml
@@ -548,7 +548,7 @@
x:Uid="PowerLauncher_TitleFontSize"
HeaderIcon="{ui:FontIcon Glyph=}">
-
- Adds feet to the end of cross lines
- Enable Screen Ruler
+ Screen Ruler
"Screen Ruler" is the name of the utility
@@ -299,7 +299,7 @@
Service
- Enable Mouse Without Borders
+ Mouse Without Borders
Attribution
@@ -540,7 +540,7 @@ opera.exe
Product name: Navigation view item name for Shortcut Guide
- File Explorer add-ons
+ File Explorer Add-ons
Product name: Navigation view item name for File Explorer. Please use File Explorer as in the context of File Explorer in Windows
@@ -564,7 +564,7 @@ opera.exe
Product name: Navigation view item name for Mouse Without Borders
- Mouse utilities
+ Mouse Utilities
Product name: Navigation view item name for Mouse utilities
@@ -584,7 +584,7 @@ opera.exe
Keyboard Manager page description
- Enable Keyboard Manager
+ Keyboard Manager
Keyboard Manager enable toggle header. Do not loc the Product name. Do you want this feature on / off
@@ -624,7 +624,7 @@ opera.exe
Disable
- Enable Paste with AI
+ Paste with AI
## Preview Terms
@@ -639,7 +639,7 @@ Please review the placeholder content that represents the final terms and usage
I have read and accept the information above.
- Enable OpenAI content moderation
+ OpenAI content moderation
Use built-in functions to handle complex tasks. Token consumption may increase.
@@ -748,7 +748,7 @@ Please review the placeholder content that represents the final terms and usage
Quick and simple system-wide color picker.
- Enable Color Picker
+ Color Picker
do not loc the Product name. Do you want this feature on / off
@@ -758,7 +758,7 @@ Please review the placeholder content that represents the final terms and usage
A quick launcher that has additional capabilities without sacrificing performance.
- Enable PowerToys Run
+ PowerToys Run
do not loc the Product name. Do you want this feature on / off
@@ -883,7 +883,7 @@ Please review the placeholder content that represents the final terms and usage
windows refers to application windows
- Enable FancyZones
+ FancyZones
{Locked="FancyZones"}
@@ -1053,7 +1053,7 @@ Please review the placeholder content that represents the final terms and usage
This refers to directly integrating in with Windows
- Enable PowerRename
+ PowerRename
do not loc the Product name. Do you want this feature on / off
@@ -1235,7 +1235,7 @@ Please review the placeholder content that represents the final terms and usage
PowerToys will restart automatically if needed
- Enable Shortcut Guide
+ Shortcut Guide
do not loc the Product name. Do you want this feature on / off
@@ -1267,7 +1267,7 @@ Please review the placeholder content that represents the final terms and usage
Lets you resize images by right-clicking.
- Enable Image Resizer
+ Image Resizer
do not loc the Product name. Do you want this feature on / off
@@ -2405,7 +2405,7 @@ From there, simply click on one of the supported files in the File Explorer and
A convenient way to keep your PC awake on-demand.
- Enable Awake
+ Awake
Awake is a product name, do not loc
@@ -2670,7 +2670,7 @@ From there, simply click on one of the supported files in the File Explorer and
Mouse as in the hardware peripheral.
- Enable CursorWrap
+ CursorWrap
CursorWrap
@@ -2825,7 +2825,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut."Ctrl" is a keyboard key. "Find My Mouse" is the name of the utility
- Enable Find My Mouse
+ Find My Mouse
"Find My Mouse" is the name of the utility.
@@ -2914,7 +2914,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut."Mouse Highlighter" is the name of the utility. Mouse is the hardware mouse.
- Enable Mouse Highlighter
+ Mouse Highlighter
"Find My Mouse" is the name of the utility.
@@ -2959,7 +2959,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut."Mouse Pointer Crosshairs" is the name of the utility. Mouse is the hardware mouse.
- Enable Mouse Pointer Crosshairs
+ Mouse Pointer Crosshairs
"Mouse Pointer Crosshairs" is the name of the utility.
@@ -3084,7 +3084,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Activation
- Enable Crop And Lock
+ Crop And Lock
"Crop And Lock" is the name of the utility
@@ -3140,7 +3140,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Activation
- Enable Always On Top
+ Always On Top
{Locked="Always On Top"}
@@ -3241,7 +3241,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Peek is a quick and easy way to preview files. Select a file in File Explorer and press the shortcut to open the file preview.
- Enable Peek
+ Peek
Peek is a product name, do not loc
@@ -3318,7 +3318,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Quick Accent is a product name, do not loc
- Enable Quick Accent
+ Quick Accent
Quick Accent
@@ -3327,7 +3327,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Workspaces
- Enable Workspaces
+ Workspaces
"Workspaces" is the name of the utility
@@ -3601,7 +3601,7 @@ Activate by holding the key for the character you want to add an accent to, then
Text Extractor
- Enable Text Extractor
+ Text Extractor
Text Extractor can only recognize languages that have the OCR pack installed.
@@ -3830,7 +3830,7 @@ Activate by holding the key for the character you want to add an accent to, then
Products name: Navigation view item name for Hosts File Editor
- Enable Hosts File Editor
+ Hosts File Editor
"Hosts File Editor" is the name of the utility
@@ -3895,7 +3895,7 @@ Activate by holding the key for the character you want to add an accent to, then
Environment Variables
- Enable Environment Variables
+ Environment Variables
Activation
@@ -3943,7 +3943,7 @@ Activate by holding the key for the character you want to add an accent to, then
Product name: Navigation view item name for FileLocksmith
- Enable File Locksmith
+ File Locksmith
File Locksmith is the name of the utility
@@ -4032,7 +4032,7 @@ Activate by holding the key for the character you want to add an accent to, then
cancel
- Enable Advanced Paste
+ Advanced Paste
Paste with AI
@@ -4047,7 +4047,7 @@ Activate by holding the key for the character you want to add an accent to, then
Preview the output of AI formats and Image to text before pasting
- Enable Advanced AI
+ Advanced AI
Advanced Paste is a tool to put your clipboard content into any format you need, focused towards developer workflows. It can paste as plain text, markdown, or json directly with the UX or with a direct keystroke invoke. These are fully locally executed. In addition, it has an AI powered option that is 100% opt-in and requires an Open AI key. Note: this will replace the formatted text in your clipboard with the selected format.
@@ -4199,7 +4199,7 @@ Activate by holding the key for the character you want to add an accent to, then
Product name: Navigation view item name for Registry Preview
- Enable Registry Preview
+ Registry Preview
Registry Preview is the name of the utility
@@ -4233,7 +4233,7 @@ Activate by holding the key for the character you want to add an accent to, then
"Mouse Jump" is the name of the utility. Mouse is the hardware mouse.
- Enable Mouse Jump
+ Mouse Jump
"Mouse Jump" is the name of the utility.
@@ -4608,7 +4608,7 @@ Activate by holding the key for the character you want to add an accent to, then
New+ learn more link. Localize product name in accordance with Windows New
- Enable New+
+ New+
Localize product name in accordance with Windows New
@@ -4721,7 +4721,7 @@ Activate by holding the key for the character you want to add an accent to, then
{Locked="ZoomIt"}
- Enable ZoomIt
+ ZoomIt
{Locked="ZoomIt"}
@@ -5142,7 +5142,7 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
System Tools
- Enable Command Palette
+ Command Palette
Command Palette is a product name, do not loc
@@ -5327,9 +5327,6 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
Light Switch
-
- Enable Light Switch
-
Easily switch between light and dark mode - on a schedule, automatically, or with a shortcut.
@@ -5343,7 +5340,7 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
Behavior
- Enable Light Switch
+ Light Switch
Shortcuts
From 0f8cf94d90564dab9dced478a182b865bb7faabe Mon Sep 17 00:00:00 2001
From: Kayla Cinnamon
Date: Tue, 9 Dec 2025 13:04:09 -0500
Subject: [PATCH 3/9] Update Community file (#44175)
## Summary of the Pull Request
Moved myself to community since I'm now in a new role :)
It's been a blast being on this team <3
## 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: Clint Rutkas
---
COMMUNITY.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/COMMUNITY.md b/COMMUNITY.md
index d145cafd57..c18bacc8c9 100644
--- a/COMMUNITY.md
+++ b/COMMUNITY.md
@@ -121,6 +121,9 @@ PowerToys Awake is a tool to keep your computer awake.
Randy contributed Registry Preview and some very early conversations about keyboard remapping.
+### [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon
+Kayla was a former lead for PowerToys and helped create multiple utilities, maintained the GitHub repo, and collaborated with the community to improve the overall product
+
### [@oldnewthing](https://github.com/oldnewthing) - Raymond Chen
Find My Mouse is based on Raymond Chen's SuperSonar.
@@ -180,7 +183,6 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter
## PowerToys core team
-- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Lead
- [@craigloewen-msft](https://github.com/craigloewen-msft) - Craig Loewen - Product Manager
- [@niels9001](https://github.com/niels9001/) - Niels Laute - Product Manager
- [@dhowett](https://github.com/dhowett) - Dustin Howett - Dev Lead
@@ -209,6 +211,7 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter
## Former PowerToys core team members
- [@indierawk2k2](https://github.com/indierawk2k2) - Mike Harsh - Product Manager
+- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Product Manager
- [@ethanfangg](https://github.com/ethanfangg) - Ethan Fang - Product Manager
- [@plante-msft](https://github.com/plante-msft) - Connor Plante - Product Manager
- [@joadoumie](https://github.com/joadoumie) - Jordi Adoumie - Product Manager
From 97c1de8bf6c7ba27320c964b0db28c9155d63202 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?=
Date: Wed, 10 Dec 2025 01:56:03 +0100
Subject: [PATCH 4/9] CmdPal: Light, dark, pink, and unicorns (#43505)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary of the Pull Request
This PR introduces user settings for app mode themes (dark, light, or
system) and background customization options, including custom colors,
system accent colors, or custom images.
- Adds a new page to the Settings window with new appearance settings
and moves some existing settings there as well.
- Introduces a new core-level service abstraction, `IThemeService`, that
holds the state for the current theme.
- Uses the helper class `ResourceSwapper` to update application-level
XAML resources. The way WinUI / XAML handles these is painful, and XAML
Hot Reload is pain². Initialization must be lazy, as XAML resources can
only be accessed after the window is activated.
- `ThemeService` takes app and system settings and selects one of the
registered `IThemeProvider`s to calculate visuals and choose the
appropriate XAML resources.
- At the moment, there are two:
- `NormalThemeProvider`
- Provides the current uncolorized light and dark styles
- `ms-appx:///Styles/Theme.Normal.xaml`
- `ColorfulThemeProvider`
- Style that matches the Windows 11 visual style (based on the Start
menu) and colors
- `ms-appx:///Styles/Theme.Colorful.xaml`
- Applied when the background is colorized or a background image is
selected
- The app theme is applied only on the main window
(`WindowThemeSynchronizer` helper class can be used to synchronize other
windows if needed).
- Adds a new dependency on `Microsoft.Graphics.Win2D`.
- Adds a custom color picker popup; the one from the Community Toolkit
occasionally loses the selected color.
- Flyby: separates the keyword tag and localizable label for pages in
the Settings window navigation.
## Pictures? Pictures!
Matching Windows accent color and tint:
## PR Checklist
- [x] Closes: #38444
- [ ] **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: Niels Laute
---
.github/actions/spell-check/expect.txt | 4 +
Directory.Packages.props | 1 +
src/common/ManagedCsWin32/CLSID.cs | 1 +
src/common/ManagedCsWin32/Ole32.cs | 6 +
.../ShellViewModel.cs | 9 +
.../AppearanceSettingsViewModel.cs | 390 +++++++++++++++++
.../BackgroundImageFit.cs | 11 +
.../ColorizationMode.cs | 13 +
.../MainWindowViewModel.cs | 70 +++
.../Microsoft.CmdPal.UI.ViewModels.csproj | 3 +-
.../Properties/Resources.Designer.cs | 11 +-
.../Properties/Resources.resx | 3 +
.../Services/AcrylicBackdropParameters.cs | 9 +
.../Services/IThemeService.cs | 39 ++
.../Services/ThemeChangedEventArgs.cs | 9 +
.../Services/ThemeSnapshot.cs | 62 +++
.../SettingsModel.cs | 20 +
.../SettingsViewModel.cs | 7 +
.../UserTheme.cs | 12 +
.../cmdpal/Microsoft.CmdPal.UI/App.xaml | 20 +-
.../cmdpal/Microsoft.CmdPal.UI/App.xaml.cs | 38 +-
.../Controls/BlurImageControl.cs | 412 ++++++++++++++++++
.../Controls/ColorPalette.xaml | 216 +++++++++
.../Controls/ColorPalette.xaml.cs | 71 +++
.../Controls/ColorPickerButton.xaml | 90 ++++
.../Controls/ColorPickerButton.xaml.cs | 146 +++++++
.../Controls/CommandPalettePreview.xaml | 75 ++++
.../Controls/CommandPalettePreview.xaml.cs | 123 ++++++
.../Controls/ScreenPreview.xaml | 34 ++
.../Controls/ScreenPreview.xaml.cs | 33 ++
.../Controls/SearchBar.xaml | 4 +-
.../Converters/ContrastBrushConverter.cs | 121 +++++
.../Helpers/BindTransformers.cs | 2 +
.../Helpers/ColorExtensions.cs | 132 ++++++
.../Helpers/TextBoxCaretColor.cs | 173 ++++++++
.../Helpers/WallpaperHelper.cs | 178 ++++++++
.../Microsoft.CmdPal.UI/MainWindow.xaml | 16 +
.../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 89 ++--
.../Microsoft.CmdPal.UI.csproj | 39 ++
.../Microsoft.CmdPal.UI/Pages/ShellPage.xaml | 9 +-
.../Services/ColorfulThemeProvider.cs | 207 +++++++++
.../Services/IThemeProvider.cs | 38 ++
.../Services/MutableOverridesDictionary.cs | 13 +
.../Services/NormalThemeProvider.cs | 43 ++
.../Services/ResourceSwapper.cs | 332 ++++++++++++++
.../Services/ResourcesSwappedEventArgs.cs | 12 +
.../Services/ThemeContext.cs | 24 +
.../Services/ThemeService.cs | 261 +++++++++++
.../Services/WindowThemeSynchronizer.cs | 70 +++
.../Settings/AppearancePage.xaml | 209 +++++++++
.../Settings/AppearancePage.xaml.cs | 86 ++++
.../Settings/GeneralPage.xaml | 25 --
.../Settings/SettingsWindow.xaml | 5 +
.../Settings/SettingsWindow.xaml.cs | 8 +-
.../Strings/en-us/Resources.resw | 129 ++++++
.../Microsoft.CmdPal.UI/Styles/Colors.xaml | 32 --
.../Styles/Theme.Colorful.xaml | 28 ++
.../Styles/Theme.Normal.xaml | 33 ++
58 files changed, 4141 insertions(+), 115 deletions(-)
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml
diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index 672616c8e7..4a3305217e 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -141,6 +141,7 @@ BITSPIXEL
bla
BLACKFRAME
BLENDFUNCTION
+blittable
Blockquotes
blt
BLURBEHIND
@@ -250,6 +251,7 @@ colorformat
colorhistory
colorhistorylimit
COLORKEY
+colorref
comctl
comdlg
comexp
@@ -1860,8 +1862,10 @@ Uniquifies
unitconverter
unittests
UNLEN
+Uninitializes
UNORM
unremapped
+Unsubscribes
unvirtualized
unwide
unzoom
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3d64052a21..eb04903b7e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -40,6 +40,7 @@
+
diff --git a/src/common/ManagedCsWin32/CLSID.cs b/src/common/ManagedCsWin32/CLSID.cs
index 6087ba575b..00315fe737 100644
--- a/src/common/ManagedCsWin32/CLSID.cs
+++ b/src/common/ManagedCsWin32/CLSID.cs
@@ -16,4 +16,5 @@ public static partial class CLSID
public static readonly Guid CollatorDataSource = new Guid("9E175B8B-F52A-11D8-B9A5-505054503030");
public static readonly Guid ApplicationActivationManager = new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C");
public static readonly Guid VirtualDesktopManager = new("aa509086-5ca9-4c25-8f95-589d3c07b48a");
+ public static readonly Guid DesktopWallpaper = new("C2CF3110-460E-4FC1-B9D0-8A1C0C9CC4BD");
}
diff --git a/src/common/ManagedCsWin32/Ole32.cs b/src/common/ManagedCsWin32/Ole32.cs
index 20181f3626..cf56c80373 100644
--- a/src/common/ManagedCsWin32/Ole32.cs
+++ b/src/common/ManagedCsWin32/Ole32.cs
@@ -16,6 +16,12 @@ public static partial class Ole32
CLSCTX dwClsContext,
ref Guid riid,
out IntPtr rReturnedComObject);
+
+ [LibraryImport("ole32.dll")]
+ internal static partial int CoInitializeEx(nint pvReserved, uint dwCoInit);
+
+ [LibraryImport("ole32.dll")]
+ internal static partial void CoUninitialize();
}
[Flags]
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 2abbd83d3e..16ca5b1fca 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs
@@ -14,6 +14,7 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ShellViewModel : ObservableObject,
+ IDisposable,
IRecipient,
IRecipient
{
@@ -460,4 +461,12 @@ public partial class ShellViewModel : ObservableObject,
{
_navigationCts?.Cancel();
}
+
+ public void Dispose()
+ {
+ _handleInvokeTask?.Dispose();
+ _navigationCts?.Dispose();
+
+ GC.SuppressFinalize(this);
+ }
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs
new file mode 100644
index 0000000000..71e150a7d2
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs
@@ -0,0 +1,390 @@
+// 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.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.WinUI;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+using Windows.UI.ViewManagement;
+
+namespace Microsoft.CmdPal.UI.ViewModels;
+
+public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable
+{
+ private static readonly ObservableCollection WindowsColorSwatches = [
+
+ // row 0
+ Color.FromArgb(255, 255, 185, 0), // #ffb900
+ Color.FromArgb(255, 255, 140, 0), // #ff8c00
+ Color.FromArgb(255, 247, 99, 12), // #f7630c
+ Color.FromArgb(255, 202, 80, 16), // #ca5010
+ Color.FromArgb(255, 218, 59, 1), // #da3b01
+ Color.FromArgb(255, 239, 105, 80), // #ef6950
+
+ // row 1
+ Color.FromArgb(255, 209, 52, 56), // #d13438
+ Color.FromArgb(255, 255, 67, 67), // #ff4343
+ Color.FromArgb(255, 231, 72, 86), // #e74856
+ Color.FromArgb(255, 232, 17, 35), // #e81123
+ Color.FromArgb(255, 234, 0, 94), // #ea005e
+ Color.FromArgb(255, 195, 0, 82), // #c30052
+
+ // row 2
+ Color.FromArgb(255, 227, 0, 140), // #e3008c
+ Color.FromArgb(255, 191, 0, 119), // #bf0077
+ Color.FromArgb(255, 194, 57, 179), // #c239b3
+ Color.FromArgb(255, 154, 0, 137), // #9a0089
+ Color.FromArgb(255, 0, 120, 212), // #0078d4
+ Color.FromArgb(255, 0, 99, 177), // #0063b1
+
+ // row 3
+ Color.FromArgb(255, 142, 140, 216), // #8e8cd8
+ Color.FromArgb(255, 107, 105, 214), // #6b69d6
+ Color.FromArgb(255, 135, 100, 184), // #8764b8
+ Color.FromArgb(255, 116, 77, 169), // #744da9
+ Color.FromArgb(255, 177, 70, 194), // #b146c2
+ Color.FromArgb(255, 136, 23, 152), // #881798
+
+ // row 4
+ Color.FromArgb(255, 0, 153, 188), // #0099bc
+ Color.FromArgb(255, 45, 125, 154), // #2d7d9a
+ Color.FromArgb(255, 0, 183, 195), // #00b7c3
+ Color.FromArgb(255, 3, 131, 135), // #038387
+ Color.FromArgb(255, 0, 178, 148), // #00b294
+ Color.FromArgb(255, 1, 133, 116), // #018574
+
+ // row 5
+ Color.FromArgb(255, 0, 204, 106), // #00cc6a
+ Color.FromArgb(255, 16, 137, 62), // #10893e
+ Color.FromArgb(255, 122, 117, 116), // #7a7574
+ Color.FromArgb(255, 93, 90, 88), // #5d5a58
+ Color.FromArgb(255, 104, 118, 138), // #68768a
+ Color.FromArgb(255, 81, 92, 107), // #515c6b
+
+ // row 6
+ Color.FromArgb(255, 86, 124, 115), // #567c73
+ Color.FromArgb(255, 72, 104, 96), // #486860
+ Color.FromArgb(255, 73, 130, 5), // #498205
+ Color.FromArgb(255, 16, 124, 16), // #107c10
+ Color.FromArgb(255, 118, 118, 118), // #767676
+ Color.FromArgb(255, 76, 74, 72), // #4c4a48
+
+ // row 7
+ Color.FromArgb(255, 105, 121, 126), // #69797e
+ Color.FromArgb(255, 74, 84, 89), // #4a5459
+ Color.FromArgb(255, 100, 124, 100), // #647c64
+ Color.FromArgb(255, 82, 94, 84), // #525e54
+ Color.FromArgb(255, 132, 117, 69), // #847545
+ Color.FromArgb(255, 126, 115, 95), // #7e735f
+ ];
+
+ private readonly SettingsModel _settings;
+ private readonly UISettings _uiSettings;
+ private readonly IThemeService _themeService;
+ private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+ private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread();
+
+ private ElementTheme? _elementThemeOverride;
+ private Color _currentSystemAccentColor;
+
+ public ObservableCollection Swatches => WindowsColorSwatches;
+
+ public int ThemeIndex
+ {
+ get => (int)_settings.Theme;
+ set => Theme = (UserTheme)value;
+ }
+
+ public UserTheme Theme
+ {
+ get => _settings.Theme;
+ set
+ {
+ if (_settings.Theme != value)
+ {
+ _settings.Theme = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ThemeIndex));
+ Save();
+ }
+ }
+ }
+
+ public ColorizationMode ColorizationMode
+ {
+ get => _settings.ColorizationMode;
+ set
+ {
+ if (_settings.ColorizationMode != value)
+ {
+ _settings.ColorizationMode = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ColorizationModeIndex));
+ OnPropertyChanged(nameof(IsCustomTintVisible));
+ OnPropertyChanged(nameof(IsCustomTintIntensityVisible));
+ OnPropertyChanged(nameof(IsBackgroundControlsVisible));
+ OnPropertyChanged(nameof(IsNoBackgroundVisible));
+ OnPropertyChanged(nameof(IsAccentColorControlsVisible));
+
+ if (value == ColorizationMode.WindowsAccentColor)
+ {
+ ThemeColor = _currentSystemAccentColor;
+ }
+
+ IsColorizationDetailsExpanded = value != ColorizationMode.None;
+
+ Save();
+ }
+ }
+ }
+
+ public int ColorizationModeIndex
+ {
+ get => (int)_settings.ColorizationMode;
+ set => ColorizationMode = (ColorizationMode)value;
+ }
+
+ public Color ThemeColor
+ {
+ get => _settings.CustomThemeColor;
+ set
+ {
+ if (_settings.CustomThemeColor != value)
+ {
+ _settings.CustomThemeColor = value;
+
+ OnPropertyChanged();
+
+ if (ColorIntensity == 0)
+ {
+ ColorIntensity = 100;
+ }
+
+ Save();
+ }
+ }
+ }
+
+ public int ColorIntensity
+ {
+ get => _settings.CustomThemeColorIntensity;
+ set
+ {
+ _settings.CustomThemeColorIntensity = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+
+ public string BackgroundImagePath
+ {
+ get => _settings.BackgroundImagePath ?? string.Empty;
+ set
+ {
+ if (_settings.BackgroundImagePath != value)
+ {
+ _settings.BackgroundImagePath = value;
+ OnPropertyChanged();
+
+ if (BackgroundImageOpacity == 0)
+ {
+ BackgroundImageOpacity = 100;
+ }
+
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageOpacity
+ {
+ get => _settings.BackgroundImageOpacity;
+ set
+ {
+ if (_settings.BackgroundImageOpacity != value)
+ {
+ _settings.BackgroundImageOpacity = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageBrightness
+ {
+ get => _settings.BackgroundImageBrightness;
+ set
+ {
+ if (_settings.BackgroundImageBrightness != value)
+ {
+ _settings.BackgroundImageBrightness = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageBlurAmount
+ {
+ get => _settings.BackgroundImageBlurAmount;
+ set
+ {
+ if (_settings.BackgroundImageBlurAmount != value)
+ {
+ _settings.BackgroundImageBlurAmount = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+ }
+
+ public BackgroundImageFit BackgroundImageFit
+ {
+ get => _settings.BackgroundImageFit;
+ set
+ {
+ if (_settings.BackgroundImageFit != value)
+ {
+ _settings.BackgroundImageFit = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(BackgroundImageFitIndex));
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageFitIndex
+ {
+ // Naming between UI facing string and enum is a bit confusing, but the enum fields
+ // are based on XAML Stretch enum values. So I'm choosing to keep the confusion here, close
+ // to the UI.
+ // - BackgroundImageFit.Fill corresponds to "Stretch"
+ // - BackgroundImageFit.UniformToFill corresponds to "Fill"
+ get => BackgroundImageFit switch
+ {
+ BackgroundImageFit.Fill => 1,
+ _ => 0,
+ };
+ set => BackgroundImageFit = value switch
+ {
+ 1 => BackgroundImageFit.Fill,
+ _ => BackgroundImageFit.UniformToFill,
+ };
+ }
+
+ [ObservableProperty]
+ public partial bool IsColorizationDetailsExpanded { get; set; }
+
+ public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image;
+
+ public bool IsCustomTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
+
+ public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image;
+
+ public bool IsNoBackgroundVisible => _settings.ColorizationMode is ColorizationMode.None;
+
+ public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor;
+
+ public AcrylicBackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f);
+
+ public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme;
+
+ public Color EffectiveThemeColor => ColorizationMode switch
+ {
+ ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
+ ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
+ _ => Colors.Transparent,
+ };
+
+ // Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen).
+ public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f);
+
+ public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0;
+
+ public ImageSource? EffectiveBackgroundImageSource =>
+ ColorizationMode is ColorizationMode.Image
+ && !string.IsNullOrWhiteSpace(BackgroundImagePath)
+ && Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
+ ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
+ : null;
+
+ public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings)
+ {
+ _themeService = themeService;
+ _themeService.ThemeChanged += ThemeServiceOnThemeChanged;
+ _settings = settings;
+
+ _uiSettings = new UISettings();
+ _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged;
+ UpdateAccentColor(_uiSettings);
+
+ Reapply();
+
+ IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None;
+ }
+
+ private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender));
+
+ private void UpdateAccentColor(UISettings sender)
+ {
+ _currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent);
+ if (ColorizationMode == ColorizationMode.WindowsAccentColor)
+ {
+ ThemeColor = _currentSystemAccentColor;
+ }
+ }
+
+ private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
+ {
+ _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
+ }
+
+ private void Save()
+ {
+ SettingsModel.SaveSettings(_settings);
+ _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
+ }
+
+ private void Reapply()
+ {
+ // Theme services recalculates effective color and opacity based on current settings.
+ EffectiveBackdrop = _themeService.Current.BackdropParameters;
+ OnPropertyChanged(nameof(EffectiveBackdrop));
+ OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness));
+ OnPropertyChanged(nameof(EffectiveBackgroundImageSource));
+ OnPropertyChanged(nameof(EffectiveThemeColor));
+ OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount));
+
+ // LOAD BEARING:
+ // We need to cycle through the EffectiveTheme property to force reload of resources.
+ _elementThemeOverride = ElementTheme.Light;
+ OnPropertyChanged(nameof(EffectiveTheme));
+ _elementThemeOverride = ElementTheme.Dark;
+ OnPropertyChanged(nameof(EffectiveTheme));
+ _elementThemeOverride = null;
+ OnPropertyChanged(nameof(EffectiveTheme));
+ }
+
+ [RelayCommand]
+ private void ResetBackgroundImageProperties()
+ {
+ BackgroundImageBrightness = 0;
+ BackgroundImageBlurAmount = 0;
+ BackgroundImageFit = BackgroundImageFit.UniformToFill;
+ BackgroundImageOpacity = 100;
+ ColorIntensity = 0;
+ }
+
+ public void Dispose()
+ {
+ _uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged;
+ _themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs
new file mode 100644
index 0000000000..52102df30a
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.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 Microsoft.CmdPal.UI.ViewModels;
+
+public enum BackgroundImageFit
+{
+ Fill,
+ UniformToFill,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs
new file mode 100644
index 0000000000..57a65f1882
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs
@@ -0,0 +1,13 @@
+// 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.UI.ViewModels;
+
+public enum ColorizationMode
+{
+ None,
+ WindowsAccentColor,
+ CustomColor,
+ Image,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000000..140811c784
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,70 @@
+// 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 CommunityToolkit.Mvvm.ComponentModel;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.ViewModels;
+
+public partial class MainWindowViewModel : ObservableObject, IDisposable
+{
+ private readonly IThemeService _themeService;
+ private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!;
+
+ [ObservableProperty]
+ public partial ImageSource? BackgroundImageSource { get; private set; }
+
+ [ObservableProperty]
+ public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill;
+
+ [ObservableProperty]
+ public partial double BackgroundImageOpacity { get; private set; }
+
+ [ObservableProperty]
+ public partial Color BackgroundImageTint { get; private set; }
+
+ [ObservableProperty]
+ public partial double BackgroundImageTintIntensity { get; private set; }
+
+ [ObservableProperty]
+ public partial int BackgroundImageBlurAmount { get; private set; }
+
+ [ObservableProperty]
+ public partial double BackgroundImageBrightness { get; private set; }
+
+ [ObservableProperty]
+ public partial bool ShowBackgroundImage { get; private set; }
+
+ public MainWindowViewModel(IThemeService themeService)
+ {
+ _themeService = themeService;
+ _themeService.ThemeChanged += ThemeService_ThemeChanged;
+ }
+
+ private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e)
+ {
+ _uiDispatcherQueue.TryEnqueue(() =>
+ {
+ BackgroundImageSource = _themeService.Current.BackgroundImageSource;
+ BackgroundImageStretch = _themeService.Current.BackgroundImageStretch;
+ BackgroundImageOpacity = _themeService.Current.BackgroundImageOpacity;
+
+ BackgroundImageBrightness = _themeService.Current.BackgroundBrightness;
+ BackgroundImageTint = _themeService.Current.Tint;
+ BackgroundImageTintIntensity = _themeService.Current.TintIntensity;
+ BackgroundImageBlurAmount = _themeService.Current.BlurAmount;
+
+ ShowBackgroundImage = BackgroundImageSource != null;
+ });
+ }
+
+ public void Dispose()
+ {
+ _themeService.ThemeChanged -= ThemeService_ThemeChanged;
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj
index 6b1b018273..1c85aa939b 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj
@@ -23,11 +23,12 @@
+
compile
-
+
compile
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs
index be9d103b2d..8bc2a42a92 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.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 {
@@ -411,6 +411,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
+ ///
+ /// Looks up a localized string similar to Pick background image.
+ ///
+ public static string builtin_settings_appearance_pick_background_image_title {
+ get {
+ return ResourceManager.GetString("builtin_settings_appearance_pick_background_image_title", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to {0} extensions found.
///
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx
index 9a658e38f1..bb7637e133 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx
@@ -239,4 +239,7 @@
{0} extensions installed
+
+ Pick background image
+
\ No newline at end of file
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs
new file mode 100644
index 0000000000..efb7ca1fa1
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs
@@ -0,0 +1,9 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.ViewModels.Services;
+
+public sealed record AcrylicBackdropParameters(Color TintColor, Color FallbackColor, float TintOpacity, float LuminosityOpacity);
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs
new file mode 100644
index 0000000000..546742b8f4
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs
@@ -0,0 +1,39 @@
+// 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.UI.ViewModels.Services;
+
+///
+/// Provides theme-related values for the Command Palette and notifies listeners about
+/// changes that affect visual appearance (theme, tint, background image, and backdrop).
+///
+///
+/// Implementations are expected to monitor system/app theme changes and raise
+/// accordingly. Consumers should call
+/// once to hook required sources and then query properties/methods for the current visuals.
+///
+public interface IThemeService
+{
+ ///
+ /// Occurs when the effective theme or any visual-affecting setting changes.
+ ///
+ ///
+ /// Triggered for changes such as app theme (light/dark/default), background image,
+ /// tint/accent, or backdrop parameters that would require UI to refresh styling.
+ ///
+ event EventHandler? ThemeChanged;
+
+ ///
+ /// Initializes the theme service and starts listening for theme-related changes.
+ ///
+ ///
+ /// Safe to call once during application startup before consuming the service.
+ ///
+ void Initialize();
+
+ ///
+ /// Gets the current theme settings.
+ ///
+ ThemeSnapshot Current { get; }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs
new file mode 100644
index 0000000000..96197dc376
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs
@@ -0,0 +1,9 @@
+// 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.UI.ViewModels.Services;
+
+///
+/// Event arguments for theme-related changes.
+public class ThemeChangedEventArgs : EventArgs;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs
new file mode 100644
index 0000000000..244fd41fba
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs
@@ -0,0 +1,62 @@
+// 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.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.ViewModels.Services;
+
+///
+/// Represents a snapshot of theme-related visual settings, including accent color, theme preference, and background
+/// image configuration, for use in rendering the Command Palette UI.
+///
+public sealed class ThemeSnapshot
+{
+ ///
+ /// Gets the accent tint color used by the Command Palette visuals.
+ ///
+ public required Color Tint { get; init; }
+
+ ///
+ /// Gets the accent tint color used by the Command Palette visuals.
+ ///
+ public required float TintIntensity { get; init; }
+
+ ///
+ /// Gets the configured application theme preference.
+ ///
+ public required ElementTheme Theme { get; init; }
+
+ ///
+ /// Gets the image source to render as the background, if any.
+ ///
+ ///
+ /// Returns when no background image is configured.
+ ///
+ public required ImageSource? BackgroundImageSource { get; init; }
+
+ ///
+ /// Gets the stretch mode used to lay out the background image.
+ ///
+ public required Stretch BackgroundImageStretch { get; init; }
+
+ ///
+ /// Gets the opacity applied to the background image.
+ ///
+ ///
+ /// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque.
+ ///
+ public required double BackgroundImageOpacity { get; init; }
+
+ ///
+ /// Gets the effective acrylic backdrop parameters based on current settings and theme.
+ ///
+ /// The resolved AcrylicBackdropParameters to apply.
+ public required AcrylicBackdropParameters BackdropParameters { get; init; }
+
+ public required int BlurAmount { get; init; }
+
+ public required float BackgroundBrightness { get; init; }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
index dae50b3f3e..e210359f76 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
@@ -11,7 +11,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
+using Microsoft.UI;
using Windows.Foundation;
+using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -62,6 +64,24 @@ public partial class SettingsModel : ObservableObject
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
+ public UserTheme Theme { get; set; } = UserTheme.Default;
+
+ public ColorizationMode ColorizationMode { get; set; }
+
+ public Color CustomThemeColor { get; set; } = Colors.Transparent;
+
+ public int CustomThemeColorIntensity { get; set; } = 100;
+
+ public int BackgroundImageOpacity { get; set; } = 20;
+
+ public int BackgroundImageBlurAmount { get; set; }
+
+ public int BackgroundImageBrightness { get; set; }
+
+ public BackgroundImageFit BackgroundImageFit { get; set; }
+
+ public string? BackgroundImagePath { get; set; }
+
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
index 586670bff7..6ac9acacc4 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
@@ -4,6 +4,8 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
+using Microsoft.CmdPal.Core.Common.Services;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
@@ -29,6 +31,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
+ public AppearanceSettingsViewModel Appearance { get; }
+
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
@@ -179,6 +183,9 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_settings = settings;
_serviceProvider = serviceProvider;
+ var themeService = serviceProvider.GetRequiredService();
+ Appearance = new AppearanceSettingsViewModel(themeService, _settings);
+
var activeProviders = GetCommandProviders();
var allProviderSettings = _settings.ProviderSettings;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs
new file mode 100644
index 0000000000..290668f3f5
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.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.UI.ViewModels;
+
+public enum UserTheme
+{
+ Default,
+ Light,
+ Dark,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml
index f9a9e37ea1..d8d4655291 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml
@@ -4,19 +4,23 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
- xmlns:local="using:Microsoft.CmdPal.UI">
+ xmlns:local="using:Microsoft.CmdPal.UI"
+ xmlns:services="using:Microsoft.CmdPal.UI.Services">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
index 53f47286b2..a44682218f 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
@@ -24,9 +24,11 @@ using Microsoft.CmdPal.Ext.WindowsTerminal;
using Microsoft.CmdPal.Ext.WindowWalker;
using Microsoft.CmdPal.Ext.WinGet;
using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
using Microsoft.CmdPal.UI.ViewModels.Models;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerToys.Telemetry;
@@ -112,6 +114,17 @@ public partial class App : Application
// Root services
services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext());
+ AddBuiltInCommands(services);
+
+ AddCoreServices(services);
+
+ AddUIServices(services);
+
+ return services.BuildServiceProvider();
+ }
+
+ private static void AddBuiltInCommands(ServiceCollection services)
+ {
// Built-in Commands. Order matters - this is the order they'll be presented by default.
var allApps = new AllAppsCommandProvider();
var files = new IndexerCommandsProvider();
@@ -154,17 +167,32 @@ public partial class App : Application
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ }
+ private static void AddUIServices(ServiceCollection services)
+ {
// Models
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
var sm = SettingsModel.LoadSettings();
services.AddSingleton(sm);
var state = AppStateModel.LoadState();
services.AddSingleton(state);
- services.AddSingleton();
+
+ // Services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ private static void AddCoreServices(ServiceCollection services)
+ {
+ // Core services
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -174,7 +202,5 @@ public partial class App : Application
// ViewModels
services.AddSingleton();
services.AddSingleton();
-
- return services.BuildServiceProvider();
}
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs
new file mode 100644
index 0000000000..743e68d690
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs
@@ -0,0 +1,412 @@
+// 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.Numerics;
+using ManagedCommon;
+using Microsoft.Graphics.Canvas.Effects;
+using Microsoft.UI;
+using Microsoft.UI.Composition;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Hosting;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+internal sealed partial class BlurImageControl : Control
+{
+ private const string ImageSourceParameterName = "ImageSource";
+
+ private const string BrightnessEffectName = "Brightness";
+ private const string BrightnessOverlayEffectName = "BrightnessOverlay";
+ private const string BlurEffectName = "Blur";
+ private const string TintBlendEffectName = "TintBlend";
+ private const string TintEffectName = "Tint";
+
+#pragma warning disable CA1507 // Use nameof to express symbol names ... some of these refer to effect properties that are separate from the class properties
+ private static readonly string BrightnessSource1AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source1Amount");
+ private static readonly string BrightnessSource2AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source2Amount");
+ private static readonly string BrightnessOverlayColorEffectProperty = GetPropertyName(BrightnessOverlayEffectName, "Color");
+ private static readonly string BlurBlurAmountEffectProperty = GetPropertyName(BlurEffectName, "BlurAmount");
+ private static readonly string TintColorEffectProperty = GetPropertyName(TintEffectName, "Color");
+#pragma warning restore CA1507
+
+ private static readonly string[] AnimatableProperties = [
+ BrightnessSource1AmountEffectProperty,
+ BrightnessSource2AmountEffectProperty,
+ BrightnessOverlayColorEffectProperty,
+ BlurBlurAmountEffectProperty,
+ TintColorEffectProperty
+ ];
+
+ public static readonly DependencyProperty ImageSourceProperty =
+ DependencyProperty.Register(
+ nameof(ImageSource),
+ typeof(ImageSource),
+ typeof(BlurImageControl),
+ new PropertyMetadata(null, OnImageChanged));
+
+ public static readonly DependencyProperty ImageStretchProperty =
+ DependencyProperty.Register(
+ nameof(ImageStretch),
+ typeof(Stretch),
+ typeof(BlurImageControl),
+ new PropertyMetadata(Stretch.UniformToFill, OnImageStretchChanged));
+
+ public static readonly DependencyProperty ImageOpacityProperty =
+ DependencyProperty.Register(
+ nameof(ImageOpacity),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(1.0, OnOpacityChanged));
+
+ public static readonly DependencyProperty ImageBrightnessProperty =
+ DependencyProperty.Register(
+ nameof(ImageBrightness),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(1.0, OnBrightnessChanged));
+
+ public static readonly DependencyProperty BlurAmountProperty =
+ DependencyProperty.Register(
+ nameof(BlurAmount),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(0.0, OnBlurAmountChanged));
+
+ public static readonly DependencyProperty TintColorProperty =
+ DependencyProperty.Register(
+ nameof(TintColor),
+ typeof(Color),
+ typeof(BlurImageControl),
+ new PropertyMetadata(Colors.Transparent, OnVisualPropertyChanged));
+
+ public static readonly DependencyProperty TintIntensityProperty =
+ DependencyProperty.Register(
+ nameof(TintIntensity),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(0.0, OnVisualPropertyChanged));
+
+ private Compositor? _compositor;
+ private SpriteVisual? _effectVisual;
+ private CompositionEffectBrush? _effectBrush;
+ private CompositionSurfaceBrush? _imageBrush;
+
+ public BlurImageControl()
+ {
+ this.DefaultStyleKey = typeof(BlurImageControl);
+ this.Loaded += OnLoaded;
+ this.SizeChanged += OnSizeChanged;
+ }
+
+ public ImageSource ImageSource
+ {
+ get => (ImageSource)GetValue(ImageSourceProperty);
+ set => SetValue(ImageSourceProperty, value);
+ }
+
+ public Stretch ImageStretch
+ {
+ get => (Stretch)GetValue(ImageStretchProperty);
+ set => SetValue(ImageStretchProperty, value);
+ }
+
+ public double ImageOpacity
+ {
+ get => (double)GetValue(ImageOpacityProperty);
+ set => SetValue(ImageOpacityProperty, value);
+ }
+
+ public double ImageBrightness
+ {
+ get => (double)GetValue(ImageBrightnessProperty);
+ set => SetValue(ImageBrightnessProperty, Math.Clamp(value, -1, 1));
+ }
+
+ public double BlurAmount
+ {
+ get => (double)GetValue(BlurAmountProperty);
+ set => SetValue(BlurAmountProperty, value);
+ }
+
+ public Color TintColor
+ {
+ get => (Color)GetValue(TintColorProperty);
+ set => SetValue(TintColorProperty, value);
+ }
+
+ public double TintIntensity
+ {
+ get => (double)GetValue(TintIntensityProperty);
+ set => SetValue(TintIntensityProperty, value);
+ }
+
+ private static void OnImageStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._imageBrush != null)
+ {
+ control._imageBrush.Stretch = ConvertStretch((Stretch)e.NewValue);
+ }
+ }
+
+ private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._compositor != null)
+ {
+ control.UpdateEffect();
+ }
+ }
+
+ private static void OnOpacityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._effectVisual != null)
+ {
+ control._effectVisual.Opacity = (float)(double)e.NewValue;
+ }
+ }
+
+ private static void OnBlurAmountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._effectBrush != null)
+ {
+ control.UpdateEffect();
+ }
+ }
+
+ private static void OnBrightnessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._effectBrush != null)
+ {
+ control.UpdateEffect();
+ }
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ InitializeComposition();
+ }
+
+ private void OnSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if (_effectVisual != null)
+ {
+ _effectVisual.Size = new Vector2(
+ (float)Math.Max(1, e.NewSize.Width),
+ (float)Math.Max(1, e.NewSize.Height));
+ }
+ }
+
+ private static void OnImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not BlurImageControl control)
+ {
+ return;
+ }
+
+ control.EnsureEffect(force: true);
+ control.UpdateEffect();
+ }
+
+ private void InitializeComposition()
+ {
+ var visual = ElementCompositionPreview.GetElementVisual(this);
+ _compositor = visual.Compositor;
+
+ _effectVisual = _compositor.CreateSpriteVisual();
+ _effectVisual.Size = new Vector2(
+ (float)Math.Max(1, ActualWidth),
+ (float)Math.Max(1, ActualHeight));
+ _effectVisual.Opacity = (float)ImageOpacity;
+
+ ElementCompositionPreview.SetElementChildVisual(this, _effectVisual);
+
+ UpdateEffect();
+ }
+
+ private void EnsureEffect(bool force = false)
+ {
+ if (_compositor is null)
+ {
+ return;
+ }
+
+ if (_effectBrush is not null && !force)
+ {
+ return;
+ }
+
+ var imageSource = new CompositionEffectSourceParameter(ImageSourceParameterName);
+
+ // 1) Brightness via ArithmeticCompositeEffect
+ // We blend between the original image and either black or white,
+ // depending on whether we want to darken or brighten. BrightnessEffect isn't supported
+ // in the composition graph.
+ var brightnessEffect = new ArithmeticCompositeEffect
+ {
+ Name = BrightnessEffectName,
+ Source1 = imageSource, // original image
+ Source2 = new ColorSourceEffect
+ {
+ Name = BrightnessOverlayEffectName,
+ Color = Colors.Black, // we'll swap black/white via properties
+ },
+
+ MultiplyAmount = 0.0f,
+ Source1Amount = 1.0f, // original
+ Source2Amount = 0.0f, // overlay
+ Offset = 0.0f,
+ };
+
+ // 2) Blur
+ var blurEffect = new GaussianBlurEffect
+ {
+ Name = BlurEffectName,
+ BlurAmount = 0.0f,
+ BorderMode = EffectBorderMode.Hard,
+ Optimization = EffectOptimization.Balanced,
+ Source = brightnessEffect,
+ };
+
+ // 3) Tint (always in the chain; intensity via alpha)
+ var tintEffect = new BlendEffect
+ {
+ Name = TintBlendEffectName,
+ Background = blurEffect,
+ Foreground = new ColorSourceEffect
+ {
+ Name = TintEffectName,
+ Color = Colors.Transparent,
+ },
+ Mode = BlendEffectMode.Multiply,
+ };
+
+ var effectFactory = _compositor.CreateEffectFactory(tintEffect, AnimatableProperties);
+
+ _effectBrush?.Dispose();
+ _effectBrush = effectFactory.CreateBrush();
+
+ // Set initial source
+ if (ImageSource is not null)
+ {
+ _imageBrush ??= _compositor.CreateSurfaceBrush();
+ LoadImageAsync(ImageSource);
+ _effectBrush.SetSourceParameter(ImageSourceParameterName, _imageBrush);
+ }
+ else
+ {
+ _effectBrush.SetSourceParameter(ImageSourceParameterName, _compositor.CreateBackdropBrush());
+ }
+
+ if (_effectVisual is not null)
+ {
+ _effectVisual.Brush = _effectBrush;
+ }
+ }
+
+ private void UpdateEffect()
+ {
+ if (_compositor is null)
+ {
+ return;
+ }
+
+ EnsureEffect();
+ if (_effectBrush is null)
+ {
+ return;
+ }
+
+ var props = _effectBrush.Properties;
+
+ // Brightness
+ var b = (float)Math.Clamp(ImageBrightness, -1.0, 1.0);
+
+ float source1Amount;
+ float source2Amount;
+ Color overlayColor;
+
+ if (b >= 0)
+ {
+ // Brighten: blend towards white
+ overlayColor = Colors.White;
+ source1Amount = 1.0f - b; // original image contribution
+ source2Amount = b; // white overlay contribution
+ }
+ else
+ {
+ // Darken: blend towards black
+ overlayColor = Colors.Black;
+ var t = -b; // 0..1
+ source1Amount = 1.0f - t; // original image
+ source2Amount = t; // black overlay
+ }
+
+ props.InsertScalar(BrightnessSource1AmountEffectProperty, source1Amount);
+ props.InsertScalar(BrightnessSource2AmountEffectProperty, source2Amount);
+ props.InsertColor(BrightnessOverlayColorEffectProperty, overlayColor);
+
+ // Blur
+ props.InsertScalar(BlurBlurAmountEffectProperty, (float)BlurAmount);
+
+ // Tint
+ var tintColor = TintColor;
+ var clampedIntensity = (float)Math.Clamp(TintIntensity, 0.0, 1.0);
+
+ var adjustedColor = Color.FromArgb(
+ (byte)(clampedIntensity * 255),
+ tintColor.R,
+ tintColor.G,
+ tintColor.B);
+
+ props.InsertColor(TintColorEffectProperty, adjustedColor);
+ }
+
+ private void LoadImageAsync(ImageSource imageSource)
+ {
+ try
+ {
+ if (imageSource is Microsoft.UI.Xaml.Media.Imaging.BitmapImage bitmapImage)
+ {
+ _imageBrush ??= _compositor?.CreateSurfaceBrush();
+ if (_imageBrush is null)
+ {
+ return;
+ }
+
+ var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource);
+ loadedSurface.LoadCompleted += (_, _) =>
+ {
+ if (_imageBrush is not null)
+ {
+ _imageBrush.Surface = loadedSurface;
+ _imageBrush.Stretch = ConvertStretch(ImageStretch);
+ _imageBrush.BitmapInterpolationMode = CompositionBitmapInterpolationMode.Linear;
+ }
+ };
+
+ _effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to load image for BlurImageControl: {0}", ex);
+ }
+ }
+
+ private static CompositionStretch ConvertStretch(Stretch stretch)
+ {
+ return stretch switch
+ {
+ Stretch.None => CompositionStretch.None,
+ Stretch.Fill => CompositionStretch.Fill,
+ Stretch.Uniform => CompositionStretch.Uniform,
+ Stretch.UniformToFill => CompositionStretch.UniformToFill,
+ _ => CompositionStretch.UniformToFill,
+ };
+ }
+
+ private static string GetPropertyName(string effectName, string propertyName) => $"{effectName}.{propertyName}";
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml
new file mode 100644
index 0000000000..105010bbd2
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs
new file mode 100644
index 0000000000..7267e894fa
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs
@@ -0,0 +1,71 @@
+// 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.ObjectModel;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+public sealed partial class ColorPalette : UserControl
+{
+ public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPalette), null!)!;
+
+ public static readonly DependencyProperty CustomPaletteColumnCountProperty = DependencyProperty.Register(nameof(CustomPaletteColumnCount), typeof(int), typeof(ColorPalette), new PropertyMetadata(10))!;
+
+ public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPalette), new PropertyMetadata(null!))!;
+
+ public event EventHandler? SelectedColorChanged;
+
+ private Color? _selectedColor;
+
+ public Color? SelectedColor
+ {
+ get => _selectedColor;
+
+ set
+ {
+ if (_selectedColor != value)
+ {
+ _selectedColor = value;
+ if (value is not null)
+ {
+ SetValue(SelectedColorProperty, value);
+ }
+ else
+ {
+ ClearValue(SelectedColorProperty);
+ }
+ }
+ }
+ }
+
+ public ObservableCollection PaletteColors
+ {
+ get => (ObservableCollection)GetValue(PaletteColorsProperty)!;
+ set => SetValue(PaletteColorsProperty, value);
+ }
+
+ public int CustomPaletteColumnCount
+ {
+ get => (int)GetValue(CustomPaletteColumnCountProperty);
+ set => SetValue(CustomPaletteColumnCountProperty, value);
+ }
+
+ public ColorPalette()
+ {
+ PaletteColors = [];
+ InitializeComponent();
+ }
+
+ private void ListViewBase_OnItemClick(object sender, ItemClickEventArgs e)
+ {
+ if (e.ClickedItem is Color color)
+ {
+ SelectedColor = color;
+ SelectedColorChanged?.Invoke(this, color);
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml
new file mode 100644
index 0000000000..92a556f7a7
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs
new file mode 100644
index 0000000000..ff82fffd4e
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs
@@ -0,0 +1,146 @@
+// 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.ObjectModel;
+using ManagedCommon;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+public sealed partial class ColorPickerButton : UserControl
+{
+ public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPickerButton), new PropertyMetadata(new ObservableCollection()))!;
+
+ public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(Colors.Black))!;
+
+ public static readonly DependencyProperty IsAlphaEnabledProperty = DependencyProperty.Register(nameof(IsAlphaEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(defaultValue: false))!;
+
+ public static readonly DependencyProperty IsValueEditorEnabledProperty = DependencyProperty.Register(nameof(IsValueEditorEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!;
+
+ public static readonly DependencyProperty HasSelectedColorProperty = DependencyProperty.Register(nameof(HasSelectedColor), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!;
+
+ private Color _selectedColor;
+
+ public Color SelectedColor
+ {
+ get
+ {
+ return _selectedColor;
+ }
+
+ set
+ {
+ if (_selectedColor != value)
+ {
+ _selectedColor = value;
+ SetValue(SelectedColorProperty, value);
+ HasSelectedColor = true;
+ }
+ }
+ }
+
+ public bool HasSelectedColor
+ {
+ get { return (bool)GetValue(HasSelectedColorProperty); }
+ set { SetValue(HasSelectedColorProperty, value); }
+ }
+
+ public bool IsAlphaEnabled
+ {
+ get => (bool)GetValue(IsAlphaEnabledProperty);
+ set => SetValue(IsAlphaEnabledProperty, value);
+ }
+
+ public bool IsValueEditorEnabled
+ {
+ get { return (bool)GetValue(IsValueEditorEnabledProperty); }
+ set { SetValue(IsValueEditorEnabledProperty, value); }
+ }
+
+ public ObservableCollection PaletteColors
+ {
+ get { return (ObservableCollection)GetValue(PaletteColorsProperty); }
+ set { SetValue(PaletteColorsProperty, value); }
+ }
+
+ public ColorPickerButton()
+ {
+ this.InitializeComponent();
+
+ IsEnabledChanged -= ColorPickerButton_IsEnabledChanged;
+ SetEnabledState();
+ IsEnabledChanged += ColorPickerButton_IsEnabledChanged;
+ }
+
+ private void ColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ SetEnabledState();
+ }
+
+ private void SetEnabledState()
+ {
+ if (this.IsEnabled)
+ {
+ ColorPreviewBorder.Opacity = 1;
+ }
+ else
+ {
+ ColorPreviewBorder.Opacity = 0.2;
+ }
+ }
+
+ private void ColorPalette_OnSelectedColorChanged(object? sender, Color? e)
+ {
+ if (e.HasValue)
+ {
+ HasSelectedColor = true;
+ SelectedColor = e.Value;
+ }
+ }
+
+ private void FlyoutBase_OnOpened(object? sender, object e)
+ {
+ if (sender is not Flyout flyout || (flyout.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter)
+ {
+ return;
+ }
+
+ FlyoutRoot!.UpdateLayout();
+ flyoutPresenter.UpdateLayout();
+
+ // Logger.LogInfo($"FlyoutBase_OnOpened: {flyoutPresenter}, {FlyoutRoot!.ActualWidth}");
+ flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth;
+ flyoutPresenter.MinWidth = 660;
+ flyoutPresenter.Width = FlyoutRoot!.ActualWidth;
+ }
+
+ private void FlyoutRoot_OnSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if ((ColorPickerFlyout!.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter)
+ {
+ return;
+ }
+
+ FlyoutRoot!.UpdateLayout();
+ flyoutPresenter.UpdateLayout();
+
+ flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth;
+ flyoutPresenter.MinWidth = 660;
+ flyoutPresenter.Width = FlyoutRoot!.ActualWidth;
+ }
+
+ private Thickness ToDropDownPadding(bool hasColor)
+ {
+ return hasColor ? new Thickness(3, 3, 8, 3) : new Thickness(8, 4, 8, 4);
+ }
+
+ private void ResetButton_Click(object sender, RoutedEventArgs e)
+ {
+ HasSelectedColor = false;
+ ColorPickerFlyout?.Hide();
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml
new file mode 100644
index 0000000000..a30d1fafdf
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs
new file mode 100644
index 0000000000..96cd5d6aac
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs
@@ -0,0 +1,123 @@
+// 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.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+public sealed partial class CommandPalettePreview : UserControl
+{
+ public static readonly DependencyProperty PreviewBackgroundOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
+
+ public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
+
+ public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, PropertyChangedCallback));
+
+ public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
+
+ public static readonly DependencyProperty PreviewBackgroundImageFitProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageFit), typeof(BackgroundImageFit), typeof(CommandPalettePreview), new PropertyMetadata(default(BackgroundImageFit)));
+
+ public static readonly DependencyProperty PreviewBackgroundImageBrightnessProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBrightness), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
+
+ public static readonly DependencyProperty PreviewBackgroundImageBlurAmountProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBlurAmount), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
+
+ public static readonly DependencyProperty PreviewBackgroundImageTintProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTint), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
+
+ public static readonly DependencyProperty PreviewBackgroundImageTintIntensityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTintIntensity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
+
+ public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed));
+
+ public BackgroundImageFit PreviewBackgroundImageFit
+ {
+ get { return (BackgroundImageFit)GetValue(PreviewBackgroundImageFitProperty); }
+ set { SetValue(PreviewBackgroundImageFitProperty, value); }
+ }
+
+ public double PreviewBackgroundOpacity
+ {
+ get { return (double)GetValue(PreviewBackgroundOpacityProperty); }
+ set { SetValue(PreviewBackgroundOpacityProperty, value); }
+ }
+
+ public Color PreviewBackgroundColor
+ {
+ get { return (Color)GetValue(PreviewBackgroundColorProperty); }
+ set { SetValue(PreviewBackgroundColorProperty, value); }
+ }
+
+ public ImageSource PreviewBackgroundImageSource
+ {
+ get { return (ImageSource)GetValue(PreviewBackgroundImageSourceProperty); }
+ set { SetValue(PreviewBackgroundImageSourceProperty, value); }
+ }
+
+ public int PreviewBackgroundImageOpacity
+ {
+ get { return (int)GetValue(PreviewBackgroundImageOpacityProperty); }
+ set { SetValue(PreviewBackgroundImageOpacityProperty, value); }
+ }
+
+ public double PreviewBackgroundImageBrightness
+ {
+ get => (double)GetValue(PreviewBackgroundImageBrightnessProperty);
+ set => SetValue(PreviewBackgroundImageBrightnessProperty, value);
+ }
+
+ public double PreviewBackgroundImageBlurAmount
+ {
+ get => (double)GetValue(PreviewBackgroundImageBlurAmountProperty);
+ set => SetValue(PreviewBackgroundImageBlurAmountProperty, value);
+ }
+
+ public Color PreviewBackgroundImageTint
+ {
+ get => (Color)GetValue(PreviewBackgroundImageTintProperty);
+ set => SetValue(PreviewBackgroundImageTintProperty, value);
+ }
+
+ public int PreviewBackgroundImageTintIntensity
+ {
+ get => (int)GetValue(PreviewBackgroundImageTintIntensityProperty);
+ set => SetValue(PreviewBackgroundImageTintIntensityProperty, value);
+ }
+
+ public Visibility ShowBackgroundImage
+ {
+ get => (Visibility)GetValue(ShowBackgroundImageProperty);
+ set => SetValue(ShowBackgroundImageProperty, value);
+ }
+
+ public CommandPalettePreview()
+ {
+ InitializeComponent();
+ }
+
+ private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not CommandPalettePreview preview)
+ {
+ return;
+ }
+
+ preview.ShowBackgroundImage = e.NewValue is ImageSource ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private double ToOpacity(int value) => value / 100.0;
+
+ private double ToTintIntensity(int value) => value / 100.0;
+
+ private Stretch ToStretch(BackgroundImageFit fit)
+ {
+ return fit switch
+ {
+ BackgroundImageFit.Fill => Stretch.Fill,
+ BackgroundImageFit.UniformToFill => Stretch.UniformToFill,
+ _ => Stretch.None,
+ };
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml
new file mode 100644
index 0000000000..58c4e890a6
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs
new file mode 100644
index 0000000000..828fa76c74
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Markup;
+using Microsoft.UI.Xaml.Media;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+[ContentProperty(Name = nameof(PreviewContent))]
+public sealed partial class ScreenPreview : UserControl
+{
+ public static readonly DependencyProperty PreviewContentProperty =
+ DependencyProperty.Register(nameof(PreviewContent), typeof(object), typeof(ScreenPreview), new PropertyMetadata(null!))!;
+
+ public object PreviewContent
+ {
+ get => GetValue(PreviewContentProperty)!;
+ set => SetValue(PreviewContentProperty, value);
+ }
+
+ public ScreenPreview()
+ {
+ InitializeComponent();
+
+ var wallpaperHelper = new WallpaperHelper();
+ WallpaperImage!.Source = wallpaperHelper.GetWallpaperImage()!;
+ ScreenBorder!.Background = new SolidColorBrush(wallpaperHelper.GetWallpaperColor());
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml
index 80eb1a3ad6..d248c24f89 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml
@@ -4,9 +4,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUi="using:Microsoft.CmdPal.UI"
- xmlns:converters="using:CommunityToolkit.WinUI.Converters"
- xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:h="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
@@ -22,6 +21,7 @@
MinHeight="32"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
+ h:TextBoxCaretColor.SyncWithForeground="True"
AutomationProperties.AutomationId="MainSearchBox"
KeyDown="FilterBox_KeyDown"
PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}"
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs
new file mode 100644
index 0000000000..5f54682aaf
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs
@@ -0,0 +1,121 @@
+// 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.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Converters;
+
+///
+/// Gets a color, either black or white, depending on the brightness of the supplied color.
+///
+public sealed partial class ContrastBrushConverter : IValueConverter
+{
+ ///
+ /// Gets or sets the alpha channel threshold below which a default color is used instead of black/white.
+ ///
+ public byte AlphaThreshold { get; set; } = 128;
+
+ ///
+ public object Convert(
+ object value,
+ Type targetType,
+ object parameter,
+ string language)
+ {
+ Color comparisonColor;
+ Color? defaultColor = null;
+
+ // Get the changing color to compare against
+ if (value is Color valueColor)
+ {
+ comparisonColor = valueColor;
+ }
+ else if (value is SolidColorBrush valueBrush)
+ {
+ comparisonColor = valueBrush.Color;
+ }
+ else
+ {
+ // Invalid color value provided
+ return DependencyProperty.UnsetValue;
+ }
+
+ // Get the default color when transparency is high
+ if (parameter is Color parameterColor)
+ {
+ defaultColor = parameterColor;
+ }
+ else if (parameter is SolidColorBrush parameterBrush)
+ {
+ defaultColor = parameterBrush.Color;
+ }
+
+ if (comparisonColor.A < AlphaThreshold &&
+ defaultColor.HasValue)
+ {
+ // If the transparency is less than 50 %, just use the default brush
+ // This can commonly be something like the TextControlForeground brush
+ return new SolidColorBrush(defaultColor.Value);
+ }
+ else
+ {
+ // Chose a white/black brush based on contrast to the base color
+ return UseLightContrastColor(comparisonColor)
+ ? new SolidColorBrush(Colors.White)
+ : new SolidColorBrush(Colors.Black);
+ }
+ }
+
+ ///
+ public object ConvertBack(
+ object value,
+ Type targetType,
+ object parameter,
+ string language)
+ {
+ return DependencyProperty.UnsetValue;
+ }
+
+ ///
+ /// Determines whether a light or dark contrast color should be used with the given displayed color.
+ ///
+ ///
+ /// This code is using the WinUI algorithm.
+ ///
+ private bool UseLightContrastColor(Color displayedColor)
+ {
+ // The selection ellipse should be light if and only if the chosen color
+ // contrasts more with black than it does with white.
+ // To find how much something contrasts with white, we use the equation
+ // for relative luminance, which is given by
+ //
+ // L = 0.2126 * Rg + 0.7152 * Gg + 0.0722 * Bg
+ //
+ // where Xg = { X/3294 if X <= 10, (R/269 + 0.0513)^2.4 otherwise }
+ //
+ // If L is closer to 1, then the color is closer to white; if it is closer to 0,
+ // then the color is closer to black. This is based on the fact that the human
+ // eye perceives green to be much brighter than red, which in turn is perceived to be
+ // brighter than blue.
+ //
+ // If the third dimension is value, then we won't be updating the spectrum's displayed colors,
+ // so in that case we should use a value of 1 when considering the backdrop
+ // for the selection ellipse.
+ var rg = displayedColor.R <= 10
+ ? displayedColor.R / 3294.0
+ : Math.Pow((displayedColor.R / 269.0) + 0.0513, 2.4);
+ var gg = displayedColor.G <= 10
+ ? displayedColor.G / 3294.0
+ : Math.Pow((displayedColor.G / 269.0) + 0.0513, 2.4);
+ var bg = displayedColor.B <= 10
+ ? displayedColor.B / 3294.0
+ : Math.Pow((displayedColor.B / 269.0) + 0.0513, 2.4);
+
+ return (0.2126 * rg) + (0.7152 * gg) + (0.0722 * bg) <= 0.5;
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs
index 012e8dc789..f00c230da5 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs
@@ -10,6 +10,8 @@ internal static class BindTransformers
{
public static bool Negate(bool value) => !value;
+ public static Visibility NegateVisibility(Visibility value) => value == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible;
+
public static Visibility EmptyToCollapsed(string? input)
=> string.IsNullOrEmpty(input) ? Visibility.Collapsed : Visibility.Visible;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs
new file mode 100644
index 0000000000..2492f7f7c9
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs
@@ -0,0 +1,132 @@
+// 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 CommunityToolkit.WinUI.Helpers;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Helpers;
+
+///
+/// Extension methods for .
+///
+internal static class ColorExtensions
+{
+ /// Input color.
+ public static double CalculateBrightness(this Color color)
+ {
+ return color.ToHsv().V;
+ }
+
+ ///
+ /// Allows to change the brightness by a factor based on the HSV color space.
+ ///
+ /// Input color.
+ /// The brightness adjustment factor, ranging from -1 to 1.
+ /// Updated color.
+ public static Color UpdateBrightness(this Color color, double brightnessFactor)
+ {
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1);
+
+ var hsvColor = color.ToHsv();
+ return ColorHelper.FromHsv(hsvColor.H, hsvColor.S, Math.Clamp(hsvColor.V + brightnessFactor, 0, 1), hsvColor.A);
+ }
+
+ ///
+ /// Updates the color by adjusting brightness, saturation, and luminance factors.
+ ///
+ /// Input color.
+ /// The brightness adjustment factor, ranging from -1 to 1.
+ /// The saturation adjustment factor, ranging from -1 to 1. Defaults to 0.
+ /// The luminance adjustment factor, ranging from -1 to 1. Defaults to 0.
+ /// Updated color.
+ public static Color Update(this Color color, double brightnessFactor, double saturationFactor = 0, double luminanceFactor = 0)
+ {
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1);
+
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(saturationFactor, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(saturationFactor, -1);
+
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(luminanceFactor, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(luminanceFactor, -1);
+
+ var hsv = color.ToHsv();
+
+ var rgb = ColorHelper.FromHsv(
+ hsv.H,
+ Clamp01(hsv.S + saturationFactor),
+ Clamp01(hsv.V + brightnessFactor));
+
+ if (luminanceFactor == 0)
+ {
+ return rgb;
+ }
+
+ var hsl = rgb.ToHsl();
+ var lightness = Clamp01(hsl.L + luminanceFactor);
+ return ColorHelper.FromHsl(hsl.H, hsl.S, lightness);
+ }
+
+ ///
+ /// Linearly interpolates between two colors in HSV space.
+ /// Hue is blended along the shortest arc on the color wheel (wrap-aware).
+ /// Saturation, Value, and Alpha are blended linearly.
+ ///
+ /// Start color.
+ /// End color.
+ /// Interpolation factor in [0,1].
+ /// Interpolated color.
+ public static Color LerpHsv(this Color a, Color b, double t)
+ {
+ t = Clamp01(t);
+
+ // Convert to HSV
+ var hslA = a.ToHsv();
+ var hslB = b.ToHsv();
+
+ var h1 = hslA.H;
+ var h2 = hslB.H;
+
+ // Handle near-gray hues (undefined hue) by inheriting the other's hue
+ const double satEps = 1e-4f;
+ if (hslA.S < satEps && hslB.S >= satEps)
+ {
+ h1 = h2;
+ }
+ else if (hslB.S < satEps && hslA.S >= satEps)
+ {
+ h2 = h1;
+ }
+
+ return ColorHelper.FromHsv(
+ hue: LerpHueDegrees(h1, h2, t),
+ saturation: Lerp(hslA.S, hslB.S, t),
+ value: Lerp(hslA.V, hslB.V, t),
+ alpha: (byte)Math.Round(Lerp(hslA.A, hslB.A, t)));
+ }
+
+ private static double LerpHueDegrees(double a, double b, double t)
+ {
+ a = Mod360(a);
+ b = Mod360(b);
+ var delta = ((b - a + 540f) % 360f) - 180f;
+ return Mod360(a + (delta * t));
+ }
+
+ private static double Mod360(double angle)
+ {
+ angle %= 360f;
+ if (angle < 0f)
+ {
+ angle += 360f;
+ }
+
+ return angle;
+ }
+
+ private static double Lerp(double a, double b, double t) => a + ((b - a) * t);
+
+ private static double Clamp01(double x) => Math.Clamp(x, 0, 1);
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs
new file mode 100644
index 0000000000..f5103b9efc
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs
@@ -0,0 +1,173 @@
+// 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.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using CommunityToolkit.WinUI;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Rectangle = Microsoft.UI.Xaml.Shapes.Rectangle;
+
+namespace Microsoft.CmdPal.UI.Helpers;
+
+///
+/// Attached property to color internal caret/overlay rectangles inside a TextBox
+/// so they follow the TextBox's actual Foreground brush.
+///
+public static class TextBoxCaretColor
+{
+ public static readonly DependencyProperty SyncWithForegroundProperty =
+ DependencyProperty.RegisterAttached("SyncWithForeground", typeof(bool), typeof(TextBoxCaretColor), new PropertyMetadata(false, OnSyncCaretRectanglesChanged))!;
+
+ private static readonly ConditionalWeakTable States = [];
+
+ public static void SetSyncWithForeground(DependencyObject obj, bool value)
+ {
+ obj.SetValue(SyncWithForegroundProperty, value);
+ }
+
+ public static bool GetSyncWithForeground(DependencyObject obj)
+ {
+ return (bool)obj.GetValue(SyncWithForegroundProperty);
+ }
+
+ private static void OnSyncCaretRectanglesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not TextBox tb)
+ {
+ return;
+ }
+
+ if ((bool)e.NewValue)
+ {
+ Attach(tb);
+ }
+ else
+ {
+ Detach(tb);
+ }
+ }
+
+ private static void Attach(TextBox tb)
+ {
+ if (States.TryGetValue(tb, out var st) && st.IsHooked)
+ {
+ return;
+ }
+
+ st ??= new State();
+ st.IsHooked = true;
+ States.Remove(tb);
+ States.Add(tb, st);
+
+ tb.Loaded += TbOnLoaded;
+ tb.Unloaded += TbOnUnloaded;
+ tb.GotFocus += TbOnGotFocus;
+
+ st.ForegroundToken = tb.RegisterPropertyChangedCallback(Control.ForegroundProperty!, (_, _) => Apply(tb));
+
+ if (tb.IsLoaded)
+ {
+ Apply(tb);
+ }
+ }
+
+ private static void Detach(TextBox tb)
+ {
+ if (!States.TryGetValue(tb, out var st))
+ {
+ return;
+ }
+
+ tb.Loaded -= TbOnLoaded;
+ tb.Unloaded -= TbOnUnloaded;
+ tb.GotFocus -= TbOnGotFocus;
+
+ if (st.ForegroundToken != 0)
+ {
+ tb.UnregisterPropertyChangedCallback(Control.ForegroundProperty!, st.ForegroundToken);
+ st.ForegroundToken = 0;
+ }
+
+ st.IsHooked = false;
+ }
+
+ private static void TbOnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is TextBox tb)
+ {
+ Apply(tb);
+ }
+ }
+
+ private static void TbOnUnloaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is TextBox tb)
+ {
+ Detach(tb);
+ }
+ }
+
+ private static void TbOnGotFocus(object sender, RoutedEventArgs e)
+ {
+ if (sender is TextBox tb)
+ {
+ Apply(tb);
+ }
+ }
+
+ private static void Apply(TextBox tb)
+ {
+ try
+ {
+ ApplyCore(tb);
+ }
+ catch (COMException)
+ {
+ // ignore
+ }
+ }
+
+ private static void ApplyCore(TextBox tb)
+ {
+ // Ensure template is realized
+ tb.ApplyTemplate();
+
+ // Find the internal ScrollContentPresenter within the TextBox template
+ var scp = tb.FindDescendant(s => s.Name == "ScrollContentPresenter");
+ if (scp is null)
+ {
+ return;
+ }
+
+ var brush = tb.Foreground; // use the actual current foreground brush
+ if (brush == null)
+ {
+ brush = new SolidColorBrush(Colors.Black);
+ }
+
+ foreach (var rect in scp.FindDescendants().OfType())
+ {
+ try
+ {
+ rect.Fill = brush;
+ rect.CompositeMode = ElementCompositeMode.SourceOver;
+ rect.Opacity = 0.9;
+ }
+ catch
+ {
+ // best-effort; some rectangles might be template-owned
+ }
+ }
+ }
+
+ private sealed class State
+ {
+ public long ForegroundToken { get; set; }
+
+ public bool IsHooked { get; set; }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs
new file mode 100644
index 0000000000..9772d33b1d
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs
@@ -0,0 +1,178 @@
+// 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.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.Marshalling;
+using ManagedCommon;
+using ManagedCsWin32;
+using Microsoft.UI;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Helpers;
+
+///
+/// Lightweight helper to access wallpaper information.
+///
+internal sealed partial class WallpaperHelper
+{
+ private readonly IDesktopWallpaper? _desktopWallpaper;
+
+ public WallpaperHelper()
+ {
+ try
+ {
+ var desktopWallpaper = ComHelper.CreateComInstance(
+ ref Unsafe.AsRef(in CLSID.DesktopWallpaper),
+ CLSCTX.ALL);
+
+ _desktopWallpaper = desktopWallpaper;
+ }
+ catch (Exception ex)
+ {
+ // If COM initialization fails, keep helper usable with safe fallbacks
+ Logger.LogError("Failed to initialize DesktopWallpaper COM interface", ex);
+ _desktopWallpaper = null;
+ }
+ }
+
+ private string? GetWallpaperPathForFirstMonitor()
+ {
+ try
+ {
+ if (_desktopWallpaper is null)
+ {
+ return null;
+ }
+
+ _desktopWallpaper.GetMonitorDevicePathCount(out var monitorCount);
+
+ for (uint i = 0; monitorCount != 0 && i < monitorCount; i++)
+ {
+ _desktopWallpaper.GetMonitorDevicePathAt(i, out var monitorId);
+ if (string.IsNullOrEmpty(monitorId))
+ {
+ continue;
+ }
+
+ _desktopWallpaper.GetWallpaper(monitorId, out var wallpaperPath);
+
+ if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath))
+ {
+ return wallpaperPath;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to query wallpaper path", ex);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the wallpaper background color.
+ ///
+ /// The wallpaper background color, or black if it cannot be determined.
+ public Color GetWallpaperColor()
+ {
+ try
+ {
+ if (_desktopWallpaper is null)
+ {
+ return Colors.Black;
+ }
+
+ _desktopWallpaper.GetBackgroundColor(out var colorref);
+ var r = (byte)(colorref.Value & 0x000000FF);
+ var g = (byte)((colorref.Value & 0x0000FF00) >> 8);
+ var b = (byte)((colorref.Value & 0x00FF0000) >> 16);
+ return Color.FromArgb(255, r, g, b);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to load wallpaper color", ex);
+ return Colors.Black;
+ }
+ }
+
+ ///
+ /// Gets the wallpaper image for the primary monitor.
+ ///
+ /// The wallpaper image, or null if it cannot be determined.
+ public BitmapImage? GetWallpaperImage()
+ {
+ try
+ {
+ var path = GetWallpaperPathForFirstMonitor();
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ var image = new BitmapImage();
+ using var stream = File.OpenRead(path);
+ var randomAccessStream = stream.AsRandomAccessStream();
+ if (randomAccessStream == null)
+ {
+ Logger.LogError("Failed to convert file stream to RandomAccessStream for wallpaper image.");
+ return null;
+ }
+
+ image.SetSource(randomAccessStream);
+ return image;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to load wallpaper image", ex);
+ return null;
+ }
+ }
+
+ // blittable type for COM interop
+ [StructLayout(LayoutKind.Sequential)]
+ internal readonly partial struct COLORREF
+ {
+ internal readonly uint Value;
+ }
+
+ // blittable type for COM interop
+ [StructLayout(LayoutKind.Sequential)]
+ internal readonly partial struct RECT
+ {
+ internal readonly int Left;
+ internal readonly int Top;
+ internal readonly int Right;
+ internal readonly int Bottom;
+ }
+
+ // COM interface for IDesktopWallpaper, GeneratedComInterface to be AOT compatible
+ [GeneratedComInterface]
+ [Guid("B92B56A9-8B55-4E14-9A89-0199BBB6F93B")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal partial interface IDesktopWallpaper
+ {
+ void SetWallpaper(
+ [MarshalAs(UnmanagedType.LPWStr)] string? monitorId,
+ [MarshalAs(UnmanagedType.LPWStr)] string wallpaper);
+
+ void GetWallpaper(
+ [MarshalAs(UnmanagedType.LPWStr)] string? monitorId,
+ [MarshalAs(UnmanagedType.LPWStr)] out string wallpaper);
+
+ void GetMonitorDevicePathAt(uint monitorIndex, [MarshalAs(UnmanagedType.LPWStr)] out string monitorId);
+
+ void GetMonitorDevicePathCount(out uint count);
+
+ void GetMonitorRECT([MarshalAs(UnmanagedType.LPWStr)] string? monitorId, out RECT rect);
+
+ void SetBackgroundColor(COLORREF color);
+
+ void GetBackgroundColor(out COLORREF color);
+
+ // Other methods omitted for brevity
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml
index c0c0ab811f..32329e17a0 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml
@@ -2,6 +2,7 @@
x:Class="Microsoft.CmdPal.UI.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="using:Microsoft.CmdPal.UI.Pages"
@@ -15,6 +16,21 @@
Closed="MainWindow_Closed"
mc:Ignorable="d">
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
index 1655626714..d9acdb48d9 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
@@ -15,8 +15,10 @@ using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
+using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI;
@@ -66,7 +68,10 @@ public sealed partial class MainWindow : WindowEx,
private readonly KeyboardListener _keyboardListener;
private readonly LocalKeyboardListener _localKeyboardListener;
private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new();
+ private readonly IThemeService _themeService;
+ private readonly WindowThemeSynchronizer _windowThemeSynchronizer;
private bool _ignoreHotKeyWhenFullScreen = true;
+ private bool _themeServiceInitialized;
private DesktopAcrylicController? _acrylicController;
private SystemBackdropConfiguration? _configurationSource;
@@ -74,13 +79,21 @@ public sealed partial class MainWindow : WindowEx,
private WindowPosition _currentWindowPosition = new();
+ private MainWindowViewModel ViewModel { get; }
+
public MainWindow()
{
InitializeComponent();
+ ViewModel = App.Current.Services.GetService()!;
+
_autoGoHomeTimer = new DispatcherTimer();
_autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick;
+ _themeService = App.Current.Services.GetRequiredService();
+ _themeService.ThemeChanged += ThemeServiceOnThemeChanged;
+ _windowThemeSynchronizer = new WindowThemeSynchronizer(_themeService, this);
+
_hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32());
unsafe
@@ -88,6 +101,8 @@ public sealed partial class MainWindow : WindowEx,
CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value);
}
+ SetAcrylic();
+
_hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached);
_keyboardListener = new KeyboardListener();
@@ -100,8 +115,6 @@ public sealed partial class MainWindow : WindowEx,
RestoreWindowPosition();
UpdateWindowPositionInMemory();
- SetAcrylic();
-
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
@@ -156,6 +169,11 @@ public sealed partial class MainWindow : WindowEx,
WeakReferenceMessenger.Default.Send(new GoHomeMessage(WithAnimation: false, FocusSearch: false));
}
+ private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
+ {
+ UpdateAcrylic();
+ }
+
private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e)
{
if (e.Key == VirtualKey.GoBack)
@@ -247,8 +265,6 @@ public sealed partial class MainWindow : WindowEx,
_autoGoHomeTimer.Interval = _autoGoHomeInterval;
}
- // We want to use DesktopAcrylicKind.Thin and custom colors as this is the default material
- // other Shell surfaces are using, this cannot be set in XAML however.
private void SetAcrylic()
{
if (DesktopAcrylicController.IsSupported())
@@ -265,41 +281,32 @@ public sealed partial class MainWindow : WindowEx,
private void UpdateAcrylic()
{
- if (_acrylicController != null)
+ try
{
- _acrylicController.RemoveAllSystemBackdropTargets();
- _acrylicController.Dispose();
- }
-
- _acrylicController = GetAcrylicConfig(Content);
-
- // Enable the system backdrop.
- // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call.
- _acrylicController.AddSystemBackdropTarget(this.As());
- _acrylicController.SetSystemBackdropConfiguration(_configurationSource);
- }
-
- private static DesktopAcrylicController GetAcrylicConfig(UIElement content)
- {
- var feContent = content as FrameworkElement;
-
- return feContent?.ActualTheme == ElementTheme.Light
- ? new DesktopAcrylicController()
+ if (_acrylicController != null)
{
- Kind = DesktopAcrylicKind.Thin,
- TintColor = Color.FromArgb(255, 243, 243, 243),
- LuminosityOpacity = 0.90f,
- TintOpacity = 0.0f,
- FallbackColor = Color.FromArgb(255, 238, 238, 238),
+ _acrylicController.RemoveAllSystemBackdropTargets();
+ _acrylicController.Dispose();
}
- : new DesktopAcrylicController()
+
+ var backdrop = _themeService.Current.BackdropParameters;
+ _acrylicController = new DesktopAcrylicController
{
- Kind = DesktopAcrylicKind.Thin,
- TintColor = Color.FromArgb(255, 32, 32, 32),
- LuminosityOpacity = 0.96f,
- TintOpacity = 0.5f,
- FallbackColor = Color.FromArgb(255, 28, 28, 28),
+ TintColor = backdrop.TintColor,
+ TintOpacity = backdrop.TintOpacity,
+ FallbackColor = backdrop.FallbackColor,
+ LuminosityOpacity = backdrop.LuminosityOpacity,
};
+
+ // Enable the system backdrop.
+ // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call.
+ _acrylicController.AddSystemBackdropTarget(this.As());
+ _acrylicController.SetSystemBackdropConfiguration(_configurationSource);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to update backdrop", ex);
+ }
}
private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target)
@@ -711,6 +718,19 @@ public sealed partial class MainWindow : WindowEx,
internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
{
+ if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated)
+ {
+ try
+ {
+ _themeService.Initialize();
+ _themeServiceInitialized = true;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to initialize ThemeService", ex);
+ }
+ }
+
if (args.WindowActivationState == WindowActivationState.Deactivated)
{
// Save the current window position before hiding the window
@@ -1004,6 +1024,7 @@ public sealed partial class MainWindow : WindowEx,
public void Dispose()
{
_localKeyboardListener.Dispose();
+ _windowThemeSynchronizer.Dispose();
DisposeAcrylic();
}
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj
index 8397ffc767..54961a5828 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj
@@ -68,8 +68,11 @@
+
+
+
@@ -78,10 +81,12 @@
+
+
@@ -93,6 +98,7 @@
+
@@ -207,6 +213,39 @@
+
+
+ MSBuild:Compile
+
+
+
+
+
+ MSBuild:Compile
+
+
+
+
+
+ MSBuild:Compile
+
+
+
+
+
+ Designer
+
+
+ MSBuild:Compile
+
+
+
+
+
+ MSBuild:Compile
+
+
+
MSBuild:Compile
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml
index fe1a29dd97..04b4ca6c16 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml
@@ -11,7 +11,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:h="using:Microsoft.CmdPal.UI.Helpers"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
- xmlns:labToolkit="using:CommunityToolkit.Labs.WinUI.MarkdownTextBlock"
xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
@@ -177,7 +176,7 @@
-
+
@@ -190,7 +189,7 @@
Padding="0,12,0,12"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
- BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
+ BorderBrush="{ThemeResource CmdPal.TopBarBorderBrush}"
BorderThickness="0,0,0,1">
@@ -390,7 +389,7 @@
HorizontalAlignment="Stretch"
ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
- BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
+ BorderBrush="{ThemeResource CmdPal.DividerStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource ControlCornerRadius}"
Visibility="Collapsed">
@@ -518,7 +517,7 @@
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs
new file mode 100644
index 0000000000..fe6c8e48e0
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs
@@ -0,0 +1,207 @@
+// 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 CommunityToolkit.WinUI.Helpers;
+using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI.Xaml;
+using Windows.UI;
+using Windows.UI.ViewManagement;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Provides theme appropriate for colorful (accented) appearance.
+///
+internal sealed class ColorfulThemeProvider : IThemeProvider
+{
+ // Fluent dark: #202020
+ private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32);
+
+ // Fluent light: #F3F3F3
+ private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243);
+
+ private readonly UISettings _uiSettings;
+
+ public string ThemeKey => "colorful";
+
+ public string ResourcePath => "ms-appx:///Styles/Theme.Colorful.xaml";
+
+ public ColorfulThemeProvider(UISettings uiSettings)
+ {
+ ArgumentNullException.ThrowIfNull(uiSettings);
+ _uiSettings = uiSettings;
+ }
+
+ public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context)
+ {
+ var isLight = context.Theme == ElementTheme.Light ||
+ (context.Theme == ElementTheme.Default &&
+ _uiSettings.GetColorValue(UIColorType.Background).R > 128);
+
+ var baseColor = isLight ? LightBaseColor : DarkBaseColor;
+
+ // Windows is warping the hue of accent colors and running it through some curves to produce their accent shades.
+ // This will attempt to mimic that behavior.
+ var accentShades = AccentShades.Compute(context.Tint.LerpHsv(WindowsAccentHueWarpTransform.Transform(context.Tint), 0.5f));
+ var blended = isLight ? accentShades.Light3 : accentShades.Dark2;
+ var colorIntensityUser = (context.ColorIntensity ?? 100) / 100f;
+
+ // For light theme, we want to reduce intensity a bit, and also we need to keep the color fairly light,
+ // to avoid issues with text box caret.
+ var colorIntensity = isLight ? 0.6f * colorIntensityUser : colorIntensityUser;
+ var effectiveBgColor = ColorBlender.Blend(baseColor, blended, colorIntensity);
+
+ return new AcrylicBackdropParameters(effectiveBgColor, effectiveBgColor, 0.8f, 0.8f);
+ }
+
+ private static class ColorBlender
+ {
+ ///
+ /// Blends a semitransparent tint color over an opaque base color using alpha compositing.
+ ///
+ /// The opaque base color (background)
+ /// The semitransparent tint color (foreground)
+ /// The intensity of the tint (0.0 - 1.0)
+ /// The resulting blended color
+ public static Color Blend(Color baseColor, Color tintColor, float intensity)
+ {
+ // Normalize alpha to 0.0 - 1.0 range
+ intensity = Math.Clamp(intensity, 0f, 1f);
+
+ // Alpha compositing formula: result = tint * alpha + base * (1 - alpha)
+ var r = (byte)((tintColor.R * intensity) + (baseColor.R * (1 - intensity)));
+ var g = (byte)((tintColor.G * intensity) + (baseColor.G * (1 - intensity)));
+ var b = (byte)((tintColor.B * intensity) + (baseColor.B * (1 - intensity)));
+
+ // Result is fully opaque since base is opaque
+ return Color.FromArgb(255, r, g, b);
+ }
+ }
+
+ private static class WindowsAccentHueWarpTransform
+ {
+ private static readonly (double HIn, double HOut)[] HueMap =
+ [
+ (0, 0),
+ (10, 1),
+ (20, 6),
+ (30, 10),
+ (40, 14),
+ (50, 19),
+ (60, 36),
+ (70, 94),
+ (80, 112),
+ (90, 120),
+ (100, 120),
+ (110, 120),
+ (120, 120),
+ (130, 120),
+ (140, 120),
+ (150, 125),
+ (160, 135),
+ (170, 142),
+ (180, 178),
+ (190, 205),
+ (200, 220),
+ (210, 229),
+ (220, 237),
+ (230, 241),
+ (240, 243),
+ (250, 244),
+ (260, 245),
+ (270, 248),
+ (280, 252),
+ (290, 276),
+ (300, 293),
+ (310, 313),
+ (320, 330),
+ (330, 349),
+ (340, 353),
+ (350, 357)
+ ];
+
+ public static Color Transform(Color input, Options? opt = null)
+ {
+ opt ??= new Options();
+ var hsv = input.ToHsv();
+ return ColorHelper.FromHsv(
+ RemapHueLut(hsv.H),
+ Clamp01(Math.Pow(hsv.S, opt.SaturationGamma) * opt.SaturationGain),
+ Clamp01((opt.ValueScaleA * hsv.V) + opt.ValueBiasB),
+ input.A);
+ }
+
+ // Hue LUT remap (piecewise-linear with cyclic wrap)
+ private static double RemapHueLut(double hDeg)
+ {
+ // Normalize to [0,360)
+ hDeg = Mod(hDeg, 360.0);
+
+ // Handle wrap-around case: hDeg is between last entry (350°) and 360°
+ var last = HueMap[^1];
+ var first = HueMap[0];
+ if (hDeg >= last.HIn)
+ {
+ // Interpolate between last entry and first entry (wrapped by 360°)
+ var t = (hDeg - last.HIn) / (first.HIn + 360.0 - last.HIn + 1e-12);
+ var ho = Lerp(last.HOut, first.HOut + 360.0, t);
+ return Mod(ho, 360.0);
+ }
+
+ // Find segment [i, i+1] where HueMap[i].HIn <= hDeg < HueMap[i+1].HIn
+ for (var i = 0; i < HueMap.Length - 1; i++)
+ {
+ var a = HueMap[i];
+ var b = HueMap[i + 1];
+
+ if (hDeg >= a.HIn && hDeg < b.HIn)
+ {
+ var t = (hDeg - a.HIn) / (b.HIn - a.HIn + 1e-12);
+ return Lerp(a.HOut, b.HOut, t);
+ }
+ }
+
+ // Fallback (shouldn't happen)
+ return hDeg;
+ }
+
+ private static double Lerp(double a, double b, double t) => a + ((b - a) * t);
+
+ private static double Mod(double x, double m) => ((x % m) + m) % m;
+
+ private static double Clamp01(double x) => x < 0 ? 0 : (x > 1 ? 1 : x);
+
+ public sealed class Options
+ {
+ // Saturation boost (1.0 = no change). Typical: 1.3–1.8
+ public double SaturationGain { get; init; } = 1.0;
+
+ // Optional saturation gamma (1.0 = linear). <1.0 raises low S a bit; >1.0 preserves low S.
+ public double SaturationGamma { get; init; } = 1.0;
+
+ // Value (V) remap: V' = a*V + b (tone curve; clamp applied)
+ // Example that lifts blacks & compresses whites slightly: a=0.50, b=0.08
+ public double ValueScaleA { get; init; } = 0.6;
+
+ public double ValueBiasB { get; init; } = 0.01;
+ }
+ }
+
+ private static class AccentShades
+ {
+ public static (Color Light3, Color Light2, Color Light1, Color Dark1, Color Dark2, Color Dark3) Compute(Color accent)
+ {
+ var light1 = accent.Update(brightnessFactor: 0.15, saturationFactor: -0.12);
+ var light2 = accent.Update(brightnessFactor: 0.30, saturationFactor: -0.24);
+ var light3 = accent.Update(brightnessFactor: 0.45, saturationFactor: -0.36);
+
+ var dark1 = accent.UpdateBrightness(brightnessFactor: -0.05f);
+ var dark2 = accent.UpdateBrightness(brightnessFactor: -0.01f);
+ var dark3 = accent.UpdateBrightness(brightnessFactor: -0.015f);
+
+ return (light3, light2, light1, dark1, dark2, dark3);
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs
new file mode 100644
index 0000000000..a9411c3656
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs
@@ -0,0 +1,38 @@
+// 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.Core.Common.Services;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Provides theme identification, resource path resolution, and creation of acrylic
+/// backdrop parameters based on the current .
+///
+///
+/// Implementations should expose a stable and a valid XAML resource
+/// dictionary path via . The
+/// method computes
+/// using the supplied theme context.
+///
+internal interface IThemeProvider
+{
+ ///
+ /// Gets the unique key identifying this theme provider.
+ ///
+ string ThemeKey { get; }
+
+ ///
+ /// Gets the resource dictionary path for this theme.
+ ///
+ string ResourcePath { get; }
+
+ ///
+ /// Creates acrylic backdrop parameters based on the provided theme context.
+ ///
+ /// The current theme context, including theme, tint, and optional background details.
+ /// The computed for the backdrop.
+ AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context);
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs
new file mode 100644
index 0000000000..8177326259
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs
@@ -0,0 +1,13 @@
+// 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.UI.Xaml;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Dedicated ResourceDictionary for dynamic overrides that win over base theme resources. Since
+/// we can't use a key or name to identify the dictionary in Application resources, we use a dedicated type.
+///
+internal sealed partial class MutableOverridesDictionary : ResourceDictionary;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs
new file mode 100644
index 0000000000..c393894346
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.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 Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI.Xaml;
+using Windows.UI;
+using Windows.UI.ViewManagement;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Provides theme resources and acrylic backdrop parameters matching the default Command Palette theme.
+///
+internal sealed class NormalThemeProvider : IThemeProvider
+{
+ private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32);
+ private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243);
+ private readonly UISettings _uiSettings;
+
+ public NormalThemeProvider(UISettings uiSettings)
+ {
+ ArgumentNullException.ThrowIfNull(uiSettings);
+ _uiSettings = uiSettings;
+ }
+
+ public string ThemeKey => "normal";
+
+ public string ResourcePath => "ms-appx:///Styles/Theme.Normal.xaml";
+
+ public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context)
+ {
+ var isLight = context.Theme == ElementTheme.Light ||
+ (context.Theme == ElementTheme.Default &&
+ _uiSettings.GetColorValue(UIColorType.Background).R > 128);
+
+ return new AcrylicBackdropParameters(
+ TintColor: isLight ? LightBaseColor : DarkBaseColor,
+ FallbackColor: isLight ? LightBaseColor : DarkBaseColor,
+ TintOpacity: 0.5f,
+ LuminosityOpacity: isLight ? 0.9f : 0.96f);
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs
new file mode 100644
index 0000000000..6d0a6f01dd
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs
@@ -0,0 +1,332 @@
+// 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.UI.Xaml;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Simple theme switcher that swaps application ResourceDictionaries at runtime.
+/// Can also operate in event-only mode for consumers to apply resources themselves.
+/// Exposes a dedicated override dictionary that stays merged and is cleared on theme changes.
+///
+internal sealed partial class ResourceSwapper
+{
+ private readonly Lock _resourceSwapGate = new();
+ private readonly Dictionary _themeUris = new(StringComparer.OrdinalIgnoreCase);
+ private ResourceDictionary? _activeDictionary;
+ private string? _currentThemeName;
+ private Uri? _currentThemeUri;
+
+ private ResourceDictionary? _overrideDictionary;
+
+ ///
+ /// Raised after a theme has been activated.
+ ///
+ public event EventHandler? ResourcesSwapped;
+
+ ///
+ /// Gets or sets a value indicating whether when true (default) ResourceSwapper updates Application.Current.Resources. When false, it only raises ResourcesSwapped.
+ ///
+ public bool ApplyToAppResources { get; set; } = true;
+
+ ///
+ /// Gets name of the currently selected theme (if any).
+ ///
+ public string? CurrentThemeName
+ {
+ get
+ {
+ lock (_resourceSwapGate)
+ {
+ return _currentThemeName;
+ }
+ }
+ }
+
+ ///
+ /// Initializes ResourceSwapper by checking Application resources for an already merged theme dictionary.
+ ///
+ public void Initialize()
+ {
+ // Find merged dictionary in Application resources that matches a registered theme by URI
+ // This allows ResourceSwapper to pick up an initial theme set in XAML
+ var app = Application.Current;
+ var resourcesMergedDictionaries = app?.Resources?.MergedDictionaries;
+ if (resourcesMergedDictionaries == null)
+ {
+ return;
+ }
+
+ foreach (var dict in resourcesMergedDictionaries)
+ {
+ var uri = dict.Source;
+ if (uri is null)
+ {
+ continue;
+ }
+
+ var name = GetNameForUri(uri);
+ if (name is null)
+ {
+ continue;
+ }
+
+ lock (_resourceSwapGate)
+ {
+ _currentThemeName = name;
+ _currentThemeUri = uri;
+ _activeDictionary = dict;
+ }
+
+ break;
+ }
+ }
+
+ ///
+ /// Gets uri of the currently selected theme dictionary (if any).
+ ///
+ public Uri? CurrentThemeUri
+ {
+ get
+ {
+ lock (_resourceSwapGate)
+ {
+ return _currentThemeUri;
+ }
+ }
+ }
+
+ public static ResourceDictionary GetOverrideDictionary(bool clear = false)
+ {
+ var app = Application.Current ?? throw new InvalidOperationException("App is null");
+
+ if (app.Resources == null)
+ {
+ throw new InvalidOperationException("Application.Resources is null");
+ }
+
+ // (Re)locate the slot – Hot Reload may rebuild Application.Resources.
+ var slot = app.Resources!.MergedDictionaries!
+ .OfType()
+ .FirstOrDefault();
+
+ if (slot is null)
+ {
+ // If the slot vanished (Hot Reload), create it again at the end so it wins precedence.
+ slot = new MutableOverridesDictionary();
+ app.Resources.MergedDictionaries!.Add(slot);
+ }
+
+ // Ensure the slot has exactly one child RD we can swap safely.
+ if (slot.MergedDictionaries!.Count == 0)
+ {
+ slot.MergedDictionaries.Add(new ResourceDictionary());
+ }
+ else if (slot.MergedDictionaries.Count > 1)
+ {
+ // Normalize to a single child to keep semantics predictable.
+ var keep = slot.MergedDictionaries[^1];
+ slot.MergedDictionaries.Clear();
+ slot.MergedDictionaries.Add(keep);
+ }
+
+ if (clear)
+ {
+ // Swap the child dictionary instead of Clear() to avoid reentrancy issues.
+ var fresh = new ResourceDictionary();
+ slot.MergedDictionaries[0] = fresh;
+ return fresh;
+ }
+
+ return slot.MergedDictionaries[0]!;
+ }
+
+ ///
+ /// Registers a theme name mapped to a XAML ResourceDictionary URI (e.g. ms-appx:///Themes/Red.xaml)
+ ///
+ public void RegisterTheme(string name, Uri dictionaryUri)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("Theme name is required", nameof(name));
+ }
+
+ lock (_resourceSwapGate)
+ {
+ _themeUris[name] = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri));
+ }
+ }
+
+ ///
+ /// Registers a theme with a string URI.
+ ///
+ public void RegisterTheme(string name, string dictionaryUri)
+ {
+ ArgumentNullException.ThrowIfNull(dictionaryUri);
+ RegisterTheme(name, new Uri(dictionaryUri));
+ }
+
+ ///
+ /// Removes a previously registered theme.
+ ///
+ public bool UnregisterTheme(string name)
+ {
+ lock (_resourceSwapGate)
+ {
+ return _themeUris.Remove(name);
+ }
+ }
+
+ ///
+ /// Gets the names of all registered themes.
+ ///
+ public IEnumerable GetRegisteredThemes()
+ {
+ lock (_resourceSwapGate)
+ {
+ // return a copy to avoid external mutation
+ return new List(_themeUris.Keys);
+ }
+ }
+
+ ///
+ /// Activates a theme by name. The dictionary for the given name must be registered first.
+ ///
+ public void ActivateTheme(string theme)
+ {
+ if (string.IsNullOrWhiteSpace(theme))
+ {
+ throw new ArgumentException("Theme name is required", nameof(theme));
+ }
+
+ Uri uri;
+ lock (_resourceSwapGate)
+ {
+ if (!_themeUris.TryGetValue(theme, out uri!))
+ {
+ throw new KeyNotFoundException($"Theme '{theme}' is not registered.");
+ }
+ }
+
+ ActivateThemeInternal(theme, uri);
+ }
+
+ ///
+ /// Tries to activate a theme by name without throwing.
+ ///
+ public bool TryActivateTheme(string theme)
+ {
+ if (string.IsNullOrWhiteSpace(theme))
+ {
+ return false;
+ }
+
+ Uri uri;
+ lock (_resourceSwapGate)
+ {
+ if (!_themeUris.TryGetValue(theme, out uri!))
+ {
+ return false;
+ }
+ }
+
+ ActivateThemeInternal(theme, uri);
+ return true;
+ }
+
+ ///
+ /// Activates a theme by URI to a ResourceDictionary.
+ ///
+ public void ActivateTheme(Uri dictionaryUri)
+ {
+ ArgumentNullException.ThrowIfNull(dictionaryUri);
+
+ ActivateThemeInternal(GetNameForUri(dictionaryUri), dictionaryUri);
+ }
+
+ ///
+ /// Clears the currently active theme ResourceDictionary. Also clears the override dictionary.
+ ///
+ public void ClearActiveTheme()
+ {
+ lock (_resourceSwapGate)
+ {
+ var app = Application.Current;
+ if (app is null)
+ {
+ return;
+ }
+
+ if (_activeDictionary is not null && ApplyToAppResources)
+ {
+ _ = app.Resources.MergedDictionaries.Remove(_activeDictionary);
+ _activeDictionary = null;
+ }
+
+ // Clear overrides but keep the override dictionary merged for future updates
+ _overrideDictionary?.Clear();
+
+ _currentThemeName = null;
+ _currentThemeUri = null;
+ }
+ }
+
+ private void ActivateThemeInternal(string? name, Uri dictionaryUri)
+ {
+ lock (_resourceSwapGate)
+ {
+ _currentThemeName = name;
+ _currentThemeUri = dictionaryUri;
+ }
+
+ if (ApplyToAppResources)
+ {
+ ActivateThemeCore(dictionaryUri);
+ }
+
+ OnResourcesSwapped(new(name, dictionaryUri));
+ }
+
+ private void ActivateThemeCore(Uri dictionaryUri)
+ {
+ var app = Application.Current ?? throw new InvalidOperationException("Application.Current is null");
+
+ // Remove previously applied base theme dictionary
+ if (_activeDictionary is not null)
+ {
+ _ = app.Resources.MergedDictionaries.Remove(_activeDictionary);
+ _activeDictionary = null;
+ }
+
+ // Load and merge the new base theme dictionary
+ var newDict = new ResourceDictionary { Source = dictionaryUri };
+ app.Resources.MergedDictionaries.Add(newDict);
+ _activeDictionary = newDict;
+
+ // Ensure override dictionary exists and is merged last, then clear it to avoid leaking stale overrides
+ _overrideDictionary = GetOverrideDictionary(clear: true);
+ }
+
+ private string? GetNameForUri(Uri dictionaryUri)
+ {
+ lock (_resourceSwapGate)
+ {
+ foreach (var (key, value) in _themeUris)
+ {
+ if (Uri.Compare(value, dictionaryUri, UriComponents.AbsoluteUri, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ return key;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ private void OnResourcesSwapped(ResourcesSwappedEventArgs e)
+ {
+ ResourcesSwapped?.Invoke(this, e);
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs
new file mode 100644
index 0000000000..0a5cc15de6
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.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.UI.Services;
+
+public sealed class ResourcesSwappedEventArgs(string? name, Uri dictionaryUri) : EventArgs
+{
+ public string? Name { get; } = name;
+
+ public Uri DictionaryUri { get; } = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri));
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs
new file mode 100644
index 0000000000..67432c8748
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+internal sealed record ThemeContext
+{
+ public ElementTheme Theme { get; init; }
+
+ public Color Tint { get; init; }
+
+ public ImageSource? BackgroundImageSource { get; init; }
+
+ public Stretch BackgroundImageStretch { get; init; }
+
+ public double BackgroundImageOpacity { get; init; }
+
+ public int? ColorIntensity { get; init; }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs
new file mode 100644
index 0000000000..65fbfb24d7
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs
@@ -0,0 +1,261 @@
+// 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 CommunityToolkit.WinUI;
+using ManagedCommon;
+using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Windows.UI.ViewManagement;
+using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// ThemeService is a hub that translates user settings and system preferences into concrete
+/// theme resources and notifies listeners of changes.
+///
+internal sealed partial class ThemeService : IThemeService, IDisposable
+{
+ private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500);
+
+ private readonly UISettings _uiSettings;
+ private readonly SettingsModel _settings;
+ private readonly ResourceSwapper _resourceSwapper;
+ private readonly NormalThemeProvider _normalThemeProvider;
+ private readonly ColorfulThemeProvider _colorfulThemeProvider;
+
+ private DispatcherQueue? _dispatcherQueue;
+ private DispatcherQueueTimer? _dispatcherQueueTimer;
+ private bool _isInitialized;
+ private bool _disposed;
+ private InternalThemeState _currentState;
+
+ public event EventHandler? ThemeChanged;
+
+ public ThemeSnapshot Current => Volatile.Read(ref _currentState).Snapshot;
+
+ ///
+ /// Initializes the theme service. Must be called after the application window is activated and on UI thread.
+ ///
+ public void Initialize()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
+ if (_dispatcherQueue is null)
+ {
+ throw new InvalidOperationException("Failed to get DispatcherQueue for the current thread. Ensure Initialize is called on the UI thread after window activation.");
+ }
+
+ _dispatcherQueueTimer = _dispatcherQueue.CreateTimer();
+
+ _resourceSwapper.Initialize();
+ _isInitialized = true;
+ Reload();
+ }
+
+ private void Reload()
+ {
+ if (!_isInitialized)
+ {
+ return;
+ }
+
+ // provider selection
+ var intensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100);
+ IThemeProvider provider = intensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image
+ ? _colorfulThemeProvider
+ : _normalThemeProvider;
+
+ // Calculate values
+ var tint = _settings.ColorizationMode switch
+ {
+ ColorizationMode.CustomColor => _settings.CustomThemeColor,
+ ColorizationMode.WindowsAccentColor => _uiSettings.GetColorValue(UIColorType.Accent),
+ ColorizationMode.Image => _settings.CustomThemeColor,
+ _ => Colors.Transparent,
+ };
+ var effectiveTheme = GetElementTheme((ElementTheme)_settings.Theme);
+ var imageSource = _settings.ColorizationMode == ColorizationMode.Image
+ ? LoadImageSafe(_settings.BackgroundImagePath)
+ : null;
+ var stretch = _settings.BackgroundImageFit switch
+ {
+ BackgroundImageFit.Fill => Stretch.Fill,
+ _ => Stretch.UniformToFill,
+ };
+ var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0;
+
+ // create context and offload to actual theme provider
+ var context = new ThemeContext
+ {
+ Tint = tint,
+ ColorIntensity = intensity,
+ Theme = effectiveTheme,
+ BackgroundImageSource = imageSource,
+ BackgroundImageStretch = stretch,
+ BackgroundImageOpacity = opacity,
+ };
+ var backdrop = provider.GetAcrylicBackdrop(context);
+ var blur = _settings.BackgroundImageBlurAmount;
+ var brightness = _settings.BackgroundImageBrightness;
+
+ // Create public snapshot (no provider!)
+ var snapshot = new ThemeSnapshot
+ {
+ Tint = tint,
+ TintIntensity = intensity / 100f,
+ Theme = effectiveTheme,
+ BackgroundImageSource = imageSource,
+ BackgroundImageStretch = stretch,
+ BackgroundImageOpacity = opacity,
+ BackdropParameters = backdrop,
+ BlurAmount = blur,
+ BackgroundBrightness = brightness / 100f,
+ };
+
+ // Bundle with provider for internal use
+ var newState = new InternalThemeState
+ {
+ Snapshot = snapshot,
+ Provider = provider,
+ };
+
+ // Atomic swap
+ Interlocked.Exchange(ref _currentState, newState);
+
+ _resourceSwapper.TryActivateTheme(provider.ThemeKey);
+ ThemeChanged?.Invoke(this, new ThemeChangedEventArgs());
+ }
+
+ private static BitmapImage? LoadImageSafe(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ // If it looks like a file path and exists, prefer absolute file URI
+ if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri))
+ {
+ return null;
+ }
+
+ if (!uri.IsAbsoluteUri && File.Exists(path))
+ {
+ uri = new Uri(Path.GetFullPath(path));
+ }
+
+ return new BitmapImage(uri);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning($"Failed to load background image '{path}'. {ex.Message}");
+ return null;
+ }
+ }
+
+ public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(resourceSwapper);
+
+ _settings = settings;
+ _settings.SettingsChanged += SettingsOnSettingsChanged;
+
+ _resourceSwapper = resourceSwapper;
+
+ _uiSettings = new UISettings();
+ _uiSettings.ColorValuesChanged += UiSettings_ColorValuesChanged;
+
+ _normalThemeProvider = new NormalThemeProvider(_uiSettings);
+ _colorfulThemeProvider = new ColorfulThemeProvider(_uiSettings);
+ List providers = [_normalThemeProvider, _colorfulThemeProvider];
+
+ foreach (var provider in providers)
+ {
+ _resourceSwapper.RegisterTheme(provider.ThemeKey, provider.ResourcePath);
+ }
+
+ _currentState = new InternalThemeState
+ {
+ Snapshot = new ThemeSnapshot
+ {
+ Tint = Colors.Transparent,
+ Theme = ElementTheme.Light,
+ BackdropParameters = new AcrylicBackdropParameters(Colors.Black, Colors.Black, 0.5f, 0.5f),
+ BackgroundImageOpacity = 1,
+ BackgroundImageSource = null,
+ BackgroundImageStretch = Stretch.Fill,
+ BlurAmount = 0,
+ TintIntensity = 1.0f,
+ BackgroundBrightness = 0,
+ },
+ Provider = _normalThemeProvider,
+ };
+ }
+
+ private void RequestReload()
+ {
+ if (!_isInitialized || _dispatcherQueueTimer is null)
+ {
+ return;
+ }
+
+ _dispatcherQueueTimer.Debounce(Reload, ReloadDebounceInterval);
+ }
+
+ private ElementTheme GetElementTheme(ElementTheme theme)
+ {
+ return theme switch
+ {
+ ElementTheme.Light => ElementTheme.Light,
+ ElementTheme.Dark => ElementTheme.Dark,
+ _ => _uiSettings.GetColorValue(UIColorType.Background).CalculateBrightness() < 0.5
+ ? ElementTheme.Dark
+ : ElementTheme.Light,
+ };
+ }
+
+ private void SettingsOnSettingsChanged(SettingsModel sender, object? args)
+ {
+ RequestReload();
+ }
+
+ private void UiSettings_ColorValuesChanged(UISettings sender, object args)
+ {
+ RequestReload();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _dispatcherQueueTimer?.Stop();
+ _uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged;
+ _settings.SettingsChanged -= SettingsOnSettingsChanged;
+ }
+
+ private sealed class InternalThemeState
+ {
+ public required ThemeSnapshot Snapshot { get; init; }
+
+ public required IThemeProvider Provider { get; init; }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs
new file mode 100644
index 0000000000..5c250b94ef
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs
@@ -0,0 +1,70 @@
+// 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.Core.Common.Services;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI.Xaml;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Synchronizes a window's theme with .
+///
+internal sealed partial class WindowThemeSynchronizer : IDisposable
+{
+ private readonly IThemeService _themeService;
+ private readonly Window _window;
+
+ ///
+ /// Initializes a new instance of the class and subscribes to theme changes.
+ ///
+ /// The theme service to monitor for changes.
+ /// The window to synchronize.
+ /// Thrown when or is null.
+ public WindowThemeSynchronizer(IThemeService themeService, Window window)
+ {
+ _themeService = themeService ?? throw new ArgumentNullException(nameof(themeService));
+ _window = window ?? throw new ArgumentNullException(nameof(window));
+ _themeService.ThemeChanged += ThemeServiceOnThemeChanged;
+ }
+
+ ///
+ /// Unsubscribes from theme change events.
+ ///
+ public void Dispose()
+ {
+ _themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
+ }
+
+ ///
+ /// Applies the current theme to the window when theme changes occur.
+ ///
+ private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
+ {
+ if (_window.Content is not FrameworkElement fe)
+ {
+ return;
+ }
+
+ var dispatcherQueue = fe.DispatcherQueue;
+
+ if (dispatcherQueue is not null && dispatcherQueue.HasThreadAccess)
+ {
+ ApplyRequestedTheme(fe);
+ }
+ else
+ {
+ dispatcherQueue?.TryEnqueue(() => ApplyRequestedTheme(fe));
+ }
+ }
+
+ private void ApplyRequestedTheme(FrameworkElement fe)
+ {
+ // LOAD BEARING: Changing the RequestedTheme to Dark then Light then target forces
+ // a refresh of the theme.
+ fe.RequestedTheme = ElementTheme.Dark;
+ fe.RequestedTheme = ElementTheme.Light;
+ fe.RequestedTheme = _themeService.Current.Theme;
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml
new file mode 100644
index 0000000000..b9f31d8443
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs
new file mode 100644
index 0000000000..39a8ea4ae1
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs
@@ -0,0 +1,86 @@
+// 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;
+using ManagedCommon;
+using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Documents;
+using Microsoft.Windows.Storage.Pickers;
+
+namespace Microsoft.CmdPal.UI.Settings;
+
+///
+/// An empty page that can be used on its own or navigated to within a Frame.
+///
+public sealed partial class AppearancePage : Page
+{
+ private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
+
+ internal SettingsViewModel ViewModel { get; }
+
+ public AppearancePage()
+ {
+ InitializeComponent();
+
+ var settings = App.Current.Services.GetService()!;
+ ViewModel = new SettingsViewModel(settings, App.Current.Services, _mainTaskScheduler);
+ }
+
+ private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ if (XamlRoot?.ContentIslandEnvironment is null)
+ {
+ return;
+ }
+
+ var windowId = XamlRoot?.ContentIslandEnvironment?.AppWindowId ?? new WindowId(0);
+
+ var picker = new FileOpenPicker(windowId)
+ {
+ CommitButtonText = ViewModels.Properties.Resources.builtin_settings_appearance_pick_background_image_title!,
+ SuggestedStartLocation = PickerLocationId.PicturesLibrary,
+ ViewMode = PickerViewMode.Thumbnail,
+ };
+
+ string[] extensions = [".png", ".bmp", ".jpg", ".jpeg", ".jfif", ".gif", ".tiff", ".tif", ".webp", ".jxr"];
+ foreach (var ext in extensions)
+ {
+ picker.FileTypeFilter!.Add(ext);
+ }
+
+ var file = await picker.PickSingleFileAsync()!;
+ if (file != null)
+ {
+ ViewModel.Appearance.BackgroundImagePath = file.Path ?? string.Empty;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to pick background image file", ex);
+ }
+ }
+
+ private void OpenWindowsColorsSettings_Click(Hyperlink sender, HyperlinkClickEventArgs args)
+ {
+ // LOAD BEARING (or BEAR LOADING?): Process.Start with UseShellExecute inside a XAML input event can trigger WinUI reentrancy
+ // and cause FailFast crashes. Task.Run moves the call off the UI thread to prevent hard process termination.
+ Task.Run(() =>
+ {
+ try
+ {
+ _ = Process.Start(new ProcessStartInfo("ms-settings:colors") { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to open Windows Settings", ex);
+ }
+ });
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
index eb0264a683..65fa536c5b 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
@@ -81,35 +81,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml
index dc3cf9fd3b..e451fe7abe 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml
@@ -62,6 +62,11 @@
x:Uid="Settings_GeneralPage_NavigationViewItem_General"
Icon="{ui:FontIcon Glyph=}"
Tag="General" />
+
typeof(GeneralPage),
+ "Appearance" => typeof(AppearancePage),
"Extensions" => typeof(ExtensionsPage),
_ => null,
};
-
if (pageType is not null)
{
NavFrame.Navigate(pageType);
@@ -248,6 +248,12 @@ public sealed partial class SettingsWindow : WindowEx,
var pageType = RS_.GetString("Settings_PageTitles_GeneralPage");
BreadCrumbs.Add(new(pageType, pageType));
}
+ else if (e.SourcePageType == typeof(AppearancePage))
+ {
+ NavView.SelectedItem = AppearancePageNavItem;
+ var pageType = RS_.GetString("Settings_PageTitles_AppearancePage");
+ BreadCrumbs.Add(new(pageType, pageType));
+ }
else if (e.SourcePageType == typeof(ExtensionsPage))
{
NavView.SelectedItem = ExtensionPageNavItem;
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 8fe053cae2..b2c0260a94 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
@@ -550,9 +550,69 @@ Right-click to remove the key combination, thereby deactivating the shortcut.
Automatically returns to home page after a period of inactivity when Command Palette is closed
+
+ Personalization
+
+
+ App theme mode
+
+
+ Select which app theme to display
+
+
+ Appearance
+
+
+ Use system settings
+
+
+ Light
+
+
+ Dark
+
+
+ Color tint
+
+
+ Color intensity
+
+
+ Choose color
+
+
+ Use default
+
+
+ Use default color
+
+
+ Windows colors
+
+
+ Background image
+
+
+ Background image opacity
+
+
+ Background image fit
+
+
+ Fill
+
+
+ Fit
+
+
+ Stretch
+
General
+
+ Personalization
+
Extensions
@@ -577,4 +637,73 @@ Right-click to remove the key combination, thereby deactivating the shortcut.
Settings
+
+ Custom colors
+
+
+ None
+
+
+ Custom color
+
+
+ Accent color
+
+
+ Image
+
+
+ Browse...
+
+
+ Remove image
+
+
+ Background color
+
+
+ Choose a custom background color or use the current accent color
+
+
+ Background image
+
+
+ No settings
+
+
+ Background
+
+
+ Choose a custom background color or image
+
+
+ System accent color
+
+
+ Personalization › Colors
+
+
+ Background image blur
+
+
+ Background image brightness
+
+
+ Restore defaults
+
+
+ Reset
+
+
+ Change the system accent in Windows Settings:
+
+
+ Light
+
+
+ Dark
+
+
+ Use system settings
+
\ No newline at end of file
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml
index edca3f479c..728cd3ef4e 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml
@@ -4,37 +4,5 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml
new file mode 100644
index 0000000000..e1dfe7f45c
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml
new file mode 100644
index 0000000000..53b46d39d6
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From f822826cf136916d099d2335b95b7bc9be38f834 Mon Sep 17 00:00:00 2001
From: Jaylyn Barbee <51131738+Jaylyn-Barbee@users.noreply.github.com>
Date: Wed, 10 Dec 2025 08:15:34 -0500
Subject: [PATCH 5/9] [Light Switch] Follow Night Light mode (#43683)
## Summary of the Pull Request
Introduces a new mode that will have Light Switch follow Windows Night
Light.
## PR Checklist
- [x] Closes: https://github.com/microsoft/PowerToys/issues/42457
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [ ] **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
Strictly follows the state of Night Light. When NL is on, LS will be
switch to dark mode and when NL is off, LS will switch to light mode
(with respect to the users system/app selection).
## Validation Steps Performed
Turn on Follow Night Light mode
Change night light!
## Notes
---
.github/actions/spell-check/expect.txt | 3 +
.../LightSwitchModuleInterface/dllmain.cpp | 9 +-
.../LightSwitchService/LightSwitchService.cpp | 67 ++++++++-
.../LightSwitchService.vcxproj | 2 +
.../LightSwitchService.vcxproj.filters | 6 +
.../LightSwitchService/LightSwitchSettings.h | 7 +-
.../LightSwitchStateManager.cpp | 56 ++++++--
.../LightSwitchStateManager.h | 4 +
.../NightLightRegistryObserver.cpp | 1 +
.../NightLightRegistryObserver.h | 134 ++++++++++++++++++
.../LightSwitchService/SettingsConstants.h | 5 +-
.../LightSwitchService/ThemeHelper.cpp | 38 ++++-
.../LightSwitchService/ThemeHelper.h | 1 +
.../Settings.UI/Helpers/StartProcessHelper.cs | 1 +
.../SettingsXAML/Views/LightSwitchPage.xaml | 43 ++++++
.../Views/LightSwitchPage.xaml.cs | 16 +++
.../Settings.UI/Strings/en-us/Resources.resw | 65 +++++----
.../ViewModels/LightSwitchViewModel.cs | 1 +
18 files changed, 413 insertions(+), 46 deletions(-)
create mode 100644 src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp
create mode 100644 src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h
diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index 4a3305217e..f839c5976c 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -144,6 +144,8 @@ BLENDFUNCTION
blittable
Blockquotes
blt
+bluelightreduction
+bluelightreductionstate
BLURBEHIND
BLURREGION
bmi
@@ -1115,6 +1117,7 @@ NEWPLUSSHELLEXTENSIONWIN
newrow
nicksnettravels
NIF
+nightlight
NLog
NLSTEXT
NMAKE
diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp
index 170dde5b0a..a5973a396f 100644
--- a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp
+++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp
@@ -50,6 +50,7 @@ enum class ScheduleMode
Off,
FixedHours,
SunsetToSunrise,
+ FollowNightLight,
// add more later
};
@@ -61,6 +62,8 @@ inline std::wstring ToString(ScheduleMode mode)
return L"SunsetToSunrise";
case ScheduleMode::FixedHours:
return L"FixedHours";
+ case ScheduleMode::FollowNightLight:
+ return L"FollowNightLight";
default:
return L"Off";
}
@@ -72,6 +75,8 @@ inline ScheduleMode FromString(const std::wstring& str)
return ScheduleMode::SunsetToSunrise;
if (str == L"FixedHours")
return ScheduleMode::FixedHours;
+ if (str == L"FollowNightLight")
+ return ScheduleMode::FollowNightLight;
return ScheduleMode::Off;
}
@@ -167,7 +172,9 @@ public:
ToString(g_settings.m_scheduleMode),
{ { L"Off", L"Disable the schedule" },
{ L"FixedHours", L"Set hours manually" },
- { L"SunsetToSunrise", L"Use sunrise/sunset times" } });
+ { L"SunsetToSunrise", L"Use sunrise/sunset times" },
+ { L"FollowNightLight", L"Follow Windows Night Light state" }
+ });
// Integer spinners
settings.add_int_spinner(
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
index 845e24fa93..b6684da54e 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
@@ -13,10 +13,12 @@
#include
#include "LightSwitchStateManager.h"
#include
+#include
SERVICE_STATUS g_ServiceStatus = {};
SERVICE_STATUS_HANDLE g_StatusHandle = nullptr;
HANDLE g_ServiceStopEvent = nullptr;
+static LightSwitchStateManager* g_stateManagerPtr = nullptr;
VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv);
VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl);
@@ -168,7 +170,15 @@ static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateMan
}
// Use shared helper (handles wraparound logic)
- bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark);
+ bool shouldBeLight = false;
+ if (s.scheduleMode == ScheduleMode::FollowNightLight)
+ {
+ shouldBeLight = !IsNightLightEnabled();
+ }
+ else
+ {
+ shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark);
+ }
// Compare current system/apps theme
bool currentSystemLight = GetCurrentSystemTheme();
@@ -199,15 +209,40 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
// Initialization
// ────────────────────────────────────────────────────────────────
static LightSwitchStateManager stateManager;
+ g_stateManagerPtr = &stateManager;
LightSwitchSettings::instance().InitFileWatcher();
HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE");
HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent();
+ static std::unique_ptr g_nightLightWatcher;
+
LightSwitchSettings::instance().LoadSettings();
const auto& settings = LightSwitchSettings::instance().settings();
+ // after loading settings:
+ bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight);
+
+ if (nightLightNeeded && !g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Starting Night Light registry watcher...");
+
+ g_nightLightWatcher = std::make_unique(
+ HKEY_CURRENT_USER,
+ NIGHT_LIGHT_REGISTRY_PATH,
+ []() {
+ if (g_stateManagerPtr)
+ g_stateManagerPtr->OnNightLightChange();
+ });
+ }
+ else if (!nightLightNeeded && g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher...");
+ g_nightLightWatcher->Stop();
+ g_nightLightWatcher.reset();
+ }
+
SYSTEMTIME st;
GetLocalTime(&st);
int nowMinutes = st.wHour * 60 + st.wMinute;
@@ -274,6 +309,31 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
ResetEvent(hSettingsChanged);
LightSwitchSettings::instance().LoadSettings();
stateManager.OnSettingsChanged();
+
+ const auto& settings = LightSwitchSettings::instance().settings();
+ bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight);
+
+ if (nightLightNeeded && !g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Starting Night Light registry watcher...");
+
+ g_nightLightWatcher = std::make_unique(
+ HKEY_CURRENT_USER,
+ NIGHT_LIGHT_REGISTRY_PATH,
+ []() {
+ if (g_stateManagerPtr)
+ g_stateManagerPtr->OnNightLightChange();
+ });
+
+ stateManager.OnNightLightChange();
+ }
+ else if (!nightLightNeeded && g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher...");
+ g_nightLightWatcher->Stop();
+ g_nightLightWatcher.reset();
+ }
+
continue;
}
}
@@ -285,6 +345,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
CloseHandle(hManualOverride);
if (hParent)
CloseHandle(hParent);
+ if (g_nightLightWatcher)
+ {
+ g_nightLightWatcher->Stop();
+ g_nightLightWatcher.reset();
+ }
Logger::info(L"[LightSwitchService] Worker thread exiting cleanly.");
return 0;
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj
index a3a505f897..e1c8052de6 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj
@@ -76,6 +76,7 @@
+
@@ -88,6 +89,7 @@
+
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters
index 795df99aba..55c7bde39b 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters
@@ -36,6 +36,9 @@
Source Files
+
+ Source Files
+
@@ -62,6 +65,9 @@
Header Files
+
+ Header Files
+
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h
index d4029d072d..1d1c7953fe 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h
@@ -19,7 +19,8 @@ enum class ScheduleMode
{
Off,
FixedHours,
- SunsetToSunrise
+ SunsetToSunrise,
+ FollowNightLight,
// Add more in the future
};
@@ -31,6 +32,8 @@ inline std::wstring ToString(ScheduleMode mode)
return L"FixedHours";
case ScheduleMode::SunsetToSunrise:
return L"SunsetToSunrise";
+ case ScheduleMode::FollowNightLight:
+ return L"FollowNightLight";
default:
return L"Off";
}
@@ -42,6 +45,8 @@ inline ScheduleMode FromString(const std::wstring& str)
return ScheduleMode::SunsetToSunrise;
if (str == L"FixedHours")
return ScheduleMode::FixedHours;
+ if (str == L"FollowNightLight")
+ return ScheduleMode::FollowNightLight;
else
return ScheduleMode::Off;
}
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
index 4fba4ae9a6..f562d38c41 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
@@ -31,7 +31,10 @@ void LightSwitchStateManager::OnSettingsChanged()
void LightSwitchStateManager::OnTick(int currentMinutes)
{
std::lock_guard lock(_stateMutex);
- EvaluateAndApplyIfNeeded();
+ if (_state.lastAppliedMode != ScheduleMode::FollowNightLight)
+ {
+ EvaluateAndApplyIfNeeded();
+ }
}
// Called when manual override is triggered
@@ -49,8 +52,38 @@ void LightSwitchStateManager::OnManualOverride()
_state.isAppsLightActive = GetCurrentAppsTheme();
Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).",
- (_state.isSystemLightActive ? L"light" : L"dark"),
- (_state.isAppsLightActive ? L"light" : L"dark"));
+ (_state.isSystemLightActive ? L"light" : L"dark"),
+ (_state.isAppsLightActive ? L"light" : L"dark"));
+ }
+
+ EvaluateAndApplyIfNeeded();
+}
+
+// Runs with the registry observer detects a change in Night Light settings.
+void LightSwitchStateManager::OnNightLightChange()
+{
+ std::lock_guard lock(_stateMutex);
+
+ bool newNightLightState = IsNightLightEnabled();
+
+ // In Follow Night Light mode, treat a Night Light toggle as a boundary
+ if (_state.lastAppliedMode == ScheduleMode::FollowNightLight && _state.isManualOverride)
+ {
+ Logger::info(L"[LightSwitchStateManager] Night Light changed while manual override active; "
+ L"treating as a boundary and clearing manual override.");
+ _state.isManualOverride = false;
+ }
+
+ if (newNightLightState != _state.isNightLightActive)
+ {
+ Logger::info(L"[LightSwitchStateManager] Night Light toggled to {}",
+ newNightLightState ? L"ON" : L"OFF");
+
+ _state.isNightLightActive = newNightLightState;
+ }
+ else
+ {
+ Logger::debug(L"[LightSwitchStateManager] Night Light change event fired, but no actual change.");
}
EvaluateAndApplyIfNeeded();
@@ -77,9 +110,9 @@ void LightSwitchStateManager::SyncInitialThemeState()
_state.isSystemLightActive = GetCurrentSystemTheme();
_state.isAppsLightActive = GetCurrentAppsTheme();
Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})",
- _state.isSystemLightActive ? L"light" : L"dark");
+ _state.isSystemLightActive ? L"light" : L"dark");
Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})",
- _state.isAppsLightActive ? L"light" : L"dark");
+ _state.isAppsLightActive ? L"light" : L"dark");
}
static std::pair update_sun_times(auto& settings)
@@ -194,7 +227,15 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
_state.lastAppliedMode = _currentSettings.scheduleMode;
- bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes);
+ bool shouldBeLight = false;
+ if (_currentSettings.scheduleMode == ScheduleMode::FollowNightLight)
+ {
+ shouldBeLight = !_state.isNightLightActive;
+ }
+ else
+ {
+ shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes);
+ }
bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight);
bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight);
@@ -227,6 +268,3 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
_state.lastTickMinutes = now;
}
-
-
-
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h
index 5c9bcc6e25..c4f39a2e9a 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h
@@ -9,6 +9,7 @@ struct LightSwitchState
bool isManualOverride = false;
bool isSystemLightActive = false;
bool isAppsLightActive = false;
+ bool isNightLightActive = false;
int lastEvaluatedDay = -1;
int lastTickMinutes = -1;
@@ -32,6 +33,9 @@ public:
// Called when manual override is toggled (via shortcut or system change).
void OnManualOverride();
+ // Called when night light changes in windows settings
+ void OnNightLightChange();
+
// Initial sync at startup to align internal state with system theme
void SyncInitialThemeState();
diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp
new file mode 100644
index 0000000000..8da19c6595
--- /dev/null
+++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp
@@ -0,0 +1 @@
+#include "NightLightRegistryObserver.h"
diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h
new file mode 100644
index 0000000000..2806c28316
--- /dev/null
+++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h
@@ -0,0 +1,134 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+
+class NightLightRegistryObserver
+{
+public:
+ NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function callback) :
+ _root(root), _subkey(subkey), _callback(std::move(callback)), _stop(false)
+ {
+ _thread = std::thread([this]() { this->Run(); });
+ }
+
+ ~NightLightRegistryObserver()
+ {
+ Stop();
+ }
+
+ void Stop()
+ {
+ _stop = true;
+
+ {
+ std::lock_guard lock(_mutex);
+ if (_event)
+ SetEvent(_event);
+ }
+
+ if (_thread.joinable())
+ _thread.join();
+
+ std::lock_guard lock(_mutex);
+ if (_hKey)
+ {
+ RegCloseKey(_hKey);
+ _hKey = nullptr;
+ }
+
+ if (_event)
+ {
+ CloseHandle(_event);
+ _event = nullptr;
+ }
+ }
+
+
+private:
+ void Run()
+ {
+ {
+ std::lock_guard lock(_mutex);
+ if (RegOpenKeyExW(_root, _subkey.c_str(), 0, KEY_NOTIFY, &_hKey) != ERROR_SUCCESS)
+ return;
+
+ _event = CreateEventW(nullptr, TRUE, FALSE, nullptr);
+ if (!_event)
+ {
+ RegCloseKey(_hKey);
+ _hKey = nullptr;
+ return;
+ }
+ }
+
+ while (!_stop)
+ {
+ HKEY hKeyLocal = nullptr;
+ HANDLE eventLocal = nullptr;
+
+ {
+ std::lock_guard lock(_mutex);
+ if (_stop)
+ break;
+
+ hKeyLocal = _hKey;
+ eventLocal = _event;
+ }
+
+ if (!hKeyLocal || !eventLocal)
+ break;
+
+ if (_stop)
+ break;
+
+ if (RegNotifyChangeKeyValue(hKeyLocal, FALSE, REG_NOTIFY_CHANGE_LAST_SET, eventLocal, TRUE) != ERROR_SUCCESS)
+ break;
+
+ DWORD wait = WaitForSingleObject(eventLocal, INFINITE);
+ if (_stop || wait == WAIT_FAILED)
+ break;
+
+ ResetEvent(eventLocal);
+
+ if (!_stop && _callback)
+ {
+ try
+ {
+ _callback();
+ }
+ catch (...)
+ {
+ }
+ }
+ }
+
+ {
+ std::lock_guard lock(_mutex);
+ if (_hKey)
+ {
+ RegCloseKey(_hKey);
+ _hKey = nullptr;
+ }
+
+ if (_event)
+ {
+ CloseHandle(_event);
+ _event = nullptr;
+ }
+ }
+ }
+
+
+ HKEY _root;
+ std::wstring _subkey;
+ std::function _callback;
+ HANDLE _event = nullptr;
+ HKEY _hKey = nullptr;
+ std::thread _thread;
+ std::atomic _stop;
+ std::mutex _mutex;
+};
\ No newline at end of file
diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h
index 4872864eff..8015c9b3e6 100644
--- a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h
+++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h
@@ -11,4 +11,7 @@ enum class SettingId
Sunset_Offset,
ChangeSystem,
ChangeApps
-};
\ No newline at end of file
+};
+
+constexpr wchar_t PERSONALIZATION_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
+constexpr wchar_t NIGHT_LIGHT_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.bluelightreduction.bluelightreductionstate\\windows.data.bluelightreduction.bluelightreductionstate";
diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp
index 9633ab2fde..cfa858c636 100644
--- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp
@@ -3,6 +3,7 @@
#include
#include
#include "ThemeHelper.h"
+#include
// Controls changing the themes.
@@ -10,7 +11,7 @@ static void ResetColorPrevalence()
{
HKEY hKey;
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_SET_VALUE,
&hKey) == ERROR_SUCCESS)
@@ -31,7 +32,7 @@ void SetAppsTheme(bool mode)
{
HKEY hKey;
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_SET_VALUE,
&hKey) == ERROR_SUCCESS)
@@ -50,7 +51,7 @@ void SetSystemTheme(bool mode)
{
HKEY hKey;
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_SET_VALUE,
&hKey) == ERROR_SUCCESS)
@@ -79,7 +80,7 @@ bool GetCurrentSystemTheme()
DWORD size = sizeof(value);
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_READ,
&hKey) == ERROR_SUCCESS)
@@ -98,7 +99,7 @@ bool GetCurrentAppsTheme()
DWORD size = sizeof(value);
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_READ,
&hKey) == ERROR_SUCCESS)
@@ -109,3 +110,30 @@ bool GetCurrentAppsTheme()
return value == 1; // true = light, false = dark
}
+
+bool IsNightLightEnabled()
+{
+ HKEY hKey;
+ const wchar_t* path = NIGHT_LIGHT_REGISTRY_PATH;
+
+ if (RegOpenKeyExW(HKEY_CURRENT_USER, path, 0, KEY_READ, &hKey) != ERROR_SUCCESS)
+ return false;
+
+ // RegGetValueW will set size to the size of the data and we expect that to be at least 25 bytes (we need to access bytes 23 and 24)
+ DWORD size = 0;
+ if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, nullptr, &size) != ERROR_SUCCESS || size < 25)
+ {
+ RegCloseKey(hKey);
+ return false;
+ }
+
+ std::vector data(size);
+ if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, data.data(), &size) != ERROR_SUCCESS)
+ {
+ RegCloseKey(hKey);
+ return false;
+ }
+
+ RegCloseKey(hKey);
+ return data[23] == 0x10 && data[24] == 0x00;
+}
\ No newline at end of file
diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h
index 5985fd95c8..e8d45e9c2a 100644
--- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h
+++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h
@@ -3,3 +3,4 @@ void SetSystemTheme(bool dark);
void SetAppsTheme(bool dark);
bool GetCurrentSystemTheme();
bool GetCurrentAppsTheme();
+bool IsNightLightEnabled();
\ No newline at end of file
diff --git a/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs b/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs
index ce172b2aa2..05c5d7f66c 100644
--- a/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs
+++ b/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs
@@ -11,6 +11,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
{
public const string ColorsSettings = "ms-settings:colors";
public const string DiagnosticsAndFeedback = "ms-settings:privacy-feedback";
+ public const string NightLightSettings = "ms-settings:nightlight";
public static string AnimationsSettings => OSVersionHelper.IsWindows11()
? "ms-settings:easeofaccess-visualeffects"
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml
index ec61a0fcd5..a44d482a04 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml
@@ -67,6 +67,10 @@
x:Uid="LightSwitch_ModeSunsetToSunrise"
AutomationProperties.AutomationId="SunCBItem_LightSwitch"
Tag="SunsetToSunrise" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs
index dcd40fdbc7..1ee79a4010 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs
@@ -355,6 +355,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views
VisualStateManager.GoToState(this, "SunsetToSunriseState", true);
this.SunriseModeChartState();
break;
+ case "FollowNightLight":
+ VisualStateManager.GoToState(this, "FollowNightLightState", true);
+ TimelineCard.Visibility = Visibility.Collapsed;
+ break;
default:
VisualStateManager.GoToState(this, "OffState", true);
this.TimelineCard.Visibility = Visibility.Collapsed;
@@ -362,6 +366,18 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
}
+ private void OpenNightLightSettings_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ Helpers.StartProcessHelper.Start(Helpers.StartProcessHelper.NightLightSettings);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Error while trying to open the system night light settings", ex);
+ }
+ }
+
private void SunriseModeChartState()
{
if (this.ViewModel.Latitude != "0.0" && this.ViewModel.Longitude != "0.0")
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 f7fbeda08b..30535804b7 100644
--- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
+++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
@@ -1,17 +1,17 @@
-
@@ -5760,4 +5760,13 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
A modern UI built with Fluent Design
Fluent Design is a product name, do not loc
-
+
+ Follow Night Light
+
+
+ Personalize your Night Light settings.
+
+
+ Following Night Light settings.
+
+
\ No newline at end of file
diff --git a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs
index 621fa91d43..e9e744705f 100644
--- a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs
+++ b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs
@@ -42,6 +42,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
"Off",
"FixedHours",
"SunsetToSunrise",
+ "FollowNightLight",
};
_toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value;
From 995bbdc62d92bb7357676242d076492e10a1395a Mon Sep 17 00:00:00 2001
From: Gleb Khmyznikov
Date: Wed, 10 Dec 2025 10:04:04 -0800
Subject: [PATCH 6/9] Fix fancy zones UI tests #42249 (#44181)
- [ ] Closes: #42249
Contribution to https://github.com/microsoft/PowerToys/issues/40701
---
.github/actions/spell-check/allow/code.txt | 4 +
.../UITestAutomation/ScreenRecording.cs | 399 ++++++++++++++++++
src/common/UITestAutomation/SessionHelper.cs | 122 ++++--
.../UITestAutomation/SettingsConfigHelper.cs | 7 +-
src/common/UITestAutomation/UITestBase.cs | 99 ++++-
.../MouseUtils.UITests/FindMyMouseTests.cs | 2 +
.../Settings/SettingsWindow.xaml.cs | 1 +
.../FancyZones.UITests/DragWindowTests.cs | 286 +++++++------
.../LayoutApplyHotKeyTests.cs | 10 +-
.../FancyZones.UITests/OneZoneSwitchTests.cs | 68 ++-
.../peek/Peek.UITests/PeekFilePreviewTests.cs | 4 +-
.../SettingsXAML/Views/FancyZonesPage.xaml | 10 +-
12 files changed, 807 insertions(+), 205 deletions(-)
create mode 100644 src/common/UITestAutomation/ScreenRecording.cs
diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt
index c655bb1b55..a7d02dcb21 100644
--- a/.github/actions/spell-check/allow/code.txt
+++ b/.github/actions/spell-check/allow/code.txt
@@ -335,3 +335,7 @@ azp
feedbackhub
needinfo
reportbug
+
+#ffmpeg
+crf
+nostdin
diff --git a/src/common/UITestAutomation/ScreenRecording.cs b/src/common/UITestAutomation/ScreenRecording.cs
new file mode 100644
index 0000000000..57e844936d
--- /dev/null
+++ b/src/common/UITestAutomation/ScreenRecording.cs
@@ -0,0 +1,399 @@
+// 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.Diagnostics;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.PowerToys.UITest
+{
+ ///
+ /// Provides methods for recording the screen during UI tests.
+ /// Requires FFmpeg to be installed and available in PATH.
+ ///
+ internal class ScreenRecording : IDisposable
+ {
+ private readonly string outputDirectory;
+ private readonly string framesDirectory;
+ private readonly string outputFilePath;
+ private readonly List capturedFrames;
+ private readonly SemaphoreSlim recordingLock = new(1, 1);
+ private readonly Stopwatch recordingStopwatch = new();
+ private readonly string? ffmpegPath;
+ private CancellationTokenSource? recordingCancellation;
+ private Task? recordingTask;
+ private bool isRecording;
+ private int frameCount;
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetDC(IntPtr hWnd);
+
+ [DllImport("gdi32.dll")]
+ private static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC);
+
+ [DllImport("user32.dll")]
+ private static extern bool GetCursorInfo(out ScreenCapture.CURSORINFO pci);
+
+ [DllImport("user32.dll")]
+ private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags);
+
+ private const int CURSORSHOWING = 0x00000001;
+ private const int DESKTOPHORZRES = 118;
+ private const int DESKTOPVERTRES = 117;
+ private const int DINORMAL = 0x0003;
+ private const int TargetFps = 15; // 15 FPS for good balance of quality and size
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Directory where the recording will be saved.
+ public ScreenRecording(string outputDirectory)
+ {
+ this.outputDirectory = outputDirectory;
+ string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ framesDirectory = Path.Combine(outputDirectory, $"frames_{timestamp}");
+ outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4");
+ capturedFrames = new List();
+ frameCount = 0;
+
+ // Check if FFmpeg is available
+ ffmpegPath = FindFfmpeg();
+ if (ffmpegPath == null)
+ {
+ Console.WriteLine("FFmpeg not found. Screen recording will be disabled.");
+ Console.WriteLine("To enable video recording, install FFmpeg: https://ffmpeg.org/download.html");
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether screen recording is available (FFmpeg found).
+ ///
+ public bool IsAvailable => ffmpegPath != null;
+
+ ///
+ /// Starts recording the screen.
+ ///
+ /// A task representing the asynchronous operation.
+ public async Task StartRecordingAsync()
+ {
+ await recordingLock.WaitAsync();
+ try
+ {
+ if (isRecording || !IsAvailable)
+ {
+ return;
+ }
+
+ // Create frames directory
+ Directory.CreateDirectory(framesDirectory);
+
+ recordingCancellation = new CancellationTokenSource();
+ isRecording = true;
+ recordingStopwatch.Start();
+
+ // Start the recording task
+ recordingTask = Task.Run(() => RecordFrames(recordingCancellation.Token));
+
+ Console.WriteLine($"Started screen recording at {TargetFps} FPS");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to start recording: {ex.Message}");
+ isRecording = false;
+ }
+ finally
+ {
+ recordingLock.Release();
+ }
+ }
+
+ ///
+ /// Stops recording and encodes video.
+ ///
+ /// A task representing the asynchronous operation.
+ public async Task StopRecordingAsync()
+ {
+ await recordingLock.WaitAsync();
+ try
+ {
+ if (!isRecording || recordingCancellation == null)
+ {
+ return;
+ }
+
+ // Signal cancellation
+ recordingCancellation.Cancel();
+
+ // Wait for recording task to complete
+ if (recordingTask != null)
+ {
+ await recordingTask;
+ }
+
+ recordingStopwatch.Stop();
+ isRecording = false;
+
+ double duration = recordingStopwatch.Elapsed.TotalSeconds;
+ Console.WriteLine($"Recording stopped. Captured {capturedFrames.Count} frames in {duration:F2} seconds");
+
+ // Encode to video
+ await EncodeToVideoAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error stopping recording: {ex.Message}");
+ }
+ finally
+ {
+ Cleanup();
+ recordingLock.Release();
+ }
+ }
+
+ ///
+ /// Records frames from the screen.
+ ///
+ private void RecordFrames(CancellationToken cancellationToken)
+ {
+ try
+ {
+ int frameInterval = 1000 / TargetFps;
+ var frameTimer = Stopwatch.StartNew();
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var frameStart = frameTimer.ElapsedMilliseconds;
+
+ try
+ {
+ CaptureFrame();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error capturing frame: {ex.Message}");
+ }
+
+ // Sleep for remaining time to maintain target FPS
+ var frameTime = frameTimer.ElapsedMilliseconds - frameStart;
+ var sleepTime = Math.Max(0, frameInterval - (int)frameTime);
+
+ if (sleepTime > 0)
+ {
+ Thread.Sleep(sleepTime);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when stopping
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error during recording: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Captures a single frame.
+ ///
+ private void CaptureFrame()
+ {
+ IntPtr hdc = GetDC(IntPtr.Zero);
+ int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES);
+ int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES);
+ ReleaseDC(IntPtr.Zero, hdc);
+
+ Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight);
+ using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb))
+ {
+ using (Graphics g = Graphics.FromImage(bitmap))
+ {
+ g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size);
+
+ ScreenCapture.CURSORINFO cursorInfo;
+ cursorInfo.CbSize = Marshal.SizeOf();
+ if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING)
+ {
+ IntPtr hdcDest = g.GetHdc();
+ DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL);
+ g.ReleaseHdc(hdcDest);
+ }
+ }
+
+ string framePath = Path.Combine(framesDirectory, $"frame_{frameCount:D6}.jpg");
+ bitmap.Save(framePath, ImageFormat.Jpeg);
+ capturedFrames.Add(framePath);
+ frameCount++;
+ }
+ }
+
+ ///
+ /// Encodes captured frames to video using ffmpeg.
+ ///
+ private async Task EncodeToVideoAsync()
+ {
+ if (capturedFrames.Count == 0)
+ {
+ Console.WriteLine("No frames captured");
+ return;
+ }
+
+ try
+ {
+ // Build ffmpeg command with proper non-interactive flags
+ string inputPattern = Path.Combine(framesDirectory, "frame_%06d.jpg");
+
+ // -y: overwrite without asking
+ // -nostdin: disable interaction
+ // -loglevel error: only show errors
+ // -stats: show encoding progress
+ string args = $"-y -nostdin -loglevel error -stats -framerate {TargetFps} -i \"{inputPattern}\" -c:v libx264 -pix_fmt yuv420p -crf 23 \"{outputFilePath}\"";
+
+ Console.WriteLine($"Encoding {capturedFrames.Count} frames to video...");
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = ffmpegPath!,
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = true, // Important: redirect stdin to prevent hanging
+ CreateNoWindow = true,
+ };
+
+ using var process = Process.Start(startInfo);
+ if (process != null)
+ {
+ // Close stdin immediately to ensure FFmpeg doesn't wait for input
+ process.StandardInput.Close();
+
+ // Read output streams asynchronously to prevent deadlock
+ var outputTask = process.StandardOutput.ReadToEndAsync();
+ var errorTask = process.StandardError.ReadToEndAsync();
+
+ // Wait for process to exit
+ await process.WaitForExitAsync();
+
+ // Get the output
+ string stdout = await outputTask;
+ string stderr = await errorTask;
+
+ if (process.ExitCode == 0 && File.Exists(outputFilePath))
+ {
+ var fileInfo = new FileInfo(outputFilePath);
+ Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024 / 1024:F1} MB)");
+ }
+ else
+ {
+ Console.WriteLine($"FFmpeg encoding failed with exit code {process.ExitCode}");
+ if (!string.IsNullOrWhiteSpace(stderr))
+ {
+ Console.WriteLine($"FFmpeg error: {stderr}");
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error encoding video: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Finds ffmpeg executable.
+ ///
+ private static string? FindFfmpeg()
+ {
+ // Check if ffmpeg is in PATH
+ var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty();
+
+ foreach (var dir in pathDirs)
+ {
+ var ffmpegPath = Path.Combine(dir, "ffmpeg.exe");
+ if (File.Exists(ffmpegPath))
+ {
+ return ffmpegPath;
+ }
+ }
+
+ // Check common installation locations
+ var commonPaths = new[]
+ {
+ @"C:\.tools\ffmpeg\bin\ffmpeg.exe",
+ @"C:\ffmpeg\bin\ffmpeg.exe",
+ @"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
+ @"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
+ @$"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\WinGet\Links\ffmpeg.exe",
+ };
+
+ foreach (var path in commonPaths)
+ {
+ if (File.Exists(path))
+ {
+ return path;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the path to the recorded video file.
+ ///
+ public string OutputFilePath => outputFilePath;
+
+ ///
+ /// Gets the directory containing recordings.
+ ///
+ public string OutputDirectory => outputDirectory;
+
+ ///
+ /// Cleans up resources.
+ ///
+ private void Cleanup()
+ {
+ recordingCancellation?.Dispose();
+ recordingCancellation = null;
+ recordingTask = null;
+
+ // Clean up frames directory if it exists
+ try
+ {
+ if (Directory.Exists(framesDirectory))
+ {
+ Directory.Delete(framesDirectory, true);
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to cleanup frames directory: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Disposes resources.
+ ///
+ public void Dispose()
+ {
+ if (isRecording)
+ {
+ StopRecordingAsync().GetAwaiter().GetResult();
+ }
+
+ Cleanup();
+ recordingLock.Dispose();
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/src/common/UITestAutomation/SessionHelper.cs b/src/common/UITestAutomation/SessionHelper.cs
index 0ca3eb3ddd..fef220a647 100644
--- a/src/common/UITestAutomation/SessionHelper.cs
+++ b/src/common/UITestAutomation/SessionHelper.cs
@@ -130,9 +130,13 @@ namespace Microsoft.PowerToys.UITest
///
/// The path to the application executable.
/// Optional command line arguments to pass to the application.
- public void StartExe(string appPath, string[]? args = null)
+ public void StartExe(string appPath, string[]? args = null, string? enableModules = null)
{
var opts = new AppiumOptions();
+ if (!string.IsNullOrEmpty(enableModules))
+ {
+ opts.AddAdditionalCapability("enableModules", enableModules);
+ }
if (scope == PowerToysModule.PowerToysSettings)
{
@@ -169,27 +173,66 @@ namespace Microsoft.PowerToys.UITest
private void TryLaunchPowerToysSettings(AppiumOptions opts)
{
- try
+ if (opts.ToCapabilities().HasCapability("enableModules"))
{
- var runnerProcessInfo = new ProcessStartInfo
+ var modulesString = (string)opts.ToCapabilities().GetCapability("enableModules");
+ var modulesArray = modulesString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ SettingsConfigHelper.ConfigureGlobalModuleSettings(modulesArray);
+ }
+ else
+ {
+ SettingsConfigHelper.ConfigureGlobalModuleSettings();
+ }
+
+ const int maxTries = 3;
+ const int delayMs = 5000;
+ const int maxRetries = 3;
+
+ for (int tryCount = 1; tryCount <= maxTries; tryCount++)
+ {
+ try
{
- FileName = locationPath + runnerPath,
- Verb = "runas",
- Arguments = "--open-settings",
- };
+ var runnerProcessInfo = new ProcessStartInfo
+ {
+ FileName = locationPath + runnerPath,
+ Verb = "runas",
+ Arguments = "--open-settings",
+ };
- ExitExe(runnerProcessInfo.FileName);
- runner = Process.Start(runnerProcessInfo);
+ ExitExe(runnerProcessInfo.FileName);
- WaitForWindowAndSetCapability(opts, "PowerToys Settings", 5000, 5);
+ // Verify process was killed
+ string exeName = Path.GetFileNameWithoutExtension(runnerProcessInfo.FileName);
+ var remainingProcesses = Process.GetProcessesByName(exeName);
- // Exit CmdPal UI before launching new process if use installer for test
- ExitExeByName("Microsoft.CmdPal.UI");
- }
- catch (Exception ex)
- {
- throw new InvalidOperationException($"Failed to launch PowerToys Settings: {ex.Message}", ex);
+ runner = Process.Start(runnerProcessInfo);
+
+ if (WaitForWindowAndSetCapability(opts, "PowerToys Settings", delayMs, maxRetries))
+ {
+ // Exit CmdPal UI before launching new process if use installer for test
+ ExitExeByName("Microsoft.CmdPal.UI");
+ return;
+ }
+
+ // Window not found, kill all PowerToys processes and retry
+ if (tryCount < maxTries)
+ {
+ KillPowerToysProcesses();
+ }
+ }
+ catch (Exception ex)
+ {
+ if (tryCount == maxTries)
+ {
+ throw new InvalidOperationException($"Failed to launch PowerToys Settings after {maxTries} attempts: {ex.Message}", ex);
+ }
+
+ // Kill processes and retry
+ KillPowerToysProcesses();
+ }
}
+
+ throw new InvalidOperationException($"Failed to launch PowerToys Settings: Window not found after {maxTries} attempts.");
}
private void TryLaunchCommandPalette(AppiumOptions opts)
@@ -211,7 +254,10 @@ namespace Microsoft.PowerToys.UITest
var process = Process.Start(processStartInfo);
process?.WaitForExit();
- WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10);
+ if (!WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10))
+ {
+ throw new TimeoutException("Failed to find Command Palette window after multiple attempts.");
+ }
}
catch (Exception ex)
{
@@ -219,7 +265,7 @@ namespace Microsoft.PowerToys.UITest
}
}
- private void WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries)
+ private bool WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries)
{
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
@@ -230,18 +276,16 @@ namespace Microsoft.PowerToys.UITest
{
var hexHwnd = window[0].HWnd.ToString("x");
opts.AddAdditionalCapability("appTopLevelWindow", hexHwnd);
- return;
+ return true;
}
if (attempt < maxRetries)
{
Thread.Sleep(delayMs);
}
- else
- {
- throw new TimeoutException($"Failed to find {windowName} window after multiple attempts.");
- }
}
+
+ return false;
}
///
@@ -292,17 +336,17 @@ namespace Microsoft.PowerToys.UITest
catch (Exception ex)
{
// Handle exceptions if needed
- Debug.WriteLine($"Exception during Cleanup: {ex.Message}");
+ Console.WriteLine($"Exception during Cleanup: {ex.Message}");
}
}
///
/// Restarts now exe and takes control of it.
///
- public void RestartScopeExe()
+ public void RestartScopeExe(string? enableModules = null)
{
ExitScopeExe();
- StartExe(locationPath + sessionPath, this.commandLineArgs);
+ StartExe(locationPath + sessionPath, commandLineArgs, enableModules);
}
public WindowsDriver GetRoot()
@@ -327,5 +371,31 @@ namespace Microsoft.PowerToys.UITest
this.ExitExe(winAppDriverProcessInfo.FileName);
SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo);
}
+
+ private void KillPowerToysProcesses()
+ {
+ var powerToysProcessNames = new[] { "PowerToys", "Microsoft.CmdPal.UI" };
+
+ foreach (var processName in powerToysProcessNames)
+ {
+ try
+ {
+ var processes = Process.GetProcessesByName(processName);
+
+ foreach (var process in processes)
+ {
+ process.Kill();
+ process.WaitForExit();
+ }
+
+ // Verify processes are actually gone
+ var remainingProcesses = Process.GetProcessesByName(processName);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[KillPowerToysProcesses] Failed to kill process {processName}: {ex.Message}");
+ }
+ }
+ }
}
}
diff --git a/src/common/UITestAutomation/SettingsConfigHelper.cs b/src/common/UITestAutomation/SettingsConfigHelper.cs
index 0a01891dc4..81e5e3c180 100644
--- a/src/common/UITestAutomation/SettingsConfigHelper.cs
+++ b/src/common/UITestAutomation/SettingsConfigHelper.cs
@@ -26,14 +26,13 @@ namespace Microsoft.PowerToys.UITest
///
/// Configures global PowerToys settings to enable only specified modules and disable all others.
///
- /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled.
- /// Thrown when modulesToEnable is null.
+ /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. If null or empty, all modules will be disabled.
/// Thrown when settings file operations fail.
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")]
- public static void ConfigureGlobalModuleSettings(params string[] modulesToEnable)
+ public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable)
{
- ArgumentNullException.ThrowIfNull(modulesToEnable);
+ modulesToEnable ??= Array.Empty();
try
{
diff --git a/src/common/UITestAutomation/UITestBase.cs b/src/common/UITestAutomation/UITestBase.cs
index 1c72be05f4..877f384104 100644
--- a/src/common/UITestAutomation/UITestBase.cs
+++ b/src/common/UITestAutomation/UITestBase.cs
@@ -29,6 +29,8 @@ namespace Microsoft.PowerToys.UITest
public string? ScreenshotDirectory { get; set; }
+ public string? RecordingDirectory { get; set; }
+
public static MonitorInfoData.ParamsWrapper MonitorInfoData { get; set; } = new MonitorInfoData.ParamsWrapper() { Monitors = new List() };
private readonly PowerToysModule scope;
@@ -36,6 +38,7 @@ namespace Microsoft.PowerToys.UITest
private readonly string[]? commandLineArgs;
private SessionHelper? sessionHelper;
private System.Threading.Timer? screenshotTimer;
+ private ScreenRecording? screenRecording;
public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings, WindowSize size = WindowSize.UnSpecified, string[]? commandLineArgs = null)
{
@@ -65,12 +68,35 @@ namespace Microsoft.PowerToys.UITest
CloseOtherApplications();
if (IsInPipeline)
{
- ScreenshotDirectory = Path.Combine(this.TestContext.TestResultsDirectory ?? string.Empty, "UITestScreenshots_" + Guid.NewGuid().ToString());
+ string baseDirectory = this.TestContext.TestResultsDirectory ?? string.Empty;
+ ScreenshotDirectory = Path.Combine(baseDirectory, "UITestScreenshots_" + Guid.NewGuid().ToString());
Directory.CreateDirectory(ScreenshotDirectory);
+ RecordingDirectory = Path.Combine(baseDirectory, "UITestRecordings_" + Guid.NewGuid().ToString());
+ Directory.CreateDirectory(RecordingDirectory);
+
// Take screenshot every 1 second
screenshotTimer = new System.Threading.Timer(ScreenCapture.TimerCallback, ScreenshotDirectory, TimeSpan.Zero, TimeSpan.FromMilliseconds(1000));
+ // Start screen recording (requires FFmpeg)
+ try
+ {
+ screenRecording = new ScreenRecording(RecordingDirectory);
+ if (screenRecording.IsAvailable)
+ {
+ _ = screenRecording.StartRecordingAsync();
+ }
+ else
+ {
+ screenRecording = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to start screen recording: {ex.Message}");
+ screenRecording = null;
+ }
+
// Escape Popups before starting
System.Windows.Forms.SendKeys.SendWait("{ESC}");
}
@@ -88,15 +114,36 @@ namespace Microsoft.PowerToys.UITest
if (IsInPipeline)
{
screenshotTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- Dispose();
+
+ // Stop screen recording
+ if (screenRecording != null)
+ {
+ try
+ {
+ screenRecording.StopRecordingAsync().GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to stop screen recording: {ex.Message}");
+ }
+ }
+
if (TestContext.CurrentTestOutcome is UnitTestOutcome.Failed
or UnitTestOutcome.Error
or UnitTestOutcome.Unknown)
{
Task.Delay(1000).Wait();
AddScreenShotsToTestResultsDirectory();
+ AddRecordingsToTestResultsDirectory();
AddLogFilesToTestResultsDirectory();
}
+ else
+ {
+ // Clean up recording if test passed
+ CleanupRecordingDirectory();
+ }
+
+ Dispose();
}
this.Session.Cleanup();
@@ -106,6 +153,7 @@ namespace Microsoft.PowerToys.UITest
public void Dispose()
{
screenshotTimer?.Dispose();
+ screenRecording?.Dispose();
GC.SuppressFinalize(this);
}
@@ -600,6 +648,47 @@ namespace Microsoft.PowerToys.UITest
}
}
+ ///
+ /// Adds screen recordings to test results directory when test fails.
+ ///
+ protected void AddRecordingsToTestResultsDirectory()
+ {
+ if (RecordingDirectory != null && Directory.Exists(RecordingDirectory))
+ {
+ // Add video files (MP4)
+ var videoFiles = Directory.GetFiles(RecordingDirectory, "*.mp4");
+ foreach (string file in videoFiles)
+ {
+ this.TestContext.AddResultFile(file);
+ var fileInfo = new FileInfo(file);
+ Console.WriteLine($"Added video recording: {Path.GetFileName(file)} ({fileInfo.Length / 1024 / 1024:F1} MB)");
+ }
+
+ if (videoFiles.Length == 0)
+ {
+ Console.WriteLine("No video recording available (FFmpeg not found). Screenshots are still captured.");
+ }
+ }
+ }
+
+ ///
+ /// Cleans up recording directory when test passes.
+ ///
+ private void CleanupRecordingDirectory()
+ {
+ if (RecordingDirectory != null && Directory.Exists(RecordingDirectory))
+ {
+ try
+ {
+ Directory.Delete(RecordingDirectory, true);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to cleanup recording directory: {ex.Message}");
+ }
+ }
+ }
+
///
/// Copies PowerToys log files to test results directory when test fails.
/// Renames files to include the directory structure after \PowerToys.
@@ -689,11 +778,11 @@ namespace Microsoft.PowerToys.UITest
///
/// Restart scope exe.
///
- public void RestartScopeExe()
+ public Session RestartScopeExe(string? enableModules = null)
{
- this.sessionHelper!.RestartScopeExe();
+ this.sessionHelper!.RestartScopeExe(enableModules);
this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), this.scope, this.size);
- return;
+ return Session;
}
///
diff --git a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs
index 7cad62decb..5f857aa391 100644
--- a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs
+++ b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs
@@ -617,6 +617,8 @@ namespace MouseUtils.UITests
private void LaunchFromSetting(bool reload = false, bool launchAsAdmin = false)
{
+ Session = RestartScopeExe("FindMyMouse,MouseHighlighter,MouseJump,MousePointerCrosshairs,CursorWrap");
+
// this.Session.Attach(PowerToysModule.PowerToysSettings);
this.Session.SetMainWindowSize(WindowSize.Large);
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs
index 855a3e2e6c..5b12f78542 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs
@@ -105,6 +105,7 @@ public sealed partial class SettingsWindow : WindowEx,
"Extensions" => typeof(ExtensionsPage),
_ => null,
};
+
if (pageType is not null)
{
NavFrame.Navigate(pageType);
diff --git a/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs b/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs
index 82e05707e7..117e128734 100644
--- a/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs
+++ b/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using FancyZonesEditor.Models;
@@ -49,19 +50,16 @@ namespace UITests_FancyZones
[TestInitialize]
public void TestInitialize()
{
- // ClearOpenWindows
+ Session.KillAllProcessesByName("PowerToys");
ClearOpenWindows();
- // kill all processes related to FancyZones Editor to ensure a clean state
- Session.KillAllProcessesByName("PowerToys.FancyZonesEditor");
-
AppZoneHistory.DeleteFile();
- this.RestartScopeExe();
FancyZonesEditorHelper.Files.Restore();
-
- // Set a custom layout with 1 subzones and clear app zone history
SetupCustomLayouts();
+ RestartScopeExe("Hosts");
+ Thread.Sleep(2000);
+
// Get the current mouse button setting
nonPrimaryMouseButton = SystemInformation.MouseButtonsSwapped ? "Left" : "Right";
@@ -72,99 +70,6 @@ namespace UITests_FancyZones
LaunchFancyZones();
}
- ///
- /// Test Use Shift key to activate zones while dragging a window in FancyZones Zone Behaviour Settings
- ///
- /// -
- /// Verifies that holding Shift while dragging shows all zones as expected.
- ///
- ///
- ///
- [TestMethod("FancyZones.Settings.TestShowZonesOnShiftDuringDrag")]
- [TestCategory("FancyZones_Dragging #1")]
- public void TestShowZonesOnShiftDuringDrag()
- {
- string testCaseName = nameof(TestShowZonesOnShiftDuringDrag);
- Pane dragElement = Find(By.Name("Non Client Input Sink Window")); // element to drag
- var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY);
-
- var (initialColor, withShiftColor) = RunDragInteractions(
- preAction: () =>
- {
- dragElement.DragAndHold(offSet.Dx, offSet.Dy);
- },
- postAction: () =>
- {
- Session.PressKey(Key.Shift);
- Task.Delay(500).Wait();
- },
- releaseAction: () =>
- {
- Session.ReleaseKey(Key.Shift);
- Task.Delay(5000).Wait(); // Optional: Wait for a moment to ensure window switch
- },
- testCaseName: testCaseName);
-
- string zoneColorWithoutShift = GetOutWindowPixelColor(30);
-
- Assert.AreNotEqual(initialColor, withShiftColor, $"[{testCaseName}] Zone display failed.");
- Assert.IsTrue(
- withShiftColor == inactivateColor || withShiftColor == highlightColor,
- $"[{testCaseName}] Zone display failed: withShiftColor was {withShiftColor}, expected {inactivateColor} or {highlightColor}.");
- Assert.AreEqual(inactivateColor, withShiftColor, $"[{testCaseName}] Zone display failed.");
-
- Assert.AreEqual(zoneColorWithoutShift, initialColor, $"[{testCaseName}] Zone deactivated failed.");
- dragElement.ReleaseDrag();
-
- Clean();
- }
-
- ///
- /// Test dragging a window during Shift key press in FancyZones Zone Behaviour Settings
- ///
- /// -
- /// Verifies that dragging activates zones as expected.
- ///
- ///
- ///
- [TestMethod("FancyZones.Settings.TestShowZonesOnDragDuringShift")]
- [TestCategory("FancyZones_Dragging #2")]
- public void TestShowZonesOnDragDuringShift()
- {
- string testCaseName = nameof(TestShowZonesOnDragDuringShift);
-
- var dragElement = Find(By.Name("Non Client Input Sink Window"));
- var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY);
-
- var (initialColor, withDragColor) = RunDragInteractions(
- preAction: () =>
- {
- dragElement.Drag(offSet.Dx, offSet.Dy);
- Session.PressKey(Key.Shift);
- },
- postAction: () =>
- {
- dragElement.DragAndHold(0, 0);
- Task.Delay(5000).Wait();
- },
- releaseAction: () =>
- {
- dragElement.ReleaseDrag();
- Session.ReleaseKey(Key.Shift);
- },
- testCaseName: testCaseName);
-
- Assert.AreNotEqual(initialColor, withDragColor, $"[{testCaseName}] Zone color did not change; zone activation failed.");
- Assert.AreEqual(highlightColor, withDragColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed.");
-
- // double check by app-zone-history.json
- string appZoneHistoryJson = AppZoneHistory.GetData();
- string? zoneNumber = ZoneSwitchHelper.GetZoneIndexSetByAppName(powertoysWindowName, appZoneHistoryJson);
- Assert.IsNull(zoneNumber, $"[{testCaseName}] AppZoneHistory layout was unexpectedly set.");
-
- Clean();
- }
-
///
/// Test toggling zones using a non-primary mouse click during window dragging.
///
@@ -178,14 +83,19 @@ namespace UITests_FancyZones
public void TestToggleZonesWithNonPrimaryMouseClick()
{
string testCaseName = nameof(TestToggleZonesWithNonPrimaryMouseClick);
- var dragElement = Find(By.Name("Non Client Input Sink Window"));
- var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY);
+
+ var windowRect = Session.GetMainWindowRect();
+ int startX = windowRect.Left + 70;
+ int startY = windowRect.Top + 25;
+ int endX = startX + 300;
+ int endY = startY + 300;
var (initialColor, withMouseColor) = RunDragInteractions(
preAction: () =>
{
- // activate zone
- dragElement.DragAndHold(offSet.Dx, offSet.Dy);
+ Session.MoveMouseTo(startX, startY);
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.MoveMouseTo(endX, endY);
},
postAction: () =>
{
@@ -195,7 +105,7 @@ namespace UITests_FancyZones
},
releaseAction: () =>
{
- dragElement.ReleaseDrag();
+ Session.PerformMouseAction(MouseActionType.LeftUp);
},
testCaseName: testCaseName);
@@ -204,8 +114,6 @@ namespace UITests_FancyZones
// check the zone color is activated
Assert.AreEqual(highlightColor, initialColor, $"[{testCaseName}] Zone activation failed.");
-
- Clean();
}
///
@@ -221,32 +129,35 @@ namespace UITests_FancyZones
public void TestShowZonesWhenShiftAndMouseOff()
{
string testCaseName = nameof(TestShowZonesWhenShiftAndMouseOff);
- Pane dragElement = Find(By.Name("Non Client Input Sink Window"));
- var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY);
+
+ var windowRect = Session.GetMainWindowRect();
+ int startX = windowRect.Left + 70;
+ int startY = windowRect.Top + 25;
+ int endX = startX + 300;
+ int endY = startY + 300;
var (initialColor, withShiftColor) = RunDragInteractions(
preAction: () =>
{
- // activate zone
- dragElement.DragAndHold(offSet.Dx, offSet.Dy);
+ Session.MoveMouseTo(startX, startY);
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.MoveMouseTo(endX, endY);
},
postAction: () =>
{
// press Shift Key to deactivate zones
Session.PressKey(Key.Shift);
- Task.Delay(500).Wait();
+ Task.Delay(1000).Wait();
},
releaseAction: () =>
{
- dragElement.ReleaseDrag();
+ Session.PerformMouseAction(MouseActionType.LeftUp);
Session.ReleaseKey(Key.Shift);
},
testCaseName: testCaseName);
Assert.AreEqual(highlightColor, initialColor, $"[{testCaseName}] Zone activation failed.");
Assert.AreNotEqual(highlightColor, withShiftColor, $"[{testCaseName}] Zone deactivation failed.");
-
- Clean();
}
///
@@ -263,12 +174,17 @@ namespace UITests_FancyZones
{
string testCaseName = nameof(TestShowZonesWhenShiftAndMouseOn);
- var dragElement = Find(By.Name("Non Client Input Sink Window"));
- var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY);
+ var windowRect = Session.GetMainWindowRect();
+ int startX = windowRect.Left + 70;
+ int startY = windowRect.Top + 25;
+ int endX = startX + 300;
+ int endY = startY + 300;
var (initialColor, withShiftColor) = RunDragInteractions(
preAction: () =>
{
- dragElement.DragAndHold(offSet.Dx, offSet.Dy);
+ Session.MoveMouseTo(startX, startY);
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.MoveMouseTo(endX, endY);
},
postAction: () =>
{
@@ -279,7 +195,7 @@ namespace UITests_FancyZones
},
testCaseName: testCaseName);
- Assert.AreEqual(inactivateColor, withShiftColor, $"[{testCaseName}] show zone failed.");
+ Assert.AreEqual(highlightColor, withShiftColor, $"[{testCaseName}] show zone failed.");
Session.PerformMouseAction(
nonPrimaryMouseButton == "Right" ? MouseActionType.RightClick : MouseActionType.LeftClick);
@@ -288,9 +204,7 @@ namespace UITests_FancyZones
Assert.AreEqual(initialColor, zoneColorWithMouse, $"[{nameof(TestShowZonesWhenShiftAndMouseOff)}] Zone deactivate failed.");
Session.ReleaseKey(Key.Shift);
- dragElement.ReleaseDrag();
-
- Clean();
+ Session.PerformMouseAction(MouseActionType.LeftUp);
}
///
@@ -307,8 +221,6 @@ namespace UITests_FancyZones
{
var pixel = GetPixelWhenMakeDraggedWindow();
Assert.AreNotEqual(pixel.PixelInWindow, pixel.TransPixel, $"[{nameof(TestMakeDraggedWindowTransparentOn)}] Window transparency failed.");
-
- Clean();
}
///
@@ -325,14 +237,103 @@ namespace UITests_FancyZones
{
var pixel = GetPixelWhenMakeDraggedWindow();
Assert.AreEqual(pixel.PixelInWindow, pixel.TransPixel, $"[{nameof(TestMakeDraggedWindowTransparentOff)}] Window without transparency failed.");
-
- Clean();
}
- private void Clean()
+ ///
+ /// Test Use Shift key to activate zones while dragging a window in FancyZones Zone Behaviour Settings
+ ///
+ /// -
+ /// Verifies that holding Shift while dragging shows all zones as expected.
+ ///
+ ///
+ ///
+ [TestMethod("FancyZones.Settings.TestShowZonesOnShiftDuringDrag")]
+ [TestCategory("FancyZones_Dragging #1")]
+ public void TestShowZonesOnShiftDuringDrag()
{
- // clean app zone history file
- AppZoneHistory.DeleteFile();
+ string testCaseName = nameof(TestShowZonesOnShiftDuringDrag);
+
+ var windowRect = Session.GetMainWindowRect();
+ int startX = windowRect.Left + 70;
+ int startY = windowRect.Top + 25;
+ int endX = startX + 300;
+ int endY = startY + 300;
+
+ var (initialColor, withShiftColor) = RunDragInteractions(
+ preAction: () =>
+ {
+ Session.MoveMouseTo(startX, startY);
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.MoveMouseTo(endX, endY);
+ },
+ postAction: () =>
+ {
+ Session.PressKey(Key.Shift);
+ Task.Delay(500).Wait();
+ },
+ releaseAction: () =>
+ {
+ Session.ReleaseKey(Key.Shift);
+ Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure window switch
+ },
+ testCaseName: testCaseName);
+
+ string zoneColorWithoutShift = GetOutWindowPixelColor(30);
+
+ Assert.AreNotEqual(initialColor, withShiftColor, $"[{testCaseName}] Zone color did not change; zone activation failed.");
+ Assert.AreEqual(highlightColor, withShiftColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed.");
+
+ Session.PerformMouseAction(MouseActionType.LeftUp);
+ }
+
+ ///
+ /// Test dragging a window during Shift key press in FancyZones Zone Behaviour Settings
+ ///
+ /// -
+ /// Verifies that dragging activates zones as expected.
+ ///
+ ///
+ ///
+ [TestMethod("FancyZones.Settings.TestShowZonesOnDragDuringShift")]
+ [TestCategory("FancyZones_Dragging #2")]
+ public void TestShowZonesOnDragDuringShift()
+ {
+ string testCaseName = nameof(TestShowZonesOnDragDuringShift);
+
+ var windowRect = Session.GetMainWindowRect();
+ int startX = windowRect.Left + 70;
+ int startY = windowRect.Top + 25;
+ int endX = startX + 300;
+ int endY = startY + 300;
+
+ var (initialColor, withDragColor) = RunDragInteractions(
+ preAction: () =>
+ {
+ Session.PressKey(Key.Shift);
+ Task.Delay(100).Wait();
+ },
+ postAction: () =>
+ {
+ Session.MoveMouseTo(startX, startY);
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.MoveMouseTo(endX, endY);
+ Task.Delay(1000).Wait();
+ },
+ releaseAction: () =>
+ {
+ Session.PerformMouseAction(MouseActionType.LeftUp);
+ Session.ReleaseKey(Key.Shift);
+ Task.Delay(100).Wait();
+ },
+ testCaseName: testCaseName);
+
+ Assert.AreNotEqual(initialColor, withDragColor, $"[{testCaseName}] Zone color did not change; zone activation failed.");
+ Assert.AreEqual(highlightColor, withDragColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed.");
+
+ // double check by app-zone-history.json
+ string appZoneHistoryJson = AppZoneHistory.GetData();
+ string? zoneNumber = ZoneSwitchHelper.GetZoneIndexSetByAppName(powertoysWindowName, appZoneHistoryJson);
+ Assert.IsNull(zoneNumber, $"[{testCaseName}] AppZoneHistory layout was unexpectedly set.");
}
// Helper method to ensure the desktop has no open windows by clicking the "Show Desktop" button
@@ -352,7 +353,7 @@ namespace UITests_FancyZones
desktopButtonName = "Show Desktop";
}
- this.Find(By.Name(desktopButtonName), 5000, true).Click(false, 500, 2000);
+ this.Find(By.Name(desktopButtonName), 5000, true).Click(false, 500, 1000);
}
// Setup custom layout with 1 subzones
@@ -382,6 +383,11 @@ namespace UITests_FancyZones
this.Scroll(6, "Down"); // Pull the settings page up to make sure the settings are visible
ZoneBehaviourSettings(TestContext.TestName);
+ // Go back and forth to make sure settings applied
+ this.Find("Workspaces").Click();
+ Task.Delay(200).Wait();
+ this.Find("FancyZones").Click();
+
this.Find(By.AccessibilityId("LaunchLayoutEditorButton")).Click(false, 500, 10000);
this.Session.Attach(PowerToysModule.FancyZone);
@@ -435,22 +441,26 @@ namespace UITests_FancyZones
// Get the mouse color of the pixel when make dragged window
private (string PixelInWindow, string TransPixel) GetPixelWhenMakeDraggedWindow()
{
- var dragElement = Find(By.Name("Non Client Input Sink Window"));
+ var windowRect = Session.GetMainWindowRect();
+ int startX = windowRect.Left + 70;
+ int startY = windowRect.Top + 25;
+ int endX = startX + 100;
+ int endY = startY + 100;
- // maximize the window to make sure get pixel color more accurate
- dragElement.DoubleClick();
+ Session.MoveMouseTo(startX, startY);
- var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY);
+ // Session.PerformMouseAction(MouseActionType.LeftDoubleClick);
Session.PressKey(Key.Shift);
- dragElement.DragAndHold(offSet.Dx, offSet.Dy);
- Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure the window is in position
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.MoveMouseTo(endX, endY);
+
Tuple pos = GetMousePosition();
string pixelInWindow = this.GetPixelColorString(pos.Item1, pos.Item2);
Session.ReleaseKey(Key.Shift);
- Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure the window is in position
+ Task.Delay(1000).Wait();
string transPixel = this.GetPixelColorString(pos.Item1, pos.Item2);
- dragElement.ReleaseDrag();
+ Session.PerformMouseAction(MouseActionType.LeftUp);
return (pixelInWindow, transPixel);
}
diff --git a/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs b/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs
index d1712d3e2d..a145dde718 100644
--- a/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs
+++ b/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs
@@ -271,7 +271,7 @@ namespace UITests_FancyZones
};
FancyZonesEditorHelper.Files.AppliedLayoutsIOHelper.WriteData(appliedLayouts.Serialize(appliedLayoutsWrapper));
- this.RestartScopeExe();
+ RestartScopeExe("Hosts");
}
[TestMethod("FancyZones.Settings.TestApplyHotKey")]
@@ -598,10 +598,12 @@ namespace UITests_FancyZones
this.TryReaction();
int tries = 24;
Pull(tries, "down"); // Pull the setting page up to make sure the setting is visible
- this.Find("Enable quick layout switch").Toggle(flag);
+ this.Find("FancyZonesQuickLayoutSwitch").Toggle(flag);
- tries = 24;
- Pull(tries, "up");
+ // Go back and forth to make sure settings applied
+ this.Find("Workspaces").Click();
+ Task.Delay(200).Wait();
+ this.Find("FancyZones").Click();
}
private void TryReaction()
diff --git a/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs b/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs
index 70d6935702..68989a4054 100644
--- a/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs
+++ b/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs
@@ -34,7 +34,7 @@ namespace UITests_FancyZones
Session.KillAllProcessesByName("PowerToys.FancyZonesEditor");
AppZoneHistory.DeleteFile();
- this.RestartScopeExe();
+ RestartScopeExe("Hosts");
FancyZonesEditorHelper.Files.Restore();
// Set a custom layout with 1 subzones and clear app zone history
@@ -137,7 +137,7 @@ namespace UITests_FancyZones
Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch
activeWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle();
- Assert.AreNotEqual(preWindow, activeWindowTitle);
+ Assert.AreEqual(postWindow, activeWindowTitle);
Clean(); // close the windows
}
@@ -151,9 +151,23 @@ namespace UITests_FancyZones
var rect = Session.GetMainWindowRect();
var (targetX, targetY) = ZoneSwitchHelper.GetScreenMargins(rect, 4);
- var offSet = ZoneSwitchHelper.GetOffset(hostsView, targetX, targetY);
- DragWithShift(hostsView, offSet);
+ // Snap first window (Hosts) to left zone using shift+drag with direct mouse movement
+ var hostsRect = hostsView.Rect ?? throw new InvalidOperationException("Failed to get hosts window rect");
+ int hostsStartX = hostsRect.Left + 70;
+ int hostsStartY = hostsRect.Top + 25;
+
+ // For a 2-column layout, left zone is at approximately 1/4 of screen width
+ int hostsEndX = rect.Left + (3 * (rect.Right - rect.Left) / 4);
+ int hostsEndY = rect.Top + ((rect.Bottom - rect.Top) / 2);
+
+ Session.MoveMouseTo(hostsStartX, hostsStartY);
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.PressKey(Key.Shift);
+ Session.MoveMouseTo(hostsEndX, hostsEndY);
+ Session.PerformMouseAction(MouseActionType.LeftUp);
+ Session.ReleaseKey(Key.Shift);
+ Task.Delay(500).Wait(); // Wait for snap to complete
string preWindow = ZoneSwitchHelper.GetActiveWindowTitle();
@@ -163,11 +177,26 @@ namespace UITests_FancyZones
Pane settingsView = Find(By.Name("Non Client Input Sink Window"));
settingsView.DoubleClick(); // maximize the window
- DragWithShift(settingsView, offSet);
+ var windowRect = Session.GetMainWindowRect();
+ var settingsRect = settingsView.Rect ?? throw new InvalidOperationException("Failed to get settings window rect");
+ int settingsStartX = settingsRect.Left + 70;
+ int settingsStartY = settingsRect.Top + 25;
+
+ // For a 2-column layout, right zone is at approximately 3/4 of screen width
+ int settingsEndX = windowRect.Left + (3 * (windowRect.Right - windowRect.Left) / 4);
+ int settingsEndY = windowRect.Top + ((windowRect.Bottom - windowRect.Top) / 2);
+
+ Session.MoveMouseTo(settingsStartX, settingsStartY);
+ Session.PerformMouseAction(MouseActionType.LeftDown);
+ Session.PressKey(Key.Shift);
+ Session.MoveMouseTo(settingsEndX, settingsEndY);
+ Session.PerformMouseAction(MouseActionType.LeftUp);
+ Session.ReleaseKey(Key.Shift);
+ Task.Delay(500).Wait(); // Wait for snap to complete
string appZoneHistoryJson = AppZoneHistory.GetData();
- string? zoneIndexOfFileWindow = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Hosts.exe", appZoneHistoryJson); // explorer.exe
+ string? zoneIndexOfFileWindow = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Hosts.exe", appZoneHistoryJson);
string? zoneIndexOfPowertoys = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Settings.exe", appZoneHistoryJson);
// check the AppZoneHistory layout is set and in the same zone
@@ -176,16 +205,6 @@ namespace UITests_FancyZones
return (preWindow, powertoysWindowName);
}
- private void DragWithShift(Pane settingsView, (int Dx, int Dy) offSet)
- {
- Session.PressKey(Key.Shift);
- settingsView.DragAndHold(offSet.Dx, offSet.Dy);
- Task.Delay(1000).Wait(); // Wait for drag to start (optional)
- settingsView.ReleaseDrag();
- Task.Delay(1000).Wait(); // Wait after drag (optional)
- Session.ReleaseKey(Key.Shift);
- }
-
private static readonly CustomLayouts.CustomLayoutListWrapper CustomLayoutsList = new CustomLayouts.CustomLayoutListWrapper
{
CustomLayouts = new List
@@ -253,11 +272,14 @@ namespace UITests_FancyZones
this.Scroll(9, "Down"); // Pull the setting page up to make sure the setting is visible
bool switchWindowEnable = TestContext.TestName == "TestSwitchShortCutDisable" ? false : true;
- this.Find("Switch between windows in the current zone").Toggle(switchWindowEnable);
+ this.Find("FancyZonesWindowSwitchingToggle").Toggle(switchWindowEnable);
- Task.Delay(500).Wait(); // Wait for the setting to be applied
- this.Scroll(9, "Up"); // Pull the setting page down to make sure the setting is visible
- this.Find