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 image --- .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! image image Matching Windows accent color and tint: image ## 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +