diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt
index c655bb1b55..a7d02dcb21 100644
--- a/.github/actions/spell-check/allow/code.txt
+++ b/.github/actions/spell-check/allow/code.txt
@@ -335,3 +335,7 @@ azp
feedbackhub
needinfo
reportbug
+
+#ffmpeg
+crf
+nostdin
diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index d4be728886..f839c5976c 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -141,8 +141,11 @@ BITSPIXEL
bla
BLACKFRAME
BLENDFUNCTION
+blittable
Blockquotes
blt
+bluelightreduction
+bluelightreductionstate
BLURBEHIND
BLURREGION
bmi
@@ -250,6 +253,7 @@ colorformat
colorhistory
colorhistorylimit
COLORKEY
+colorref
comctl
comdlg
comexp
@@ -1113,6 +1117,7 @@ NEWPLUSSHELLEXTENSIONWIN
newrow
nicksnettravels
NIF
+nightlight
NLog
NLSTEXT
NMAKE
@@ -1483,6 +1488,7 @@ rgh
rgn
rgs
rguid
+rhk
RIDEV
RIGHTSCROLLBAR
riid
@@ -1588,6 +1594,7 @@ SHGDNF
SHGFI
SHIL
shinfo
+shk
shlwapi
shobjidl
SHORTCUTATLEAST
@@ -1858,8 +1865,10 @@ Uniquifies
unitconverter
unittests
UNLEN
+Uninitializes
UNORM
unremapped
+Unsubscribes
unvirtualized
unwide
unzoom
diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index 83289fa102..e3ebffc20c 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -60,6 +60,8 @@
"PowerToys.FancyZonesEditorCommon.dll",
"PowerToys.FancyZonesModuleInterface.dll",
"PowerToys.FancyZones.exe",
+ "FancyZonesCLI.exe",
+ "FancyZonesCLI.dll",
"PowerToys.GcodePreviewHandler.dll",
"PowerToys.GcodePreviewHandler.exe",
@@ -351,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/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
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/PowerToys.slnx b/PowerToys.slnx
index c946514fb5..1884b2d58b 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -370,6 +370,10 @@
+
+
+
+
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/common/UITestAutomation/ScreenRecording.cs b/src/common/UITestAutomation/ScreenRecording.cs
new file mode 100644
index 0000000000..57e844936d
--- /dev/null
+++ b/src/common/UITestAutomation/ScreenRecording.cs
@@ -0,0 +1,399 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.PowerToys.UITest
+{
+ ///
+ /// Provides methods for recording the screen during UI tests.
+ /// Requires FFmpeg to be installed and available in PATH.
+ ///
+ internal class ScreenRecording : IDisposable
+ {
+ private readonly string outputDirectory;
+ private readonly string framesDirectory;
+ private readonly string outputFilePath;
+ private readonly List capturedFrames;
+ private readonly SemaphoreSlim recordingLock = new(1, 1);
+ private readonly Stopwatch recordingStopwatch = new();
+ private readonly string? ffmpegPath;
+ private CancellationTokenSource? recordingCancellation;
+ private Task? recordingTask;
+ private bool isRecording;
+ private int frameCount;
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetDC(IntPtr hWnd);
+
+ [DllImport("gdi32.dll")]
+ private static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC);
+
+ [DllImport("user32.dll")]
+ private static extern bool GetCursorInfo(out ScreenCapture.CURSORINFO pci);
+
+ [DllImport("user32.dll")]
+ private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags);
+
+ private const int CURSORSHOWING = 0x00000001;
+ private const int DESKTOPHORZRES = 118;
+ private const int DESKTOPVERTRES = 117;
+ private const int DINORMAL = 0x0003;
+ private const int TargetFps = 15; // 15 FPS for good balance of quality and size
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Directory where the recording will be saved.
+ public ScreenRecording(string outputDirectory)
+ {
+ this.outputDirectory = outputDirectory;
+ string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ framesDirectory = Path.Combine(outputDirectory, $"frames_{timestamp}");
+ outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4");
+ capturedFrames = new List();
+ frameCount = 0;
+
+ // Check if FFmpeg is available
+ ffmpegPath = FindFfmpeg();
+ if (ffmpegPath == null)
+ {
+ Console.WriteLine("FFmpeg not found. Screen recording will be disabled.");
+ Console.WriteLine("To enable video recording, install FFmpeg: https://ffmpeg.org/download.html");
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether screen recording is available (FFmpeg found).
+ ///
+ public bool IsAvailable => ffmpegPath != null;
+
+ ///
+ /// Starts recording the screen.
+ ///
+ /// A task representing the asynchronous operation.
+ public async Task StartRecordingAsync()
+ {
+ await recordingLock.WaitAsync();
+ try
+ {
+ if (isRecording || !IsAvailable)
+ {
+ return;
+ }
+
+ // Create frames directory
+ Directory.CreateDirectory(framesDirectory);
+
+ recordingCancellation = new CancellationTokenSource();
+ isRecording = true;
+ recordingStopwatch.Start();
+
+ // Start the recording task
+ recordingTask = Task.Run(() => RecordFrames(recordingCancellation.Token));
+
+ Console.WriteLine($"Started screen recording at {TargetFps} FPS");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to start recording: {ex.Message}");
+ isRecording = false;
+ }
+ finally
+ {
+ recordingLock.Release();
+ }
+ }
+
+ ///
+ /// Stops recording and encodes video.
+ ///
+ /// A task representing the asynchronous operation.
+ public async Task StopRecordingAsync()
+ {
+ await recordingLock.WaitAsync();
+ try
+ {
+ if (!isRecording || recordingCancellation == null)
+ {
+ return;
+ }
+
+ // Signal cancellation
+ recordingCancellation.Cancel();
+
+ // Wait for recording task to complete
+ if (recordingTask != null)
+ {
+ await recordingTask;
+ }
+
+ recordingStopwatch.Stop();
+ isRecording = false;
+
+ double duration = recordingStopwatch.Elapsed.TotalSeconds;
+ Console.WriteLine($"Recording stopped. Captured {capturedFrames.Count} frames in {duration:F2} seconds");
+
+ // Encode to video
+ await EncodeToVideoAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error stopping recording: {ex.Message}");
+ }
+ finally
+ {
+ Cleanup();
+ recordingLock.Release();
+ }
+ }
+
+ ///
+ /// Records frames from the screen.
+ ///
+ private void RecordFrames(CancellationToken cancellationToken)
+ {
+ try
+ {
+ int frameInterval = 1000 / TargetFps;
+ var frameTimer = Stopwatch.StartNew();
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var frameStart = frameTimer.ElapsedMilliseconds;
+
+ try
+ {
+ CaptureFrame();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error capturing frame: {ex.Message}");
+ }
+
+ // Sleep for remaining time to maintain target FPS
+ var frameTime = frameTimer.ElapsedMilliseconds - frameStart;
+ var sleepTime = Math.Max(0, frameInterval - (int)frameTime);
+
+ if (sleepTime > 0)
+ {
+ Thread.Sleep(sleepTime);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when stopping
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error during recording: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Captures a single frame.
+ ///
+ private void CaptureFrame()
+ {
+ IntPtr hdc = GetDC(IntPtr.Zero);
+ int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES);
+ int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES);
+ ReleaseDC(IntPtr.Zero, hdc);
+
+ Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight);
+ using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb))
+ {
+ using (Graphics g = Graphics.FromImage(bitmap))
+ {
+ g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size);
+
+ ScreenCapture.CURSORINFO cursorInfo;
+ cursorInfo.CbSize = Marshal.SizeOf();
+ if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING)
+ {
+ IntPtr hdcDest = g.GetHdc();
+ DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL);
+ g.ReleaseHdc(hdcDest);
+ }
+ }
+
+ string framePath = Path.Combine(framesDirectory, $"frame_{frameCount:D6}.jpg");
+ bitmap.Save(framePath, ImageFormat.Jpeg);
+ capturedFrames.Add(framePath);
+ frameCount++;
+ }
+ }
+
+ ///
+ /// Encodes captured frames to video using ffmpeg.
+ ///
+ private async Task EncodeToVideoAsync()
+ {
+ if (capturedFrames.Count == 0)
+ {
+ Console.WriteLine("No frames captured");
+ return;
+ }
+
+ try
+ {
+ // Build ffmpeg command with proper non-interactive flags
+ string inputPattern = Path.Combine(framesDirectory, "frame_%06d.jpg");
+
+ // -y: overwrite without asking
+ // -nostdin: disable interaction
+ // -loglevel error: only show errors
+ // -stats: show encoding progress
+ string args = $"-y -nostdin -loglevel error -stats -framerate {TargetFps} -i \"{inputPattern}\" -c:v libx264 -pix_fmt yuv420p -crf 23 \"{outputFilePath}\"";
+
+ Console.WriteLine($"Encoding {capturedFrames.Count} frames to video...");
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = ffmpegPath!,
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = true, // Important: redirect stdin to prevent hanging
+ CreateNoWindow = true,
+ };
+
+ using var process = Process.Start(startInfo);
+ if (process != null)
+ {
+ // Close stdin immediately to ensure FFmpeg doesn't wait for input
+ process.StandardInput.Close();
+
+ // Read output streams asynchronously to prevent deadlock
+ var outputTask = process.StandardOutput.ReadToEndAsync();
+ var errorTask = process.StandardError.ReadToEndAsync();
+
+ // Wait for process to exit
+ await process.WaitForExitAsync();
+
+ // Get the output
+ string stdout = await outputTask;
+ string stderr = await errorTask;
+
+ if (process.ExitCode == 0 && File.Exists(outputFilePath))
+ {
+ var fileInfo = new FileInfo(outputFilePath);
+ Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024 / 1024:F1} MB)");
+ }
+ else
+ {
+ Console.WriteLine($"FFmpeg encoding failed with exit code {process.ExitCode}");
+ if (!string.IsNullOrWhiteSpace(stderr))
+ {
+ Console.WriteLine($"FFmpeg error: {stderr}");
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error encoding video: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Finds ffmpeg executable.
+ ///
+ private static string? FindFfmpeg()
+ {
+ // Check if ffmpeg is in PATH
+ var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty();
+
+ foreach (var dir in pathDirs)
+ {
+ var ffmpegPath = Path.Combine(dir, "ffmpeg.exe");
+ if (File.Exists(ffmpegPath))
+ {
+ return ffmpegPath;
+ }
+ }
+
+ // Check common installation locations
+ var commonPaths = new[]
+ {
+ @"C:\.tools\ffmpeg\bin\ffmpeg.exe",
+ @"C:\ffmpeg\bin\ffmpeg.exe",
+ @"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
+ @"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
+ @$"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\WinGet\Links\ffmpeg.exe",
+ };
+
+ foreach (var path in commonPaths)
+ {
+ if (File.Exists(path))
+ {
+ return path;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the path to the recorded video file.
+ ///
+ public string OutputFilePath => outputFilePath;
+
+ ///
+ /// Gets the directory containing recordings.
+ ///
+ public string OutputDirectory => outputDirectory;
+
+ ///
+ /// Cleans up resources.
+ ///
+ private void Cleanup()
+ {
+ recordingCancellation?.Dispose();
+ recordingCancellation = null;
+ recordingTask = null;
+
+ // Clean up frames directory if it exists
+ try
+ {
+ if (Directory.Exists(framesDirectory))
+ {
+ Directory.Delete(framesDirectory, true);
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to cleanup frames directory: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Disposes resources.
+ ///
+ public void Dispose()
+ {
+ if (isRecording)
+ {
+ StopRecordingAsync().GetAwaiter().GetResult();
+ }
+
+ Cleanup();
+ recordingLock.Dispose();
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/src/common/UITestAutomation/SessionHelper.cs b/src/common/UITestAutomation/SessionHelper.cs
index 0ca3eb3ddd..fef220a647 100644
--- a/src/common/UITestAutomation/SessionHelper.cs
+++ b/src/common/UITestAutomation/SessionHelper.cs
@@ -130,9 +130,13 @@ namespace Microsoft.PowerToys.UITest
///
/// The path to the application executable.
/// Optional command line arguments to pass to the application.
- public void StartExe(string appPath, string[]? args = null)
+ public void StartExe(string appPath, string[]? args = null, string? enableModules = null)
{
var opts = new AppiumOptions();
+ if (!string.IsNullOrEmpty(enableModules))
+ {
+ opts.AddAdditionalCapability("enableModules", enableModules);
+ }
if (scope == PowerToysModule.PowerToysSettings)
{
@@ -169,27 +173,66 @@ namespace Microsoft.PowerToys.UITest
private void TryLaunchPowerToysSettings(AppiumOptions opts)
{
- try
+ if (opts.ToCapabilities().HasCapability("enableModules"))
{
- var runnerProcessInfo = new ProcessStartInfo
+ var modulesString = (string)opts.ToCapabilities().GetCapability("enableModules");
+ var modulesArray = modulesString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ SettingsConfigHelper.ConfigureGlobalModuleSettings(modulesArray);
+ }
+ else
+ {
+ SettingsConfigHelper.ConfigureGlobalModuleSettings();
+ }
+
+ const int maxTries = 3;
+ const int delayMs = 5000;
+ const int maxRetries = 3;
+
+ for (int tryCount = 1; tryCount <= maxTries; tryCount++)
+ {
+ try
{
- FileName = locationPath + runnerPath,
- Verb = "runas",
- Arguments = "--open-settings",
- };
+ var runnerProcessInfo = new ProcessStartInfo
+ {
+ FileName = locationPath + runnerPath,
+ Verb = "runas",
+ Arguments = "--open-settings",
+ };
- ExitExe(runnerProcessInfo.FileName);
- runner = Process.Start(runnerProcessInfo);
+ ExitExe(runnerProcessInfo.FileName);
- WaitForWindowAndSetCapability(opts, "PowerToys Settings", 5000, 5);
+ // Verify process was killed
+ string exeName = Path.GetFileNameWithoutExtension(runnerProcessInfo.FileName);
+ var remainingProcesses = Process.GetProcessesByName(exeName);
- // Exit CmdPal UI before launching new process if use installer for test
- ExitExeByName("Microsoft.CmdPal.UI");
- }
- catch (Exception ex)
- {
- throw new InvalidOperationException($"Failed to launch PowerToys Settings: {ex.Message}", ex);
+ runner = Process.Start(runnerProcessInfo);
+
+ if (WaitForWindowAndSetCapability(opts, "PowerToys Settings", delayMs, maxRetries))
+ {
+ // Exit CmdPal UI before launching new process if use installer for test
+ ExitExeByName("Microsoft.CmdPal.UI");
+ return;
+ }
+
+ // Window not found, kill all PowerToys processes and retry
+ if (tryCount < maxTries)
+ {
+ KillPowerToysProcesses();
+ }
+ }
+ catch (Exception ex)
+ {
+ if (tryCount == maxTries)
+ {
+ throw new InvalidOperationException($"Failed to launch PowerToys Settings after {maxTries} attempts: {ex.Message}", ex);
+ }
+
+ // Kill processes and retry
+ KillPowerToysProcesses();
+ }
}
+
+ throw new InvalidOperationException($"Failed to launch PowerToys Settings: Window not found after {maxTries} attempts.");
}
private void TryLaunchCommandPalette(AppiumOptions opts)
@@ -211,7 +254,10 @@ namespace Microsoft.PowerToys.UITest
var process = Process.Start(processStartInfo);
process?.WaitForExit();
- WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10);
+ if (!WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10))
+ {
+ throw new TimeoutException("Failed to find Command Palette window after multiple attempts.");
+ }
}
catch (Exception ex)
{
@@ -219,7 +265,7 @@ namespace Microsoft.PowerToys.UITest
}
}
- private void WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries)
+ private bool WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries)
{
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
@@ -230,18 +276,16 @@ namespace Microsoft.PowerToys.UITest
{
var hexHwnd = window[0].HWnd.ToString("x");
opts.AddAdditionalCapability("appTopLevelWindow", hexHwnd);
- return;
+ return true;
}
if (attempt < maxRetries)
{
Thread.Sleep(delayMs);
}
- else
- {
- throw new TimeoutException($"Failed to find {windowName} window after multiple attempts.");
- }
}
+
+ return false;
}
///
@@ -292,17 +336,17 @@ namespace Microsoft.PowerToys.UITest
catch (Exception ex)
{
// Handle exceptions if needed
- Debug.WriteLine($"Exception during Cleanup: {ex.Message}");
+ Console.WriteLine($"Exception during Cleanup: {ex.Message}");
}
}
///
/// Restarts now exe and takes control of it.
///
- public void RestartScopeExe()
+ public void RestartScopeExe(string? enableModules = null)
{
ExitScopeExe();
- StartExe(locationPath + sessionPath, this.commandLineArgs);
+ StartExe(locationPath + sessionPath, commandLineArgs, enableModules);
}
public WindowsDriver GetRoot()
@@ -327,5 +371,31 @@ namespace Microsoft.PowerToys.UITest
this.ExitExe(winAppDriverProcessInfo.FileName);
SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo);
}
+
+ private void KillPowerToysProcesses()
+ {
+ var powerToysProcessNames = new[] { "PowerToys", "Microsoft.CmdPal.UI" };
+
+ foreach (var processName in powerToysProcessNames)
+ {
+ try
+ {
+ var processes = Process.GetProcessesByName(processName);
+
+ foreach (var process in processes)
+ {
+ process.Kill();
+ process.WaitForExit();
+ }
+
+ // Verify processes are actually gone
+ var remainingProcesses = Process.GetProcessesByName(processName);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[KillPowerToysProcesses] Failed to kill process {processName}: {ex.Message}");
+ }
+ }
+ }
}
}
diff --git a/src/common/UITestAutomation/SettingsConfigHelper.cs b/src/common/UITestAutomation/SettingsConfigHelper.cs
index 0a01891dc4..81e5e3c180 100644
--- a/src/common/UITestAutomation/SettingsConfigHelper.cs
+++ b/src/common/UITestAutomation/SettingsConfigHelper.cs
@@ -26,14 +26,13 @@ namespace Microsoft.PowerToys.UITest
///
/// Configures global PowerToys settings to enable only specified modules and disable all others.
///
- /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled.
- /// Thrown when modulesToEnable is null.
+ /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. If null or empty, all modules will be disabled.
/// Thrown when settings file operations fail.
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")]
- public static void ConfigureGlobalModuleSettings(params string[] modulesToEnable)
+ public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable)
{
- ArgumentNullException.ThrowIfNull(modulesToEnable);
+ modulesToEnable ??= Array.Empty();
try
{
diff --git a/src/common/UITestAutomation/UITestBase.cs b/src/common/UITestAutomation/UITestBase.cs
index 1c72be05f4..877f384104 100644
--- a/src/common/UITestAutomation/UITestBase.cs
+++ b/src/common/UITestAutomation/UITestBase.cs
@@ -29,6 +29,8 @@ namespace Microsoft.PowerToys.UITest
public string? ScreenshotDirectory { get; set; }
+ public string? RecordingDirectory { get; set; }
+
public static MonitorInfoData.ParamsWrapper MonitorInfoData { get; set; } = new MonitorInfoData.ParamsWrapper() { Monitors = new List() };
private readonly PowerToysModule scope;
@@ -36,6 +38,7 @@ namespace Microsoft.PowerToys.UITest
private readonly string[]? commandLineArgs;
private SessionHelper? sessionHelper;
private System.Threading.Timer? screenshotTimer;
+ private ScreenRecording? screenRecording;
public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings, WindowSize size = WindowSize.UnSpecified, string[]? commandLineArgs = null)
{
@@ -65,12 +68,35 @@ namespace Microsoft.PowerToys.UITest
CloseOtherApplications();
if (IsInPipeline)
{
- ScreenshotDirectory = Path.Combine(this.TestContext.TestResultsDirectory ?? string.Empty, "UITestScreenshots_" + Guid.NewGuid().ToString());
+ string baseDirectory = this.TestContext.TestResultsDirectory ?? string.Empty;
+ ScreenshotDirectory = Path.Combine(baseDirectory, "UITestScreenshots_" + Guid.NewGuid().ToString());
Directory.CreateDirectory(ScreenshotDirectory);
+ RecordingDirectory = Path.Combine(baseDirectory, "UITestRecordings_" + Guid.NewGuid().ToString());
+ Directory.CreateDirectory(RecordingDirectory);
+
// Take screenshot every 1 second
screenshotTimer = new System.Threading.Timer(ScreenCapture.TimerCallback, ScreenshotDirectory, TimeSpan.Zero, TimeSpan.FromMilliseconds(1000));
+ // Start screen recording (requires FFmpeg)
+ try
+ {
+ screenRecording = new ScreenRecording(RecordingDirectory);
+ if (screenRecording.IsAvailable)
+ {
+ _ = screenRecording.StartRecordingAsync();
+ }
+ else
+ {
+ screenRecording = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to start screen recording: {ex.Message}");
+ screenRecording = null;
+ }
+
// Escape Popups before starting
System.Windows.Forms.SendKeys.SendWait("{ESC}");
}
@@ -88,15 +114,36 @@ namespace Microsoft.PowerToys.UITest
if (IsInPipeline)
{
screenshotTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- Dispose();
+
+ // Stop screen recording
+ if (screenRecording != null)
+ {
+ try
+ {
+ screenRecording.StopRecordingAsync().GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to stop screen recording: {ex.Message}");
+ }
+ }
+
if (TestContext.CurrentTestOutcome is UnitTestOutcome.Failed
or UnitTestOutcome.Error
or UnitTestOutcome.Unknown)
{
Task.Delay(1000).Wait();
AddScreenShotsToTestResultsDirectory();
+ AddRecordingsToTestResultsDirectory();
AddLogFilesToTestResultsDirectory();
}
+ else
+ {
+ // Clean up recording if test passed
+ CleanupRecordingDirectory();
+ }
+
+ Dispose();
}
this.Session.Cleanup();
@@ -106,6 +153,7 @@ namespace Microsoft.PowerToys.UITest
public void Dispose()
{
screenshotTimer?.Dispose();
+ screenRecording?.Dispose();
GC.SuppressFinalize(this);
}
@@ -600,6 +648,47 @@ namespace Microsoft.PowerToys.UITest
}
}
+ ///
+ /// Adds screen recordings to test results directory when test fails.
+ ///
+ protected void AddRecordingsToTestResultsDirectory()
+ {
+ if (RecordingDirectory != null && Directory.Exists(RecordingDirectory))
+ {
+ // Add video files (MP4)
+ var videoFiles = Directory.GetFiles(RecordingDirectory, "*.mp4");
+ foreach (string file in videoFiles)
+ {
+ this.TestContext.AddResultFile(file);
+ var fileInfo = new FileInfo(file);
+ Console.WriteLine($"Added video recording: {Path.GetFileName(file)} ({fileInfo.Length / 1024 / 1024:F1} MB)");
+ }
+
+ if (videoFiles.Length == 0)
+ {
+ Console.WriteLine("No video recording available (FFmpeg not found). Screenshots are still captured.");
+ }
+ }
+ }
+
+ ///
+ /// Cleans up recording directory when test passes.
+ ///
+ private void CleanupRecordingDirectory()
+ {
+ if (RecordingDirectory != null && Directory.Exists(RecordingDirectory))
+ {
+ try
+ {
+ Directory.Delete(RecordingDirectory, true);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to cleanup recording directory: {ex.Message}");
+ }
+ }
+ }
+
///
/// Copies PowerToys log files to test results directory when test fails.
/// Renames files to include the directory structure after \PowerToys.
@@ -689,11 +778,11 @@ namespace Microsoft.PowerToys.UITest
///
/// Restart scope exe.
///
- public void RestartScopeExe()
+ public Session RestartScopeExe(string? enableModules = null)
{
- this.sessionHelper!.RestartScopeExe();
+ this.sessionHelper!.RestartScopeExe(enableModules);
this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), this.scope, this.size);
- return;
+ return Session;
}
///
diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp
index 170dde5b0a..a5973a396f 100644
--- a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp
+++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp
@@ -50,6 +50,7 @@ enum class ScheduleMode
Off,
FixedHours,
SunsetToSunrise,
+ FollowNightLight,
// add more later
};
@@ -61,6 +62,8 @@ inline std::wstring ToString(ScheduleMode mode)
return L"SunsetToSunrise";
case ScheduleMode::FixedHours:
return L"FixedHours";
+ case ScheduleMode::FollowNightLight:
+ return L"FollowNightLight";
default:
return L"Off";
}
@@ -72,6 +75,8 @@ inline ScheduleMode FromString(const std::wstring& str)
return ScheduleMode::SunsetToSunrise;
if (str == L"FixedHours")
return ScheduleMode::FixedHours;
+ if (str == L"FollowNightLight")
+ return ScheduleMode::FollowNightLight;
return ScheduleMode::Off;
}
@@ -167,7 +172,9 @@ public:
ToString(g_settings.m_scheduleMode),
{ { L"Off", L"Disable the schedule" },
{ L"FixedHours", L"Set hours manually" },
- { L"SunsetToSunrise", L"Use sunrise/sunset times" } });
+ { L"SunsetToSunrise", L"Use sunrise/sunset times" },
+ { L"FollowNightLight", L"Follow Windows Night Light state" }
+ });
// Integer spinners
settings.add_int_spinner(
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
index 845e24fa93..b6684da54e 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
@@ -13,10 +13,12 @@
#include
#include "LightSwitchStateManager.h"
#include
+#include
SERVICE_STATUS g_ServiceStatus = {};
SERVICE_STATUS_HANDLE g_StatusHandle = nullptr;
HANDLE g_ServiceStopEvent = nullptr;
+static LightSwitchStateManager* g_stateManagerPtr = nullptr;
VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv);
VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl);
@@ -168,7 +170,15 @@ static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateMan
}
// Use shared helper (handles wraparound logic)
- bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark);
+ bool shouldBeLight = false;
+ if (s.scheduleMode == ScheduleMode::FollowNightLight)
+ {
+ shouldBeLight = !IsNightLightEnabled();
+ }
+ else
+ {
+ shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark);
+ }
// Compare current system/apps theme
bool currentSystemLight = GetCurrentSystemTheme();
@@ -199,15 +209,40 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
// Initialization
// ────────────────────────────────────────────────────────────────
static LightSwitchStateManager stateManager;
+ g_stateManagerPtr = &stateManager;
LightSwitchSettings::instance().InitFileWatcher();
HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE");
HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent();
+ static std::unique_ptr g_nightLightWatcher;
+
LightSwitchSettings::instance().LoadSettings();
const auto& settings = LightSwitchSettings::instance().settings();
+ // after loading settings:
+ bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight);
+
+ if (nightLightNeeded && !g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Starting Night Light registry watcher...");
+
+ g_nightLightWatcher = std::make_unique(
+ HKEY_CURRENT_USER,
+ NIGHT_LIGHT_REGISTRY_PATH,
+ []() {
+ if (g_stateManagerPtr)
+ g_stateManagerPtr->OnNightLightChange();
+ });
+ }
+ else if (!nightLightNeeded && g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher...");
+ g_nightLightWatcher->Stop();
+ g_nightLightWatcher.reset();
+ }
+
SYSTEMTIME st;
GetLocalTime(&st);
int nowMinutes = st.wHour * 60 + st.wMinute;
@@ -274,6 +309,31 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
ResetEvent(hSettingsChanged);
LightSwitchSettings::instance().LoadSettings();
stateManager.OnSettingsChanged();
+
+ const auto& settings = LightSwitchSettings::instance().settings();
+ bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight);
+
+ if (nightLightNeeded && !g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Starting Night Light registry watcher...");
+
+ g_nightLightWatcher = std::make_unique(
+ HKEY_CURRENT_USER,
+ NIGHT_LIGHT_REGISTRY_PATH,
+ []() {
+ if (g_stateManagerPtr)
+ g_stateManagerPtr->OnNightLightChange();
+ });
+
+ stateManager.OnNightLightChange();
+ }
+ else if (!nightLightNeeded && g_nightLightWatcher)
+ {
+ Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher...");
+ g_nightLightWatcher->Stop();
+ g_nightLightWatcher.reset();
+ }
+
continue;
}
}
@@ -285,6 +345,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
CloseHandle(hManualOverride);
if (hParent)
CloseHandle(hParent);
+ if (g_nightLightWatcher)
+ {
+ g_nightLightWatcher->Stop();
+ g_nightLightWatcher.reset();
+ }
Logger::info(L"[LightSwitchService] Worker thread exiting cleanly.");
return 0;
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj
index a3a505f897..e1c8052de6 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj
@@ -76,6 +76,7 @@
+
@@ -88,6 +89,7 @@
+
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters
index 795df99aba..55c7bde39b 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters
@@ -36,6 +36,9 @@
Source Files
+
+ Source Files
+
@@ -62,6 +65,9 @@
Header Files
+
+ Header Files
+
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h
index d4029d072d..1d1c7953fe 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h
@@ -19,7 +19,8 @@ enum class ScheduleMode
{
Off,
FixedHours,
- SunsetToSunrise
+ SunsetToSunrise,
+ FollowNightLight,
// Add more in the future
};
@@ -31,6 +32,8 @@ inline std::wstring ToString(ScheduleMode mode)
return L"FixedHours";
case ScheduleMode::SunsetToSunrise:
return L"SunsetToSunrise";
+ case ScheduleMode::FollowNightLight:
+ return L"FollowNightLight";
default:
return L"Off";
}
@@ -42,6 +45,8 @@ inline ScheduleMode FromString(const std::wstring& str)
return ScheduleMode::SunsetToSunrise;
if (str == L"FixedHours")
return ScheduleMode::FixedHours;
+ if (str == L"FollowNightLight")
+ return ScheduleMode::FollowNightLight;
else
return ScheduleMode::Off;
}
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
index 4fba4ae9a6..f562d38c41 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
@@ -31,7 +31,10 @@ void LightSwitchStateManager::OnSettingsChanged()
void LightSwitchStateManager::OnTick(int currentMinutes)
{
std::lock_guard lock(_stateMutex);
- EvaluateAndApplyIfNeeded();
+ if (_state.lastAppliedMode != ScheduleMode::FollowNightLight)
+ {
+ EvaluateAndApplyIfNeeded();
+ }
}
// Called when manual override is triggered
@@ -49,8 +52,38 @@ void LightSwitchStateManager::OnManualOverride()
_state.isAppsLightActive = GetCurrentAppsTheme();
Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).",
- (_state.isSystemLightActive ? L"light" : L"dark"),
- (_state.isAppsLightActive ? L"light" : L"dark"));
+ (_state.isSystemLightActive ? L"light" : L"dark"),
+ (_state.isAppsLightActive ? L"light" : L"dark"));
+ }
+
+ EvaluateAndApplyIfNeeded();
+}
+
+// Runs with the registry observer detects a change in Night Light settings.
+void LightSwitchStateManager::OnNightLightChange()
+{
+ std::lock_guard lock(_stateMutex);
+
+ bool newNightLightState = IsNightLightEnabled();
+
+ // In Follow Night Light mode, treat a Night Light toggle as a boundary
+ if (_state.lastAppliedMode == ScheduleMode::FollowNightLight && _state.isManualOverride)
+ {
+ Logger::info(L"[LightSwitchStateManager] Night Light changed while manual override active; "
+ L"treating as a boundary and clearing manual override.");
+ _state.isManualOverride = false;
+ }
+
+ if (newNightLightState != _state.isNightLightActive)
+ {
+ Logger::info(L"[LightSwitchStateManager] Night Light toggled to {}",
+ newNightLightState ? L"ON" : L"OFF");
+
+ _state.isNightLightActive = newNightLightState;
+ }
+ else
+ {
+ Logger::debug(L"[LightSwitchStateManager] Night Light change event fired, but no actual change.");
}
EvaluateAndApplyIfNeeded();
@@ -77,9 +110,9 @@ void LightSwitchStateManager::SyncInitialThemeState()
_state.isSystemLightActive = GetCurrentSystemTheme();
_state.isAppsLightActive = GetCurrentAppsTheme();
Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})",
- _state.isSystemLightActive ? L"light" : L"dark");
+ _state.isSystemLightActive ? L"light" : L"dark");
Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})",
- _state.isAppsLightActive ? L"light" : L"dark");
+ _state.isAppsLightActive ? L"light" : L"dark");
}
static std::pair update_sun_times(auto& settings)
@@ -194,7 +227,15 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
_state.lastAppliedMode = _currentSettings.scheduleMode;
- bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes);
+ bool shouldBeLight = false;
+ if (_currentSettings.scheduleMode == ScheduleMode::FollowNightLight)
+ {
+ shouldBeLight = !_state.isNightLightActive;
+ }
+ else
+ {
+ shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes);
+ }
bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight);
bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight);
@@ -227,6 +268,3 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
_state.lastTickMinutes = now;
}
-
-
-
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h
index 5c9bcc6e25..c4f39a2e9a 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h
@@ -9,6 +9,7 @@ struct LightSwitchState
bool isManualOverride = false;
bool isSystemLightActive = false;
bool isAppsLightActive = false;
+ bool isNightLightActive = false;
int lastEvaluatedDay = -1;
int lastTickMinutes = -1;
@@ -32,6 +33,9 @@ public:
// Called when manual override is toggled (via shortcut or system change).
void OnManualOverride();
+ // Called when night light changes in windows settings
+ void OnNightLightChange();
+
// Initial sync at startup to align internal state with system theme
void SyncInitialThemeState();
diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp
new file mode 100644
index 0000000000..8da19c6595
--- /dev/null
+++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp
@@ -0,0 +1 @@
+#include "NightLightRegistryObserver.h"
diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h
new file mode 100644
index 0000000000..2806c28316
--- /dev/null
+++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h
@@ -0,0 +1,134 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+
+class NightLightRegistryObserver
+{
+public:
+ NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function callback) :
+ _root(root), _subkey(subkey), _callback(std::move(callback)), _stop(false)
+ {
+ _thread = std::thread([this]() { this->Run(); });
+ }
+
+ ~NightLightRegistryObserver()
+ {
+ Stop();
+ }
+
+ void Stop()
+ {
+ _stop = true;
+
+ {
+ std::lock_guard lock(_mutex);
+ if (_event)
+ SetEvent(_event);
+ }
+
+ if (_thread.joinable())
+ _thread.join();
+
+ std::lock_guard lock(_mutex);
+ if (_hKey)
+ {
+ RegCloseKey(_hKey);
+ _hKey = nullptr;
+ }
+
+ if (_event)
+ {
+ CloseHandle(_event);
+ _event = nullptr;
+ }
+ }
+
+
+private:
+ void Run()
+ {
+ {
+ std::lock_guard lock(_mutex);
+ if (RegOpenKeyExW(_root, _subkey.c_str(), 0, KEY_NOTIFY, &_hKey) != ERROR_SUCCESS)
+ return;
+
+ _event = CreateEventW(nullptr, TRUE, FALSE, nullptr);
+ if (!_event)
+ {
+ RegCloseKey(_hKey);
+ _hKey = nullptr;
+ return;
+ }
+ }
+
+ while (!_stop)
+ {
+ HKEY hKeyLocal = nullptr;
+ HANDLE eventLocal = nullptr;
+
+ {
+ std::lock_guard lock(_mutex);
+ if (_stop)
+ break;
+
+ hKeyLocal = _hKey;
+ eventLocal = _event;
+ }
+
+ if (!hKeyLocal || !eventLocal)
+ break;
+
+ if (_stop)
+ break;
+
+ if (RegNotifyChangeKeyValue(hKeyLocal, FALSE, REG_NOTIFY_CHANGE_LAST_SET, eventLocal, TRUE) != ERROR_SUCCESS)
+ break;
+
+ DWORD wait = WaitForSingleObject(eventLocal, INFINITE);
+ if (_stop || wait == WAIT_FAILED)
+ break;
+
+ ResetEvent(eventLocal);
+
+ if (!_stop && _callback)
+ {
+ try
+ {
+ _callback();
+ }
+ catch (...)
+ {
+ }
+ }
+ }
+
+ {
+ std::lock_guard lock(_mutex);
+ if (_hKey)
+ {
+ RegCloseKey(_hKey);
+ _hKey = nullptr;
+ }
+
+ if (_event)
+ {
+ CloseHandle(_event);
+ _event = nullptr;
+ }
+ }
+ }
+
+
+ HKEY _root;
+ std::wstring _subkey;
+ std::function _callback;
+ HANDLE _event = nullptr;
+ HKEY _hKey = nullptr;
+ std::thread _thread;
+ std::atomic _stop;
+ std::mutex _mutex;
+};
\ No newline at end of file
diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h
index 4872864eff..8015c9b3e6 100644
--- a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h
+++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h
@@ -11,4 +11,7 @@ enum class SettingId
Sunset_Offset,
ChangeSystem,
ChangeApps
-};
\ No newline at end of file
+};
+
+constexpr wchar_t PERSONALIZATION_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
+constexpr wchar_t NIGHT_LIGHT_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.bluelightreduction.bluelightreductionstate\\windows.data.bluelightreduction.bluelightreductionstate";
diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp
index 9633ab2fde..cfa858c636 100644
--- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp
@@ -3,6 +3,7 @@
#include
#include
#include "ThemeHelper.h"
+#include
// Controls changing the themes.
@@ -10,7 +11,7 @@ static void ResetColorPrevalence()
{
HKEY hKey;
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_SET_VALUE,
&hKey) == ERROR_SUCCESS)
@@ -31,7 +32,7 @@ void SetAppsTheme(bool mode)
{
HKEY hKey;
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_SET_VALUE,
&hKey) == ERROR_SUCCESS)
@@ -50,7 +51,7 @@ void SetSystemTheme(bool mode)
{
HKEY hKey;
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_SET_VALUE,
&hKey) == ERROR_SUCCESS)
@@ -79,7 +80,7 @@ bool GetCurrentSystemTheme()
DWORD size = sizeof(value);
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_READ,
&hKey) == ERROR_SUCCESS)
@@ -98,7 +99,7 @@ bool GetCurrentAppsTheme()
DWORD size = sizeof(value);
if (RegOpenKeyEx(HKEY_CURRENT_USER,
- L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
+ PERSONALIZATION_REGISTRY_PATH,
0,
KEY_READ,
&hKey) == ERROR_SUCCESS)
@@ -109,3 +110,30 @@ bool GetCurrentAppsTheme()
return value == 1; // true = light, false = dark
}
+
+bool IsNightLightEnabled()
+{
+ HKEY hKey;
+ const wchar_t* path = NIGHT_LIGHT_REGISTRY_PATH;
+
+ if (RegOpenKeyExW(HKEY_CURRENT_USER, path, 0, KEY_READ, &hKey) != ERROR_SUCCESS)
+ return false;
+
+ // RegGetValueW will set size to the size of the data and we expect that to be at least 25 bytes (we need to access bytes 23 and 24)
+ DWORD size = 0;
+ if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, nullptr, &size) != ERROR_SUCCESS || size < 25)
+ {
+ RegCloseKey(hKey);
+ return false;
+ }
+
+ std::vector data(size);
+ if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, data.data(), &size) != ERROR_SUCCESS)
+ {
+ RegCloseKey(hKey);
+ return false;
+ }
+
+ RegCloseKey(hKey);
+ return data[23] == 0x10 && data[24] == 0x00;
+}
\ No newline at end of file
diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h
index 5985fd95c8..e8d45e9c2a 100644
--- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h
+++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h
@@ -3,3 +3,4 @@ void SetSystemTheme(bool dark);
void SetAppsTheme(bool dark);
bool GetCurrentSystemTheme();
bool GetCurrentAppsTheme();
+bool IsNightLightEnabled();
\ No newline at end of file
diff --git a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs
index 7cad62decb..5f857aa391 100644
--- a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs
+++ b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs
@@ -617,6 +617,8 @@ namespace MouseUtils.UITests
private void LaunchFromSetting(bool reload = false, bool launchAsAdmin = false)
{
+ Session = RestartScopeExe("FindMyMouse,MouseHighlighter,MouseJump,MousePointerCrosshairs,CursorWrap");
+
// this.Session.Attach(PowerToysModule.PowerToysSettings);
this.Session.SetMainWindowSize(WindowSize.Large);
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs
index a381cfda6b..1e8642fff7 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs
@@ -19,6 +19,8 @@ public partial class DetailsViewModel(IDetails _details, WeakReference Metadata { get; private set; } = [];
@@ -40,6 +42,21 @@ public partial class DetailsViewModel(IDetails _details, WeakReference,
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/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
index 160fe5d279..d8a64525b8 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
@@ -12,6 +12,7 @@ using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.Ext.Apps.State;
+using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -33,7 +34,10 @@ public partial class MainListPage : DynamicListPage,
private readonly AppStateModel _appStateModel;
private List>? _filteredItems;
private List>? _filteredApps;
- private IEnumerable>? _fallbackItems;
+ private List>? _fallbackItems;
+
+ // Keep as IEnumerable for deferred execution. Fallback item titles are updated
+ // asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable>? _scoredFallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
@@ -146,44 +150,24 @@ public partial class MainListPage : DynamicListPage,
public override IListItem[] GetItems()
{
- if (string.IsNullOrEmpty(SearchText))
+ lock (_tlcManager.TopLevelCommands)
{
- lock (_tlcManager.TopLevelCommands)
+ // Either return the top-level commands (no search text), or the merged and
+ // filtered results.
+ if (string.IsNullOrWhiteSpace(SearchText))
{
- return _tlcManager
- .TopLevelCommands
+ return _tlcManager.TopLevelCommands
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
.ToArray();
}
- }
- else
- {
- lock (_tlcManager.TopLevelCommands)
+ else
{
- var limitedApps = new List>();
-
- // Fuzzy matching can produce a lot of results, so we want to limit the
- // number of apps we show at once if it's a large set.
- if (_filteredApps?.Count > 0)
- {
- limitedApps = _filteredApps.OrderByDescending(s => s.Score).Take(_appResultLimit).ToList();
- }
-
- var orderedFallbacks = _fallbackItems?
- .Where(w => !string.IsNullOrEmpty(w.Item.Title))
- .OrderByDescending(o => o.Score).ToList();
-
- var items = Enumerable.Empty>()
- .Concat(_filteredItems is not null ? _filteredItems : [])
- .Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : [])
- .Concat(limitedApps)
- .OrderByDescending(o => o.Score)
-
- // Now append the fallbacks that weren't scored (because they weren't in the global fallbacks list)
- .Concat(orderedFallbacks ?? [])
- .Select(s => s.Item)
- .ToArray();
- return items;
+ return MainListPageResultFactory.Create(
+ _filteredItems,
+ _scoredFallbackItems?.ToList(),
+ _filteredApps,
+ _fallbackItems,
+ _appResultLimit);
}
}
}
@@ -402,15 +386,8 @@ public partial class MainListPage : DynamicListPage,
return;
}
- _scoredFallbackItems = ListHelpers.FilterListWithScores(newFallbacksForScoring ?? [], SearchText, scoreItem);
-
- if (token.IsCancellationRequested)
- {
- return;
- }
-
Func scoreFallbackItem = (a, b) => { return ScoreFallbackItem(a, b, _settings.FallbackRanks); };
- _fallbackItems = ListHelpers.FilterListWithScores(newFallbacks ?? [], SearchText, scoreFallbackItem);
+ _fallbackItems = [.. ListHelpers.FilterListWithScores(newFallbacks ?? [], SearchText, scoreFallbackItem)];
if (token.IsCancellationRequested)
{
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs
new file mode 100644
index 0000000000..1490512d57
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs
@@ -0,0 +1,156 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+#pragma warning disable IDE0007 // Use implicit type
+
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+
+namespace Microsoft.CmdPal.UI.ViewModels.Commands;
+
+internal static class MainListPageResultFactory
+{
+ ///
+ /// Creates a merged and ordered array of results from multiple scored input lists,
+ /// applying an application result limit and filtering fallback items as needed.
+ ///
+ public static IListItem[] Create(
+ IList>? filteredItems,
+ IList>? scoredFallbackItems,
+ IList>? filteredApps,
+ IList>? fallbackItems,
+ int appResultLimit)
+ {
+ if (appResultLimit < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(appResultLimit), "App result limit must be non-negative.");
+ }
+
+ int len1 = filteredItems?.Count ?? 0;
+ int len2 = scoredFallbackItems?.Count ?? 0;
+
+ // Apps are pre-sorted, so we just need to take the top N, limited by appResultLimit.
+ int len3 = Math.Min(filteredApps?.Count ?? 0, appResultLimit);
+
+ // Allocate the exact size of the result array.
+ int totalCount = len1 + len2 + len3 + GetNonEmptyFallbackItemsCount(fallbackItems);
+ var result = new IListItem[totalCount];
+
+ // Three-way stable merge of already-sorted lists.
+ int idx1 = 0, idx2 = 0, idx3 = 0;
+ int writePos = 0;
+
+ // Merge while all three lists have items. To maintain a stable sort, the
+ // priority is: list1 > list2 > list3 when scores are equal.
+ while (idx1 < len1 && idx2 < len2 && idx3 < len3)
+ {
+ // Using null-forgiving operator as we have already checked against lengths.
+ int score1 = filteredItems![idx1].Score;
+ int score2 = scoredFallbackItems![idx2].Score;
+ int score3 = filteredApps![idx3].Score;
+
+ if (score1 >= score2 && score1 >= score3)
+ {
+ result[writePos++] = filteredItems[idx1++].Item;
+ }
+ else if (score2 >= score3)
+ {
+ result[writePos++] = scoredFallbackItems[idx2++].Item;
+ }
+ else
+ {
+ result[writePos++] = filteredApps[idx3++].Item;
+ }
+ }
+
+ // Two-way merges for remaining pairs.
+ while (idx1 < len1 && idx2 < len2)
+ {
+ if (filteredItems![idx1].Score >= scoredFallbackItems![idx2].Score)
+ {
+ result[writePos++] = filteredItems[idx1++].Item;
+ }
+ else
+ {
+ result[writePos++] = scoredFallbackItems[idx2++].Item;
+ }
+ }
+
+ while (idx1 < len1 && idx3 < len3)
+ {
+ if (filteredItems![idx1].Score >= filteredApps![idx3].Score)
+ {
+ result[writePos++] = filteredItems[idx1++].Item;
+ }
+ else
+ {
+ result[writePos++] = filteredApps[idx3++].Item;
+ }
+ }
+
+ while (idx2 < len2 && idx3 < len3)
+ {
+ if (scoredFallbackItems![idx2].Score >= filteredApps![idx3].Score)
+ {
+ result[writePos++] = scoredFallbackItems[idx2++].Item;
+ }
+ else
+ {
+ result[writePos++] = filteredApps[idx3++].Item;
+ }
+ }
+
+ // Drain remaining items from a non-empty list.
+ while (idx1 < len1)
+ {
+ result[writePos++] = filteredItems![idx1++].Item;
+ }
+
+ while (idx2 < len2)
+ {
+ result[writePos++] = scoredFallbackItems![idx2++].Item;
+ }
+
+ while (idx3 < len3)
+ {
+ result[writePos++] = filteredApps![idx3++].Item;
+ }
+
+ // Append filtered fallback items. Fallback items are added post-sort so they are
+ // always at the end of the list and are sorted by user settings.
+ if (fallbackItems is not null)
+ {
+ for (int i = 0; i < fallbackItems.Count; i++)
+ {
+ var item = fallbackItems[i].Item;
+ if (!string.IsNullOrEmpty(item.Title))
+ {
+ result[writePos++] = item;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private static int GetNonEmptyFallbackItemsCount(IList>? fallbackItems)
+ {
+ int fallbackItemsCount = 0;
+
+ if (fallbackItems is not null)
+ {
+ for (int i = 0; i < fallbackItems.Count; i++)
+ {
+ if (!string.IsNullOrEmpty(fallbackItems[i].Item.Title))
+ {
+ fallbackItemsCount++;
+ }
+ }
+ }
+
+ return fallbackItemsCount;
+ }
+}
+#pragma warning restore IDE0007 // Use implicit type
diff --git a/src/modules/cmdpal/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 fbec08c6c8..483fe3fdc3 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;
@@ -64,6 +66,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 93d7cf7829..947a025e69 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
@@ -4,6 +4,7 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -29,6 +30,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
+ public AppearanceSettingsViewModel Appearance { get; }
+
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
@@ -176,11 +179,13 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public SettingsExtensionsViewModel Extensions { get; }
- public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler)
+ public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler, IThemeService themeService)
{
_settings = settings;
_topLevelCommandManager = topLevelCommandManager;
+ 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/FallbackRanker.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs
index b438fb0b61..57ad2c1d8c 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml.Controls;
@@ -19,7 +20,8 @@ public sealed partial class FallbackRanker : UserControl
var settings = App.Current.Services.GetService()!;
var topLevelCommandManager = App.Current.Services.GetService()!;
- viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler);
+ var themeService = App.Current.Services.GetService()!;
+ viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService);
}
private void ListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args)
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/Converters/DetailsSizeToGridLengthConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs
new file mode 100644
index 0000000000..033c03a0b9
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.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.
+
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+
+namespace Microsoft.CmdPal.UI;
+
+public partial class DetailsSizeToGridLengthConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is ContentSize size)
+ {
+ // This converter calculates the Star width for the LIST.
+ //
+ // The input 'size' (ContentSize) represents the TARGET WIDTH desired for the DETAILS PANEL.
+ //
+ // To ensure the Details Panel achieves its target size (e.g. ContentSize.Large),
+ // we must shrink the List and let the Details fill the available space.
+ // (e.g., A larger target size for Details results in a smaller Star value for the List).
+ var starValue = size switch
+ {
+ ContentSize.Small => 3.0,
+ ContentSize.Medium => 2.0,
+ ContentSize.Large => 1.0,
+ _ => 3.0,
+ };
+
+ return new GridLength(starValue, GridUnitType.Star);
+ }
+
+ return new GridLength(3.0, GridUnitType.Star);
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
+}
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..ba9b8e736c 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"
@@ -27,6 +26,7 @@
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
+
-
+
@@ -190,7 +190,7 @@
Padding="0,12,0,12"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
- BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
+ BorderBrush="{ThemeResource CmdPal.TopBarBorderBrush}"
BorderThickness="0,0,0,1">
@@ -371,7 +371,7 @@
-
+
@@ -390,7 +390,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 +518,7 @@
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs
new file mode 100644
index 0000000000..fe6c8e48e0
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs
@@ -0,0 +1,207 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.WinUI.Helpers;
+using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI.Xaml;
+using Windows.UI;
+using Windows.UI.ViewManagement;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Provides theme appropriate for colorful (accented) appearance.
+///
+internal sealed class ColorfulThemeProvider : IThemeProvider
+{
+ // Fluent dark: #202020
+ private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32);
+
+ // Fluent light: #F3F3F3
+ private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243);
+
+ private readonly UISettings _uiSettings;
+
+ public string ThemeKey => "colorful";
+
+ public string ResourcePath => "ms-appx:///Styles/Theme.Colorful.xaml";
+
+ public ColorfulThemeProvider(UISettings uiSettings)
+ {
+ ArgumentNullException.ThrowIfNull(uiSettings);
+ _uiSettings = uiSettings;
+ }
+
+ public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context)
+ {
+ var isLight = context.Theme == ElementTheme.Light ||
+ (context.Theme == ElementTheme.Default &&
+ _uiSettings.GetColorValue(UIColorType.Background).R > 128);
+
+ var baseColor = isLight ? LightBaseColor : DarkBaseColor;
+
+ // Windows is warping the hue of accent colors and running it through some curves to produce their accent shades.
+ // This will attempt to mimic that behavior.
+ var accentShades = AccentShades.Compute(context.Tint.LerpHsv(WindowsAccentHueWarpTransform.Transform(context.Tint), 0.5f));
+ var blended = isLight ? accentShades.Light3 : accentShades.Dark2;
+ var colorIntensityUser = (context.ColorIntensity ?? 100) / 100f;
+
+ // For light theme, we want to reduce intensity a bit, and also we need to keep the color fairly light,
+ // to avoid issues with text box caret.
+ var colorIntensity = isLight ? 0.6f * colorIntensityUser : colorIntensityUser;
+ var effectiveBgColor = ColorBlender.Blend(baseColor, blended, colorIntensity);
+
+ return new AcrylicBackdropParameters(effectiveBgColor, effectiveBgColor, 0.8f, 0.8f);
+ }
+
+ private static class ColorBlender
+ {
+ ///
+ /// Blends a semitransparent tint color over an opaque base color using alpha compositing.
+ ///
+ /// The opaque base color (background)
+ /// The semitransparent tint color (foreground)
+ /// The intensity of the tint (0.0 - 1.0)
+ /// The resulting blended color
+ public static Color Blend(Color baseColor, Color tintColor, float intensity)
+ {
+ // Normalize alpha to 0.0 - 1.0 range
+ intensity = Math.Clamp(intensity, 0f, 1f);
+
+ // Alpha compositing formula: result = tint * alpha + base * (1 - alpha)
+ var r = (byte)((tintColor.R * intensity) + (baseColor.R * (1 - intensity)));
+ var g = (byte)((tintColor.G * intensity) + (baseColor.G * (1 - intensity)));
+ var b = (byte)((tintColor.B * intensity) + (baseColor.B * (1 - intensity)));
+
+ // Result is fully opaque since base is opaque
+ return Color.FromArgb(255, r, g, b);
+ }
+ }
+
+ private static class WindowsAccentHueWarpTransform
+ {
+ private static readonly (double HIn, double HOut)[] HueMap =
+ [
+ (0, 0),
+ (10, 1),
+ (20, 6),
+ (30, 10),
+ (40, 14),
+ (50, 19),
+ (60, 36),
+ (70, 94),
+ (80, 112),
+ (90, 120),
+ (100, 120),
+ (110, 120),
+ (120, 120),
+ (130, 120),
+ (140, 120),
+ (150, 125),
+ (160, 135),
+ (170, 142),
+ (180, 178),
+ (190, 205),
+ (200, 220),
+ (210, 229),
+ (220, 237),
+ (230, 241),
+ (240, 243),
+ (250, 244),
+ (260, 245),
+ (270, 248),
+ (280, 252),
+ (290, 276),
+ (300, 293),
+ (310, 313),
+ (320, 330),
+ (330, 349),
+ (340, 353),
+ (350, 357)
+ ];
+
+ public static Color Transform(Color input, Options? opt = null)
+ {
+ opt ??= new Options();
+ var hsv = input.ToHsv();
+ return ColorHelper.FromHsv(
+ RemapHueLut(hsv.H),
+ Clamp01(Math.Pow(hsv.S, opt.SaturationGamma) * opt.SaturationGain),
+ Clamp01((opt.ValueScaleA * hsv.V) + opt.ValueBiasB),
+ input.A);
+ }
+
+ // Hue LUT remap (piecewise-linear with cyclic wrap)
+ private static double RemapHueLut(double hDeg)
+ {
+ // Normalize to [0,360)
+ hDeg = Mod(hDeg, 360.0);
+
+ // Handle wrap-around case: hDeg is between last entry (350°) and 360°
+ var last = HueMap[^1];
+ var first = HueMap[0];
+ if (hDeg >= last.HIn)
+ {
+ // Interpolate between last entry and first entry (wrapped by 360°)
+ var t = (hDeg - last.HIn) / (first.HIn + 360.0 - last.HIn + 1e-12);
+ var ho = Lerp(last.HOut, first.HOut + 360.0, t);
+ return Mod(ho, 360.0);
+ }
+
+ // Find segment [i, i+1] where HueMap[i].HIn <= hDeg < HueMap[i+1].HIn
+ for (var i = 0; i < HueMap.Length - 1; i++)
+ {
+ var a = HueMap[i];
+ var b = HueMap[i + 1];
+
+ if (hDeg >= a.HIn && hDeg < b.HIn)
+ {
+ var t = (hDeg - a.HIn) / (b.HIn - a.HIn + 1e-12);
+ return Lerp(a.HOut, b.HOut, t);
+ }
+ }
+
+ // Fallback (shouldn't happen)
+ return hDeg;
+ }
+
+ private static double Lerp(double a, double b, double t) => a + ((b - a) * t);
+
+ private static double Mod(double x, double m) => ((x % m) + m) % m;
+
+ private static double Clamp01(double x) => x < 0 ? 0 : (x > 1 ? 1 : x);
+
+ public sealed class Options
+ {
+ // Saturation boost (1.0 = no change). Typical: 1.3–1.8
+ public double SaturationGain { get; init; } = 1.0;
+
+ // Optional saturation gamma (1.0 = linear). <1.0 raises low S a bit; >1.0 preserves low S.
+ public double SaturationGamma { get; init; } = 1.0;
+
+ // Value (V) remap: V' = a*V + b (tone curve; clamp applied)
+ // Example that lifts blacks & compresses whites slightly: a=0.50, b=0.08
+ public double ValueScaleA { get; init; } = 0.6;
+
+ public double ValueBiasB { get; init; } = 0.01;
+ }
+ }
+
+ private static class AccentShades
+ {
+ public static (Color Light3, Color Light2, Color Light1, Color Dark1, Color Dark2, Color Dark3) Compute(Color accent)
+ {
+ var light1 = accent.Update(brightnessFactor: 0.15, saturationFactor: -0.12);
+ var light2 = accent.Update(brightnessFactor: 0.30, saturationFactor: -0.24);
+ var light3 = accent.Update(brightnessFactor: 0.45, saturationFactor: -0.36);
+
+ var dark1 = accent.UpdateBrightness(brightnessFactor: -0.05f);
+ var dark2 = accent.UpdateBrightness(brightnessFactor: -0.01f);
+ var dark3 = accent.UpdateBrightness(brightnessFactor: -0.015f);
+
+ return (light3, light2, light1, dark1, dark2, dark3);
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs
new file mode 100644
index 0000000000..a9411c3656
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.Core.Common.Services;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Provides theme identification, resource path resolution, and creation of acrylic
+/// backdrop parameters based on the current .
+///
+///
+/// Implementations should expose a stable and a valid XAML resource
+/// dictionary path via . The
+/// method computes
+/// using the supplied theme context.
+///
+internal interface IThemeProvider
+{
+ ///
+ /// Gets the unique key identifying this theme provider.
+ ///
+ string ThemeKey { get; }
+
+ ///
+ /// Gets the resource dictionary path for this theme.
+ ///
+ string ResourcePath { get; }
+
+ ///
+ /// Creates acrylic backdrop parameters based on the provided theme context.
+ ///
+ /// The current theme context, including theme, tint, and optional background details.
+ /// The computed for the backdrop.
+ AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context);
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs
new file mode 100644
index 0000000000..8177326259
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.UI.Xaml;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Dedicated ResourceDictionary for dynamic overrides that win over base theme resources. Since
+/// we can't use a key or name to identify the dictionary in Application resources, we use a dedicated type.
+///
+internal sealed partial class MutableOverridesDictionary : ResourceDictionary;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs
new file mode 100644
index 0000000000..c393894346
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI.Xaml;
+using Windows.UI;
+using Windows.UI.ViewManagement;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Provides theme resources and acrylic backdrop parameters matching the default Command Palette theme.
+///
+internal sealed class NormalThemeProvider : IThemeProvider
+{
+ private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32);
+ private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243);
+ private readonly UISettings _uiSettings;
+
+ public NormalThemeProvider(UISettings uiSettings)
+ {
+ ArgumentNullException.ThrowIfNull(uiSettings);
+ _uiSettings = uiSettings;
+ }
+
+ public string ThemeKey => "normal";
+
+ public string ResourcePath => "ms-appx:///Styles/Theme.Normal.xaml";
+
+ public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context)
+ {
+ var isLight = context.Theme == ElementTheme.Light ||
+ (context.Theme == ElementTheme.Default &&
+ _uiSettings.GetColorValue(UIColorType.Background).R > 128);
+
+ return new AcrylicBackdropParameters(
+ TintColor: isLight ? LightBaseColor : DarkBaseColor,
+ FallbackColor: isLight ? LightBaseColor : DarkBaseColor,
+ TintOpacity: 0.5f,
+ LuminosityOpacity: isLight ? 0.9f : 0.96f);
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs
new file mode 100644
index 0000000000..6d0a6f01dd
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs
@@ -0,0 +1,332 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.UI.Xaml;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Simple theme switcher that swaps application ResourceDictionaries at runtime.
+/// Can also operate in event-only mode for consumers to apply resources themselves.
+/// Exposes a dedicated override dictionary that stays merged and is cleared on theme changes.
+///
+internal sealed partial class ResourceSwapper
+{
+ private readonly Lock _resourceSwapGate = new();
+ private readonly Dictionary _themeUris = new(StringComparer.OrdinalIgnoreCase);
+ private ResourceDictionary? _activeDictionary;
+ private string? _currentThemeName;
+ private Uri? _currentThemeUri;
+
+ private ResourceDictionary? _overrideDictionary;
+
+ ///
+ /// Raised after a theme has been activated.
+ ///
+ public event EventHandler? ResourcesSwapped;
+
+ ///
+ /// Gets or sets a value indicating whether when true (default) ResourceSwapper updates Application.Current.Resources. When false, it only raises ResourcesSwapped.
+ ///
+ public bool ApplyToAppResources { get; set; } = true;
+
+ ///
+ /// Gets name of the currently selected theme (if any).
+ ///
+ public string? CurrentThemeName
+ {
+ get
+ {
+ lock (_resourceSwapGate)
+ {
+ return _currentThemeName;
+ }
+ }
+ }
+
+ ///
+ /// Initializes ResourceSwapper by checking Application resources for an already merged theme dictionary.
+ ///
+ public void Initialize()
+ {
+ // Find merged dictionary in Application resources that matches a registered theme by URI
+ // This allows ResourceSwapper to pick up an initial theme set in XAML
+ var app = Application.Current;
+ var resourcesMergedDictionaries = app?.Resources?.MergedDictionaries;
+ if (resourcesMergedDictionaries == null)
+ {
+ return;
+ }
+
+ foreach (var dict in resourcesMergedDictionaries)
+ {
+ var uri = dict.Source;
+ if (uri is null)
+ {
+ continue;
+ }
+
+ var name = GetNameForUri(uri);
+ if (name is null)
+ {
+ continue;
+ }
+
+ lock (_resourceSwapGate)
+ {
+ _currentThemeName = name;
+ _currentThemeUri = uri;
+ _activeDictionary = dict;
+ }
+
+ break;
+ }
+ }
+
+ ///
+ /// Gets uri of the currently selected theme dictionary (if any).
+ ///
+ public Uri? CurrentThemeUri
+ {
+ get
+ {
+ lock (_resourceSwapGate)
+ {
+ return _currentThemeUri;
+ }
+ }
+ }
+
+ public static ResourceDictionary GetOverrideDictionary(bool clear = false)
+ {
+ var app = Application.Current ?? throw new InvalidOperationException("App is null");
+
+ if (app.Resources == null)
+ {
+ throw new InvalidOperationException("Application.Resources is null");
+ }
+
+ // (Re)locate the slot – Hot Reload may rebuild Application.Resources.
+ var slot = app.Resources!.MergedDictionaries!
+ .OfType()
+ .FirstOrDefault();
+
+ if (slot is null)
+ {
+ // If the slot vanished (Hot Reload), create it again at the end so it wins precedence.
+ slot = new MutableOverridesDictionary();
+ app.Resources.MergedDictionaries!.Add(slot);
+ }
+
+ // Ensure the slot has exactly one child RD we can swap safely.
+ if (slot.MergedDictionaries!.Count == 0)
+ {
+ slot.MergedDictionaries.Add(new ResourceDictionary());
+ }
+ else if (slot.MergedDictionaries.Count > 1)
+ {
+ // Normalize to a single child to keep semantics predictable.
+ var keep = slot.MergedDictionaries[^1];
+ slot.MergedDictionaries.Clear();
+ slot.MergedDictionaries.Add(keep);
+ }
+
+ if (clear)
+ {
+ // Swap the child dictionary instead of Clear() to avoid reentrancy issues.
+ var fresh = new ResourceDictionary();
+ slot.MergedDictionaries[0] = fresh;
+ return fresh;
+ }
+
+ return slot.MergedDictionaries[0]!;
+ }
+
+ ///
+ /// Registers a theme name mapped to a XAML ResourceDictionary URI (e.g. ms-appx:///Themes/Red.xaml)
+ ///
+ public void RegisterTheme(string name, Uri dictionaryUri)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("Theme name is required", nameof(name));
+ }
+
+ lock (_resourceSwapGate)
+ {
+ _themeUris[name] = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri));
+ }
+ }
+
+ ///
+ /// Registers a theme with a string URI.
+ ///
+ public void RegisterTheme(string name, string dictionaryUri)
+ {
+ ArgumentNullException.ThrowIfNull(dictionaryUri);
+ RegisterTheme(name, new Uri(dictionaryUri));
+ }
+
+ ///
+ /// Removes a previously registered theme.
+ ///
+ public bool UnregisterTheme(string name)
+ {
+ lock (_resourceSwapGate)
+ {
+ return _themeUris.Remove(name);
+ }
+ }
+
+ ///
+ /// Gets the names of all registered themes.
+ ///
+ public IEnumerable GetRegisteredThemes()
+ {
+ lock (_resourceSwapGate)
+ {
+ // return a copy to avoid external mutation
+ return new List(_themeUris.Keys);
+ }
+ }
+
+ ///
+ /// Activates a theme by name. The dictionary for the given name must be registered first.
+ ///
+ public void ActivateTheme(string theme)
+ {
+ if (string.IsNullOrWhiteSpace(theme))
+ {
+ throw new ArgumentException("Theme name is required", nameof(theme));
+ }
+
+ Uri uri;
+ lock (_resourceSwapGate)
+ {
+ if (!_themeUris.TryGetValue(theme, out uri!))
+ {
+ throw new KeyNotFoundException($"Theme '{theme}' is not registered.");
+ }
+ }
+
+ ActivateThemeInternal(theme, uri);
+ }
+
+ ///
+ /// Tries to activate a theme by name without throwing.
+ ///
+ public bool TryActivateTheme(string theme)
+ {
+ if (string.IsNullOrWhiteSpace(theme))
+ {
+ return false;
+ }
+
+ Uri uri;
+ lock (_resourceSwapGate)
+ {
+ if (!_themeUris.TryGetValue(theme, out uri!))
+ {
+ return false;
+ }
+ }
+
+ ActivateThemeInternal(theme, uri);
+ return true;
+ }
+
+ ///
+ /// Activates a theme by URI to a ResourceDictionary.
+ ///
+ public void ActivateTheme(Uri dictionaryUri)
+ {
+ ArgumentNullException.ThrowIfNull(dictionaryUri);
+
+ ActivateThemeInternal(GetNameForUri(dictionaryUri), dictionaryUri);
+ }
+
+ ///
+ /// Clears the currently active theme ResourceDictionary. Also clears the override dictionary.
+ ///
+ public void ClearActiveTheme()
+ {
+ lock (_resourceSwapGate)
+ {
+ var app = Application.Current;
+ if (app is null)
+ {
+ return;
+ }
+
+ if (_activeDictionary is not null && ApplyToAppResources)
+ {
+ _ = app.Resources.MergedDictionaries.Remove(_activeDictionary);
+ _activeDictionary = null;
+ }
+
+ // Clear overrides but keep the override dictionary merged for future updates
+ _overrideDictionary?.Clear();
+
+ _currentThemeName = null;
+ _currentThemeUri = null;
+ }
+ }
+
+ private void ActivateThemeInternal(string? name, Uri dictionaryUri)
+ {
+ lock (_resourceSwapGate)
+ {
+ _currentThemeName = name;
+ _currentThemeUri = dictionaryUri;
+ }
+
+ if (ApplyToAppResources)
+ {
+ ActivateThemeCore(dictionaryUri);
+ }
+
+ OnResourcesSwapped(new(name, dictionaryUri));
+ }
+
+ private void ActivateThemeCore(Uri dictionaryUri)
+ {
+ var app = Application.Current ?? throw new InvalidOperationException("Application.Current is null");
+
+ // Remove previously applied base theme dictionary
+ if (_activeDictionary is not null)
+ {
+ _ = app.Resources.MergedDictionaries.Remove(_activeDictionary);
+ _activeDictionary = null;
+ }
+
+ // Load and merge the new base theme dictionary
+ var newDict = new ResourceDictionary { Source = dictionaryUri };
+ app.Resources.MergedDictionaries.Add(newDict);
+ _activeDictionary = newDict;
+
+ // Ensure override dictionary exists and is merged last, then clear it to avoid leaking stale overrides
+ _overrideDictionary = GetOverrideDictionary(clear: true);
+ }
+
+ private string? GetNameForUri(Uri dictionaryUri)
+ {
+ lock (_resourceSwapGate)
+ {
+ foreach (var (key, value) in _themeUris)
+ {
+ if (Uri.Compare(value, dictionaryUri, UriComponents.AbsoluteUri, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ return key;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ private void OnResourcesSwapped(ResourcesSwappedEventArgs e)
+ {
+ ResourcesSwapped?.Invoke(this, e);
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs
new file mode 100644
index 0000000000..0a5cc15de6
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.CmdPal.UI.Services;
+
+public sealed class ResourcesSwappedEventArgs(string? name, Uri dictionaryUri) : EventArgs
+{
+ public string? Name { get; } = name;
+
+ public Uri DictionaryUri { get; } = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri));
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs
new file mode 100644
index 0000000000..67432c8748
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+internal sealed record ThemeContext
+{
+ public ElementTheme Theme { get; init; }
+
+ public Color Tint { get; init; }
+
+ public ImageSource? BackgroundImageSource { get; init; }
+
+ public Stretch BackgroundImageStretch { get; init; }
+
+ public double BackgroundImageOpacity { get; init; }
+
+ public int? ColorIntensity { get; init; }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs
new file mode 100644
index 0000000000..65fbfb24d7
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs
@@ -0,0 +1,261 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.WinUI;
+using ManagedCommon;
+using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Windows.UI.ViewManagement;
+using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// ThemeService is a hub that translates user settings and system preferences into concrete
+/// theme resources and notifies listeners of changes.
+///
+internal sealed partial class ThemeService : IThemeService, IDisposable
+{
+ private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500);
+
+ private readonly UISettings _uiSettings;
+ private readonly SettingsModel _settings;
+ private readonly ResourceSwapper _resourceSwapper;
+ private readonly NormalThemeProvider _normalThemeProvider;
+ private readonly ColorfulThemeProvider _colorfulThemeProvider;
+
+ private DispatcherQueue? _dispatcherQueue;
+ private DispatcherQueueTimer? _dispatcherQueueTimer;
+ private bool _isInitialized;
+ private bool _disposed;
+ private InternalThemeState _currentState;
+
+ public event EventHandler? ThemeChanged;
+
+ public ThemeSnapshot Current => Volatile.Read(ref _currentState).Snapshot;
+
+ ///
+ /// Initializes the theme service. Must be called after the application window is activated and on UI thread.
+ ///
+ public void Initialize()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
+ if (_dispatcherQueue is null)
+ {
+ throw new InvalidOperationException("Failed to get DispatcherQueue for the current thread. Ensure Initialize is called on the UI thread after window activation.");
+ }
+
+ _dispatcherQueueTimer = _dispatcherQueue.CreateTimer();
+
+ _resourceSwapper.Initialize();
+ _isInitialized = true;
+ Reload();
+ }
+
+ private void Reload()
+ {
+ if (!_isInitialized)
+ {
+ return;
+ }
+
+ // provider selection
+ var intensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100);
+ IThemeProvider provider = intensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image
+ ? _colorfulThemeProvider
+ : _normalThemeProvider;
+
+ // Calculate values
+ var tint = _settings.ColorizationMode switch
+ {
+ ColorizationMode.CustomColor => _settings.CustomThemeColor,
+ ColorizationMode.WindowsAccentColor => _uiSettings.GetColorValue(UIColorType.Accent),
+ ColorizationMode.Image => _settings.CustomThemeColor,
+ _ => Colors.Transparent,
+ };
+ var effectiveTheme = GetElementTheme((ElementTheme)_settings.Theme);
+ var imageSource = _settings.ColorizationMode == ColorizationMode.Image
+ ? LoadImageSafe(_settings.BackgroundImagePath)
+ : null;
+ var stretch = _settings.BackgroundImageFit switch
+ {
+ BackgroundImageFit.Fill => Stretch.Fill,
+ _ => Stretch.UniformToFill,
+ };
+ var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0;
+
+ // create context and offload to actual theme provider
+ var context = new ThemeContext
+ {
+ Tint = tint,
+ ColorIntensity = intensity,
+ Theme = effectiveTheme,
+ BackgroundImageSource = imageSource,
+ BackgroundImageStretch = stretch,
+ BackgroundImageOpacity = opacity,
+ };
+ var backdrop = provider.GetAcrylicBackdrop(context);
+ var blur = _settings.BackgroundImageBlurAmount;
+ var brightness = _settings.BackgroundImageBrightness;
+
+ // Create public snapshot (no provider!)
+ var snapshot = new ThemeSnapshot
+ {
+ Tint = tint,
+ TintIntensity = intensity / 100f,
+ Theme = effectiveTheme,
+ BackgroundImageSource = imageSource,
+ BackgroundImageStretch = stretch,
+ BackgroundImageOpacity = opacity,
+ BackdropParameters = backdrop,
+ BlurAmount = blur,
+ BackgroundBrightness = brightness / 100f,
+ };
+
+ // Bundle with provider for internal use
+ var newState = new InternalThemeState
+ {
+ Snapshot = snapshot,
+ Provider = provider,
+ };
+
+ // Atomic swap
+ Interlocked.Exchange(ref _currentState, newState);
+
+ _resourceSwapper.TryActivateTheme(provider.ThemeKey);
+ ThemeChanged?.Invoke(this, new ThemeChangedEventArgs());
+ }
+
+ private static BitmapImage? LoadImageSafe(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ // If it looks like a file path and exists, prefer absolute file URI
+ if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri))
+ {
+ return null;
+ }
+
+ if (!uri.IsAbsoluteUri && File.Exists(path))
+ {
+ uri = new Uri(Path.GetFullPath(path));
+ }
+
+ return new BitmapImage(uri);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning($"Failed to load background image '{path}'. {ex.Message}");
+ return null;
+ }
+ }
+
+ public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(resourceSwapper);
+
+ _settings = settings;
+ _settings.SettingsChanged += SettingsOnSettingsChanged;
+
+ _resourceSwapper = resourceSwapper;
+
+ _uiSettings = new UISettings();
+ _uiSettings.ColorValuesChanged += UiSettings_ColorValuesChanged;
+
+ _normalThemeProvider = new NormalThemeProvider(_uiSettings);
+ _colorfulThemeProvider = new ColorfulThemeProvider(_uiSettings);
+ List providers = [_normalThemeProvider, _colorfulThemeProvider];
+
+ foreach (var provider in providers)
+ {
+ _resourceSwapper.RegisterTheme(provider.ThemeKey, provider.ResourcePath);
+ }
+
+ _currentState = new InternalThemeState
+ {
+ Snapshot = new ThemeSnapshot
+ {
+ Tint = Colors.Transparent,
+ Theme = ElementTheme.Light,
+ BackdropParameters = new AcrylicBackdropParameters(Colors.Black, Colors.Black, 0.5f, 0.5f),
+ BackgroundImageOpacity = 1,
+ BackgroundImageSource = null,
+ BackgroundImageStretch = Stretch.Fill,
+ BlurAmount = 0,
+ TintIntensity = 1.0f,
+ BackgroundBrightness = 0,
+ },
+ Provider = _normalThemeProvider,
+ };
+ }
+
+ private void RequestReload()
+ {
+ if (!_isInitialized || _dispatcherQueueTimer is null)
+ {
+ return;
+ }
+
+ _dispatcherQueueTimer.Debounce(Reload, ReloadDebounceInterval);
+ }
+
+ private ElementTheme GetElementTheme(ElementTheme theme)
+ {
+ return theme switch
+ {
+ ElementTheme.Light => ElementTheme.Light,
+ ElementTheme.Dark => ElementTheme.Dark,
+ _ => _uiSettings.GetColorValue(UIColorType.Background).CalculateBrightness() < 0.5
+ ? ElementTheme.Dark
+ : ElementTheme.Light,
+ };
+ }
+
+ private void SettingsOnSettingsChanged(SettingsModel sender, object? args)
+ {
+ RequestReload();
+ }
+
+ private void UiSettings_ColorValuesChanged(UISettings sender, object args)
+ {
+ RequestReload();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _dispatcherQueueTimer?.Stop();
+ _uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged;
+ _settings.SettingsChanged -= SettingsOnSettingsChanged;
+ }
+
+ private sealed class InternalThemeState
+ {
+ public required ThemeSnapshot Snapshot { get; init; }
+
+ public required IThemeProvider Provider { get; init; }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs
new file mode 100644
index 0000000000..5c250b94ef
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.Core.Common.Services;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI.Xaml;
+
+namespace Microsoft.CmdPal.UI.Services;
+
+///
+/// Synchronizes a window's theme with .
+///
+internal sealed partial class WindowThemeSynchronizer : IDisposable
+{
+ private readonly IThemeService _themeService;
+ private readonly Window _window;
+
+ ///
+ /// Initializes a new instance of the class and subscribes to theme changes.
+ ///
+ /// The theme service to monitor for changes.
+ /// The window to synchronize.
+ /// Thrown when or is null.
+ public WindowThemeSynchronizer(IThemeService themeService, Window window)
+ {
+ _themeService = themeService ?? throw new ArgumentNullException(nameof(themeService));
+ _window = window ?? throw new ArgumentNullException(nameof(window));
+ _themeService.ThemeChanged += ThemeServiceOnThemeChanged;
+ }
+
+ ///
+ /// Unsubscribes from theme change events.
+ ///
+ public void Dispose()
+ {
+ _themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
+ }
+
+ ///
+ /// Applies the current theme to the window when theme changes occur.
+ ///
+ private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
+ {
+ if (_window.Content is not FrameworkElement fe)
+ {
+ return;
+ }
+
+ var dispatcherQueue = fe.DispatcherQueue;
+
+ if (dispatcherQueue is not null && dispatcherQueue.HasThreadAccess)
+ {
+ ApplyRequestedTheme(fe);
+ }
+ else
+ {
+ dispatcherQueue?.TryEnqueue(() => ApplyRequestedTheme(fe));
+ }
+ }
+
+ private void ApplyRequestedTheme(FrameworkElement fe)
+ {
+ // LOAD BEARING: Changing the RequestedTheme to Dark then Light then target forces
+ // a refresh of the theme.
+ fe.RequestedTheme = ElementTheme.Dark;
+ fe.RequestedTheme = ElementTheme.Light;
+ fe.RequestedTheme = _themeService.Current.Theme;
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml
new file mode 100644
index 0000000000..b9f31d8443
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs
new file mode 100644
index 0000000000..c93c551db3
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics;
+using ManagedCommon;
+using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Documents;
+using Microsoft.Windows.Storage.Pickers;
+
+namespace Microsoft.CmdPal.UI.Settings;
+
+///
+/// An empty page that can be used on its own or navigated to within a Frame.
+///
+public sealed partial class AppearancePage : Page
+{
+ private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
+
+ internal SettingsViewModel ViewModel { get; }
+
+ public AppearancePage()
+ {
+ InitializeComponent();
+
+ var settings = App.Current.Services.GetService()!;
+ var themeService = App.Current.Services.GetService()!;
+ var topLevelCommandManager = App.Current.Services.GetService()!;
+ ViewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService);
+ }
+
+ private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ if (XamlRoot?.ContentIslandEnvironment is null)
+ {
+ return;
+ }
+
+ var windowId = XamlRoot?.ContentIslandEnvironment?.AppWindowId ?? new WindowId(0);
+
+ var picker = new FileOpenPicker(windowId)
+ {
+ CommitButtonText = ViewModels.Properties.Resources.builtin_settings_appearance_pick_background_image_title!,
+ SuggestedStartLocation = PickerLocationId.PicturesLibrary,
+ ViewMode = PickerViewMode.Thumbnail,
+ };
+
+ string[] extensions = [".png", ".bmp", ".jpg", ".jpeg", ".jfif", ".gif", ".tiff", ".tif", ".webp", ".jxr"];
+ foreach (var ext in extensions)
+ {
+ picker.FileTypeFilter!.Add(ext);
+ }
+
+ var file = await picker.PickSingleFileAsync()!;
+ if (file != null)
+ {
+ ViewModel.Appearance.BackgroundImagePath = file.Path ?? string.Empty;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to pick background image file", ex);
+ }
+ }
+
+ private void OpenWindowsColorsSettings_Click(Hyperlink sender, HyperlinkClickEventArgs args)
+ {
+ // LOAD BEARING (or BEAR LOADING?): Process.Start with UseShellExecute inside a XAML input event can trigger WinUI reentrancy
+ // and cause FailFast crashes. Task.Run moves the call off the UI thread to prevent hard process termination.
+ Task.Run(() =>
+ {
+ try
+ {
+ _ = Process.Start(new ProcessStartInfo("ms-settings:colors") { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to open Windows Settings", ex);
+ }
+ });
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs
index c197f22f32..360bce9f0b 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs
@@ -5,6 +5,7 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI.Controls;
using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -24,7 +25,8 @@ public sealed partial class ExtensionsPage : Page
var settings = App.Current.Services.GetService()!;
var topLevelCommandManager = App.Current.Services.GetService()!;
- viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler);
+ var themeService = App.Current.Services.GetService()!;
+ viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService);
}
private void SettingsCard_Click(object sender, RoutedEventArgs e)
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
index eb0264a683..65fa536c5b 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml
@@ -81,35 +81,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs
index 84308e5574..e2a5b7938c 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml.Controls;
using Windows.ApplicationModel;
@@ -21,7 +22,8 @@ public sealed partial class GeneralPage : Page
var settings = App.Current.Services.GetService()!;
var topLevelCommandManager = App.Current.Services.GetService()!;
- viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler);
+ var themeService = App.Current.Services.GetService()!;
+ viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService);
}
public string ApplicationVersion
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml
index dc3cf9fd3b..e451fe7abe 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml
@@ -62,6 +62,11 @@
x:Uid="Settings_GeneralPage_NavigationViewItem_General"
Icon="{ui:FontIcon Glyph=}"
Tag="General" />
+
typeof(GeneralPage),
+ "Appearance" => typeof(AppearancePage),
"Extensions" => typeof(ExtensionsPage),
_ => null,
};
@@ -248,6 +249,12 @@ public sealed partial class SettingsWindow : WindowEx,
var pageType = RS_.GetString("Settings_PageTitles_GeneralPage");
BreadCrumbs.Add(new(pageType, pageType));
}
+ else if (e.SourcePageType == typeof(AppearancePage))
+ {
+ NavView.SelectedItem = AppearancePageNavItem;
+ var pageType = RS_.GetString("Settings_PageTitles_AppearancePage");
+ BreadCrumbs.Add(new(pageType, pageType));
+ }
else if (e.SourcePageType == typeof(ExtensionsPage))
{
NavView.SelectedItem = ExtensionPageNavItem;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
index c7eafbda19..82d8d56699 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
@@ -550,9 +550,69 @@ Right-click to remove the key combination, thereby deactivating the shortcut.
Automatically returns to home page after a period of inactivity when Command Palette is closed
+
+ Personalization
+
+
+ App theme mode
+
+
+ Select which app theme to display
+
+
+ Appearance
+
+
+ Use system settings
+
+
+ Light
+
+
+ Dark
+
+
+ Color tint
+
+
+ Color intensity
+
+
+ Choose color
+
+
+ Use default
+
+
+ Use default color
+
+
+ Windows colors
+
+
+ Background image
+
+
+ Background image opacity
+
+
+ Background image fit
+
+
+ Fill
+
+
+ Fit
+
+
+ Stretch
+
General
+
+ Personalization
+
Extensions
@@ -577,6 +637,75 @@ Right-click to remove the key combination, thereby deactivating the shortcut.
Settings
+
+ Custom colors
+
+
+ None
+
+
+ Custom color
+
+
+ Accent color
+
+
+ Image
+
+
+ Browse...
+
+
+ Remove image
+
+
+ Background color
+
+
+ Choose a custom background color or use the current accent color
+
+
+ Background image
+
+
+ No settings
+
+
+ Background
+
+
+ Choose a custom background color or image
+
+
+ System accent color
+
+
+ Personalization › Colors
+
+
+ Background image blur
+
+
+ Background image brightness
+
+
+ Restore defaults
+
+
+ Reset
+
+
+ Change the system accent in Windows Settings:
+
+
+ Light
+
+
+ Dark
+
+
+ Use system settings
+
Include in the Global result
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml
index edca3f479c..728cd3ef4e 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml
@@ -4,37 +4,5 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml
new file mode 100644
index 0000000000..e1dfe7f45c
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml
new file mode 100644
index 0000000000..53b46d39d6
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs
new file mode 100644
index 0000000000..624fa2da73
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs
@@ -0,0 +1,161 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CmdPal.UI.ViewModels.Commands;
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
+
+[TestClass]
+public partial class MainListPageResultFactoryTests
+{
+ private sealed partial class MockListItem : IListItem
+ {
+ public string Title { get; set; } = string.Empty;
+
+ public string Subtitle { get; set; } = string.Empty;
+
+ public ICommand Command => new NoOpCommand();
+
+ public IDetails? Details => null;
+
+ public IIconInfo? Icon => null;
+
+ public string Section => throw new NotImplementedException();
+
+ public ITag[] Tags => throw new NotImplementedException();
+
+ public string TextToSuggest => throw new NotImplementedException();
+
+ public IContextItem[] MoreCommands => throw new NotImplementedException();
+
+#pragma warning disable CS0067 // The event is never used
+ public event TypedEventHandler