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 12801238dc..25eb1677c6 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
@@ -1116,6 +1120,7 @@ NEWPLUSSHELLEXTENSIONWIN
newrow
nicksnettravels
NIF
+nightlight
NLog
NLSTEXT
NMAKE
@@ -1487,6 +1492,7 @@ rgh
rgn
rgs
rguid
+rhk
RIDEV
RIGHTSCROLLBAR
riid
@@ -1592,6 +1598,7 @@ SHGDNF
SHGFI
SHIL
shinfo
+shk
shlwapi
shobjidl
SHORTCUTATLEAST
@@ -1804,6 +1811,7 @@ tlbimp
tlc
tmain
TNP
+toolgood
Toolhelp
toolwindow
TOPDOWNDIB
@@ -1852,6 +1860,8 @@ uitests
UITo
ULONGLONG
ums
+UMax
+UMin
uncompilable
UNCPRIORITY
UNDNAME
@@ -1863,8 +1873,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..cf1f515e78 100644
--- a/.pipelines/versionAndSignCheck.ps1
+++ b/.pipelines/versionAndSignCheck.ps1
@@ -27,7 +27,8 @@ $versionExceptions = @(
"WyHash.dll",
"Microsoft.Recognizers.Text.DataTypes.TimexExpression.dll",
"ObjectModelCsProjection.dll",
- "RendererCsProjection.dll") -join '|';
+ "RendererCsProjection.dll",
+ "Microsoft.ML.OnnxRuntime.dll") -join '|';
$nullVersionExceptions = @(
"SkiaSharp.Views.WinUI.Native.dll",
"libSkiaSharp.dll",
@@ -52,7 +53,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 6744b991aa..60567e30b8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -38,6 +38,7 @@
+
@@ -118,6 +119,7 @@
+
diff --git a/NOTICE.md b/NOTICE.md
index 6ca3cbfceb..23efb64864 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -75,6 +75,37 @@ OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to
```
+### ToolGood.Words.Pinyin
+
+We use the ToolGood.Words.Pinyin NuGet package for converting Chinese characters to pinyin.
+
+**Source**: [https://github.com/toolgood/ToolGood.Words.Pinyin](https://github.com/toolgood/ToolGood.Words.Pinyin)
+
+```
+MIT License
+
+Copyright (c) 2020 ToolGood
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+
## Utility: Command Palette Built-in Extensions
### Calculator
@@ -1532,6 +1563,7 @@ SOFTWARE.
- SkiaSharp.Views.WinUI
- StreamJsonRpc
- StyleCop.Analyzers
+- ToolGood.Words.Pinyin
- UnicodeInformation
- UnitsNet
- UTF.Unknown
diff --git a/PowerToys.slnx b/PowerToys.slnx
index b2a003f09b..772bc44fd7 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -370,6 +370,10 @@
+
+
+
+
diff --git a/doc/devdocs/core/settings/settings-implementation.md b/doc/devdocs/core/settings/settings-implementation.md
index defe59a3fa..65d0d27c73 100644
--- a/doc/devdocs/core/settings/settings-implementation.md
+++ b/doc/devdocs/core/settings/settings-implementation.md
@@ -38,7 +38,7 @@ For C# modules, the settings are accessed through the `SettingsUtils` class in t
using Microsoft.PowerToys.Settings.UI.Library;
// Read settings
-var settings = SettingsUtils.GetSettings("ModuleName");
+var settings = SettingsUtils.Default.GetSettings("ModuleName");
bool enabled = settings.Enabled;
```
@@ -49,7 +49,7 @@ using Microsoft.PowerToys.Settings.UI.Library;
// Write settings
settings.Enabled = true;
-SettingsUtils.SaveSettings(settings.ToJsonString(), "ModuleName");
+SettingsUtils.Default.SaveSettings(settings.ToJsonString(), "ModuleName");
```
## Settings Handling in Modules
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 833ec4f19d..81e5e3c180 100644
--- a/src/common/UITestAutomation/SettingsConfigHelper.cs
+++ b/src/common/UITestAutomation/SettingsConfigHelper.cs
@@ -21,19 +21,18 @@ namespace Microsoft.PowerToys.UITest
public class SettingsConfigHelper
{
private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true };
- private static readonly SettingsUtils SettingsUtils = new SettingsUtils();
+ private static readonly SettingsUtils SettingsUtils = SettingsUtils.Default;
///
/// 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/common/utils/registry.h b/src/common/utils/registry.h
index 059589352d..c9770bbea3 100644
--- a/src/common/utils/registry.h
+++ b/src/common/utils/registry.h
@@ -16,9 +16,54 @@
namespace registry
{
+ namespace detail
+ {
+ struct on_exit
+ {
+ std::function f;
+
+ on_exit(std::function f) :
+ f{ std::move(f) } {}
+ ~on_exit() { f(); }
+ };
+
+ template
+ struct overloaded : Ts...
+ {
+ using Ts::operator()...;
+ };
+
+ template
+ overloaded(Ts...) -> overloaded;
+
+ inline const wchar_t* getScopeName(HKEY scope)
+ {
+ if (scope == HKEY_LOCAL_MACHINE)
+ {
+ return L"HKLM";
+ }
+ else if (scope == HKEY_CURRENT_USER)
+ {
+ return L"HKCU";
+ }
+ else if (scope == HKEY_CLASSES_ROOT)
+ {
+ return L"HKCR";
+ }
+ else
+ {
+ return L"HK??";
+ }
+ }
+ }
+
namespace install_scope
{
const wchar_t INSTALL_SCOPE_REG_KEY[] = L"Software\\Classes\\powertoys\\";
+ const wchar_t UNINSTALL_REG_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall";
+
+ // Bundle UpgradeCode from PowerToys.wxs (with braces as stored in registry)
+ const wchar_t BUNDLE_UPGRADE_CODE[] = L"{6341382D-C0A9-4238-9188-BE9607E3FAB2}";
enum class InstallScope
{
@@ -26,8 +71,67 @@ namespace registry
PerUser,
};
+ // Helper function to find PowerToys bundle in Windows Uninstall registry by BundleUpgradeCode
+ inline bool find_powertoys_bundle_in_uninstall_registry(HKEY rootKey)
+ {
+ HKEY uninstallKey{};
+ if (RegOpenKeyExW(rootKey, UNINSTALL_REG_KEY, 0, KEY_READ, &uninstallKey) != ERROR_SUCCESS)
+ {
+ return false;
+ }
+ detail::on_exit closeUninstallKey{ [uninstallKey] { RegCloseKey(uninstallKey); } };
+
+ DWORD index = 0;
+ wchar_t subKeyName[256];
+
+ // Enumerate all subkeys under Uninstall
+ while (RegEnumKeyW(uninstallKey, index++, subKeyName, 256) == ERROR_SUCCESS)
+ {
+ HKEY productKey{};
+ if (RegOpenKeyExW(uninstallKey, subKeyName, 0, KEY_READ, &productKey) != ERROR_SUCCESS)
+ {
+ continue;
+ }
+ detail::on_exit closeProductKey{ [productKey] { RegCloseKey(productKey); } };
+
+ // Check BundleUpgradeCode value (specific to WiX Bundle installations)
+ wchar_t bundleUpgradeCode[256]{};
+ DWORD bundleUpgradeCodeSize = sizeof(bundleUpgradeCode);
+
+ if (RegQueryValueExW(productKey, L"BundleUpgradeCode", nullptr, nullptr,
+ reinterpret_cast(bundleUpgradeCode), &bundleUpgradeCodeSize) == ERROR_SUCCESS)
+ {
+ if (_wcsicmp(bundleUpgradeCode, BUNDLE_UPGRADE_CODE) == 0)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
inline const InstallScope get_current_install_scope()
{
+ // 1. Check HKCU Uninstall registry first (user-level bundle)
+ // Note: MSI components are always in HKLM regardless of install scope,
+ // but the Bundle entry will be in HKCU for per-user installations
+ if (find_powertoys_bundle_in_uninstall_registry(HKEY_CURRENT_USER))
+ {
+ Logger::info(L"Found user-level PowerToys bundle via BundleUpgradeCode in HKCU");
+ return InstallScope::PerUser;
+ }
+
+ // 2. Check HKLM Uninstall registry (machine-level bundle)
+ if (find_powertoys_bundle_in_uninstall_registry(HKEY_LOCAL_MACHINE))
+ {
+ Logger::info(L"Found machine-level PowerToys bundle via BundleUpgradeCode in HKLM");
+ return InstallScope::PerMachine;
+ }
+
+ // 3. Fallback to legacy custom registry key detection
+ Logger::info(L"PowerToys bundle not found in Uninstall registry, falling back to legacy detection");
+
// Open HKLM key
HKEY perMachineKey{};
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE,
@@ -45,6 +149,7 @@ namespace registry
&perUserKey) != ERROR_SUCCESS)
{
// both keys are missing
+ Logger::warn(L"No PowerToys installation detected, defaulting to PerMachine");
return InstallScope::PerMachine;
}
else
@@ -96,47 +201,6 @@ namespace registry
template
inline constexpr bool always_false_v = false;
- namespace detail
- {
- struct on_exit
- {
- std::function f;
-
- on_exit(std::function f) :
- f{ std::move(f) } {}
- ~on_exit() { f(); }
- };
-
- template
- struct overloaded : Ts...
- {
- using Ts::operator()...;
- };
-
- template
- overloaded(Ts...) -> overloaded;
-
- inline const wchar_t* getScopeName(HKEY scope)
- {
- if (scope == HKEY_LOCAL_MACHINE)
- {
- return L"HKLM";
- }
- else if (scope == HKEY_CURRENT_USER)
- {
- return L"HKCU";
- }
- else if (scope == HKEY_CLASSES_ROOT)
- {
- return L"HKCR";
- }
- else
- {
- return L"HK??";
- }
- }
- }
-
struct ValueChange
{
using value_t = std::variant;
diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs
index ad7eb1d200..8d43f48a77 100644
--- a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs
+++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs
@@ -18,7 +18,7 @@ namespace PowerToys.DSC.UnitTests.SettingsResourceTests;
public abstract class SettingsResourceModuleTest : BaseDscTest
where TSettingsConfig : ISettingsConfig, new()
{
- private readonly SettingsUtils _settingsUtils = new();
+ private readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
private TSettingsConfig _originalSettings;
protected TSettingsConfig DefaultSettings => new();
diff --git a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs
index 7fcce03d33..9d87b1e773 100644
--- a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs
+++ b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs
@@ -18,7 +18,7 @@ namespace PowerToys.DSC.Models.FunctionData;
public sealed class SettingsFunctionData : BaseFunctionData, ISettingsFunctionData
where TSettingsConfig : ISettingsConfig, new()
{
- private static readonly SettingsUtils _settingsUtils = new();
+ private static readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
private static readonly TSettingsConfig _settingsConfig = new();
private readonly SettingsResourceObject _input;
diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs
index 80376e5f72..5bef6389f0 100644
--- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs
+++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs
@@ -198,20 +198,14 @@ namespace AdvancedPaste.Pages
}
}
- private async void ClipboardHistory_ItemInvoked(ItemsView sender, ItemsViewItemInvokedEventArgs args)
+ private void ClipboardHistory_ItemInvoked(ItemsView sender, ItemsViewItemInvokedEventArgs args)
{
- if (args.InvokedItem is ClipboardItem item)
+ if (args.InvokedItem is ClipboardItem item && item.Item is not null)
{
PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteClipboardItemClicked());
- if (!string.IsNullOrEmpty(item.Content))
- {
- ClipboardHelper.SetTextContent(item.Content);
- }
- else if (item.Image is not null)
- {
- RandomAccessStreamReference image = await item.Item.Content.GetBitmapAsync();
- ClipboardHelper.SetImageContent(image);
- }
+
+ // Use SetHistoryItemAsContent to set the clipboard content without creating a new history entry
+ Clipboard.SetHistoryItemAsContent(item.Item);
}
}
}
diff --git a/src/modules/Hosts/Hosts/Settings/UserSettings.cs b/src/modules/Hosts/Hosts/Settings/UserSettings.cs
index 038823f0e2..bd69a336eb 100644
--- a/src/modules/Hosts/Hosts/Settings/UserSettings.cs
+++ b/src/modules/Hosts/Hosts/Settings/UserSettings.cs
@@ -62,7 +62,7 @@ namespace Hosts.Settings
public UserSettings()
{
- _settingsUtils = new SettingsUtils();
+ _settingsUtils = SettingsUtils.Default;
var defaultSettings = new HostsProperties();
ShowStartupWarning = defaultSettings.ShowStartupWarning;
LoopbackDuplicates = defaultSettings.LoopbackDuplicates;
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/MeasureTool/MeasureToolUI/Settings.cs b/src/modules/MeasureTool/MeasureToolUI/Settings.cs
index 4e8cd99b18..ac48339ad6 100644
--- a/src/modules/MeasureTool/MeasureToolUI/Settings.cs
+++ b/src/modules/MeasureTool/MeasureToolUI/Settings.cs
@@ -11,7 +11,7 @@ namespace MeasureToolUI
{
public sealed class Settings
{
- private static readonly SettingsUtils ModuleSettings = new();
+ private static readonly SettingsUtils ModuleSettings = SettingsUtils.Default;
public MeasureToolMeasureStyle DefaultMeasureStyle
{
diff --git a/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs b/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs
index efe721e873..6e19043547 100644
--- a/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs
+++ b/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs
@@ -53,7 +53,7 @@ internal sealed class SettingsHelper
lock (this.LockObject)
{
{
- var settingsUtils = new SettingsUtils();
+ var settingsUtils = SettingsUtils.Default;
// set this to 1 to disable retries
var remainingRetries = 5;
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/MouseWithoutBorders/App/Class/Setting.cs b/src/modules/MouseWithoutBorders/App/Class/Setting.cs
index c526c70976..7d440a6125 100644
--- a/src/modules/MouseWithoutBorders/App/Class/Setting.cs
+++ b/src/modules/MouseWithoutBorders/App/Class/Setting.cs
@@ -192,7 +192,7 @@ namespace MouseWithoutBorders.Class
internal Settings()
{
- _settingsUtils = new SettingsUtils();
+ _settingsUtils = SettingsUtils.Default;
_watcher = SettingsHelper.GetFileWatcher("MouseWithoutBorders", "settings.json", () =>
{
diff --git a/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs b/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs
index 0c57171f3a..053f3b5f30 100644
--- a/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs
+++ b/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs
@@ -29,7 +29,7 @@ namespace PowerOCR.Settings
[ImportingConstructor]
public UserSettings(Helpers.IThrottledActionInvoker throttledActionInvoker)
{
- _settingsUtils = new SettingsUtils();
+ _settingsUtils = SettingsUtils.Default;
ActivationShortcut = new SettingItem(DefaultActivationShortcut);
PreferredLanguage = new SettingItem(string.Empty);
diff --git a/src/modules/Workspaces/WorkspacesEditor/Utils/Settings.cs b/src/modules/Workspaces/WorkspacesEditor/Utils/Settings.cs
index 29dd65d56f..0955e4019e 100644
--- a/src/modules/Workspaces/WorkspacesEditor/Utils/Settings.cs
+++ b/src/modules/Workspaces/WorkspacesEditor/Utils/Settings.cs
@@ -9,7 +9,7 @@ namespace WorkspacesEditor.Utils
public class Settings
{
private const string WorkspacesModuleName = "Workspaces";
- private static readonly SettingsUtils _settingsUtils = new();
+ private static readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
public static WorkspacesSettings ReadSettings()
{
diff --git a/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs b/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs
index d05498d62e..30b28f4cd8 100644
--- a/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs
+++ b/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs
@@ -133,7 +133,7 @@ namespace WorkspacesEditor.ViewModels
_orderByIndex = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
settings.Properties.SortBy = (WorkspacesProperties.SortByProperty)value;
- settings.Save(new SettingsUtils());
+ settings.Save(SettingsUtils.Default);
}
}
diff --git a/src/modules/awake/Awake/Core/Manager.cs b/src/modules/awake/Awake/Core/Manager.cs
index c6aa1c2efb..cc3e461b20 100644
--- a/src/modules/awake/Awake/Core/Manager.cs
+++ b/src/modules/awake/Awake/Core/Manager.cs
@@ -60,7 +60,7 @@ namespace Awake.Core
{
_tokenSource = new CancellationTokenSource();
_stateQueue = [];
- ModuleSettings = new SettingsUtils();
+ ModuleSettings = SettingsUtils.Default;
}
internal static void StartMonitor()
diff --git a/src/modules/awake/Awake/Program.cs b/src/modules/awake/Awake/Program.cs
index 4d6d20bc96..b5c3102ba0 100644
--- a/src/modules/awake/Awake/Program.cs
+++ b/src/modules/awake/Awake/Program.cs
@@ -51,7 +51,7 @@ namespace Awake
private static async Task Main(string[] args)
{
- _settingsUtils = new SettingsUtils();
+ _settingsUtils = SettingsUtils.Default;
LockMutex = new Mutex(true, Core.Constants.AppName, out bool instantiated);
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
+/// Used to navigate left in a grid view when pressing the Left arrow key in the SearchBox.
+///
+public record NavigateLeftCommand;
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs
new file mode 100644
index 0000000000..3cfb05913d
--- /dev/null
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.CmdPal.Core.ViewModels.Messages;
+
+///
+/// Used to navigate right in a grid view when pressing the Right arrow key in the SearchBox.
+///
+public record NavigateRightCommand;
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs
index a1c4696b35..93fae9beff 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs
@@ -3,13 +3,14 @@
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
-using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Core.ViewModels;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public partial class SeparatorViewModel() :
+ CommandItem,
IContextItemViewModel,
IFilterItemViewModel,
ISeparatorContextItem,
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs
index 2abbd83d3e..16ca5b1fca 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs
@@ -14,6 +14,7 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ShellViewModel : ObservableObject,
+ IDisposable,
IRecipient,
IRecipient
{
@@ -460,4 +461,12 @@ public partial class ShellViewModel : ObservableObject,
{
_navigationCts?.Cancel();
}
+
+ public void Dispose()
+ {
+ _handleInvokeTask?.Dispose();
+ _navigationCts?.Dispose();
+
+ GC.SuppressFinalize(this);
+ }
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs
new file mode 100644
index 0000000000..71e150a7d2
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs
@@ -0,0 +1,390 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.WinUI;
+using Microsoft.CmdPal.UI.ViewModels.Services;
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+using Windows.UI.ViewManagement;
+
+namespace Microsoft.CmdPal.UI.ViewModels;
+
+public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable
+{
+ private static readonly ObservableCollection WindowsColorSwatches = [
+
+ // row 0
+ Color.FromArgb(255, 255, 185, 0), // #ffb900
+ Color.FromArgb(255, 255, 140, 0), // #ff8c00
+ Color.FromArgb(255, 247, 99, 12), // #f7630c
+ Color.FromArgb(255, 202, 80, 16), // #ca5010
+ Color.FromArgb(255, 218, 59, 1), // #da3b01
+ Color.FromArgb(255, 239, 105, 80), // #ef6950
+
+ // row 1
+ Color.FromArgb(255, 209, 52, 56), // #d13438
+ Color.FromArgb(255, 255, 67, 67), // #ff4343
+ Color.FromArgb(255, 231, 72, 86), // #e74856
+ Color.FromArgb(255, 232, 17, 35), // #e81123
+ Color.FromArgb(255, 234, 0, 94), // #ea005e
+ Color.FromArgb(255, 195, 0, 82), // #c30052
+
+ // row 2
+ Color.FromArgb(255, 227, 0, 140), // #e3008c
+ Color.FromArgb(255, 191, 0, 119), // #bf0077
+ Color.FromArgb(255, 194, 57, 179), // #c239b3
+ Color.FromArgb(255, 154, 0, 137), // #9a0089
+ Color.FromArgb(255, 0, 120, 212), // #0078d4
+ Color.FromArgb(255, 0, 99, 177), // #0063b1
+
+ // row 3
+ Color.FromArgb(255, 142, 140, 216), // #8e8cd8
+ Color.FromArgb(255, 107, 105, 214), // #6b69d6
+ Color.FromArgb(255, 135, 100, 184), // #8764b8
+ Color.FromArgb(255, 116, 77, 169), // #744da9
+ Color.FromArgb(255, 177, 70, 194), // #b146c2
+ Color.FromArgb(255, 136, 23, 152), // #881798
+
+ // row 4
+ Color.FromArgb(255, 0, 153, 188), // #0099bc
+ Color.FromArgb(255, 45, 125, 154), // #2d7d9a
+ Color.FromArgb(255, 0, 183, 195), // #00b7c3
+ Color.FromArgb(255, 3, 131, 135), // #038387
+ Color.FromArgb(255, 0, 178, 148), // #00b294
+ Color.FromArgb(255, 1, 133, 116), // #018574
+
+ // row 5
+ Color.FromArgb(255, 0, 204, 106), // #00cc6a
+ Color.FromArgb(255, 16, 137, 62), // #10893e
+ Color.FromArgb(255, 122, 117, 116), // #7a7574
+ Color.FromArgb(255, 93, 90, 88), // #5d5a58
+ Color.FromArgb(255, 104, 118, 138), // #68768a
+ Color.FromArgb(255, 81, 92, 107), // #515c6b
+
+ // row 6
+ Color.FromArgb(255, 86, 124, 115), // #567c73
+ Color.FromArgb(255, 72, 104, 96), // #486860
+ Color.FromArgb(255, 73, 130, 5), // #498205
+ Color.FromArgb(255, 16, 124, 16), // #107c10
+ Color.FromArgb(255, 118, 118, 118), // #767676
+ Color.FromArgb(255, 76, 74, 72), // #4c4a48
+
+ // row 7
+ Color.FromArgb(255, 105, 121, 126), // #69797e
+ Color.FromArgb(255, 74, 84, 89), // #4a5459
+ Color.FromArgb(255, 100, 124, 100), // #647c64
+ Color.FromArgb(255, 82, 94, 84), // #525e54
+ Color.FromArgb(255, 132, 117, 69), // #847545
+ Color.FromArgb(255, 126, 115, 95), // #7e735f
+ ];
+
+ private readonly SettingsModel _settings;
+ private readonly UISettings _uiSettings;
+ private readonly IThemeService _themeService;
+ private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+ private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread();
+
+ private ElementTheme? _elementThemeOverride;
+ private Color _currentSystemAccentColor;
+
+ public ObservableCollection Swatches => WindowsColorSwatches;
+
+ public int ThemeIndex
+ {
+ get => (int)_settings.Theme;
+ set => Theme = (UserTheme)value;
+ }
+
+ public UserTheme Theme
+ {
+ get => _settings.Theme;
+ set
+ {
+ if (_settings.Theme != value)
+ {
+ _settings.Theme = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ThemeIndex));
+ Save();
+ }
+ }
+ }
+
+ public ColorizationMode ColorizationMode
+ {
+ get => _settings.ColorizationMode;
+ set
+ {
+ if (_settings.ColorizationMode != value)
+ {
+ _settings.ColorizationMode = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ColorizationModeIndex));
+ OnPropertyChanged(nameof(IsCustomTintVisible));
+ OnPropertyChanged(nameof(IsCustomTintIntensityVisible));
+ OnPropertyChanged(nameof(IsBackgroundControlsVisible));
+ OnPropertyChanged(nameof(IsNoBackgroundVisible));
+ OnPropertyChanged(nameof(IsAccentColorControlsVisible));
+
+ if (value == ColorizationMode.WindowsAccentColor)
+ {
+ ThemeColor = _currentSystemAccentColor;
+ }
+
+ IsColorizationDetailsExpanded = value != ColorizationMode.None;
+
+ Save();
+ }
+ }
+ }
+
+ public int ColorizationModeIndex
+ {
+ get => (int)_settings.ColorizationMode;
+ set => ColorizationMode = (ColorizationMode)value;
+ }
+
+ public Color ThemeColor
+ {
+ get => _settings.CustomThemeColor;
+ set
+ {
+ if (_settings.CustomThemeColor != value)
+ {
+ _settings.CustomThemeColor = value;
+
+ OnPropertyChanged();
+
+ if (ColorIntensity == 0)
+ {
+ ColorIntensity = 100;
+ }
+
+ Save();
+ }
+ }
+ }
+
+ public int ColorIntensity
+ {
+ get => _settings.CustomThemeColorIntensity;
+ set
+ {
+ _settings.CustomThemeColorIntensity = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+
+ public string BackgroundImagePath
+ {
+ get => _settings.BackgroundImagePath ?? string.Empty;
+ set
+ {
+ if (_settings.BackgroundImagePath != value)
+ {
+ _settings.BackgroundImagePath = value;
+ OnPropertyChanged();
+
+ if (BackgroundImageOpacity == 0)
+ {
+ BackgroundImageOpacity = 100;
+ }
+
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageOpacity
+ {
+ get => _settings.BackgroundImageOpacity;
+ set
+ {
+ if (_settings.BackgroundImageOpacity != value)
+ {
+ _settings.BackgroundImageOpacity = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageBrightness
+ {
+ get => _settings.BackgroundImageBrightness;
+ set
+ {
+ if (_settings.BackgroundImageBrightness != value)
+ {
+ _settings.BackgroundImageBrightness = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageBlurAmount
+ {
+ get => _settings.BackgroundImageBlurAmount;
+ set
+ {
+ if (_settings.BackgroundImageBlurAmount != value)
+ {
+ _settings.BackgroundImageBlurAmount = value;
+ OnPropertyChanged();
+ Save();
+ }
+ }
+ }
+
+ public BackgroundImageFit BackgroundImageFit
+ {
+ get => _settings.BackgroundImageFit;
+ set
+ {
+ if (_settings.BackgroundImageFit != value)
+ {
+ _settings.BackgroundImageFit = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(BackgroundImageFitIndex));
+ Save();
+ }
+ }
+ }
+
+ public int BackgroundImageFitIndex
+ {
+ // Naming between UI facing string and enum is a bit confusing, but the enum fields
+ // are based on XAML Stretch enum values. So I'm choosing to keep the confusion here, close
+ // to the UI.
+ // - BackgroundImageFit.Fill corresponds to "Stretch"
+ // - BackgroundImageFit.UniformToFill corresponds to "Fill"
+ get => BackgroundImageFit switch
+ {
+ BackgroundImageFit.Fill => 1,
+ _ => 0,
+ };
+ set => BackgroundImageFit = value switch
+ {
+ 1 => BackgroundImageFit.Fill,
+ _ => BackgroundImageFit.UniformToFill,
+ };
+ }
+
+ [ObservableProperty]
+ public partial bool IsColorizationDetailsExpanded { get; set; }
+
+ public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image;
+
+ public bool IsCustomTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
+
+ public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image;
+
+ public bool IsNoBackgroundVisible => _settings.ColorizationMode is ColorizationMode.None;
+
+ public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor;
+
+ public AcrylicBackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f);
+
+ public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme;
+
+ public Color EffectiveThemeColor => ColorizationMode switch
+ {
+ ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
+ ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
+ _ => Colors.Transparent,
+ };
+
+ // Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen).
+ public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f);
+
+ public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0;
+
+ public ImageSource? EffectiveBackgroundImageSource =>
+ ColorizationMode is ColorizationMode.Image
+ && !string.IsNullOrWhiteSpace(BackgroundImagePath)
+ && Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
+ ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
+ : null;
+
+ public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings)
+ {
+ _themeService = themeService;
+ _themeService.ThemeChanged += ThemeServiceOnThemeChanged;
+ _settings = settings;
+
+ _uiSettings = new UISettings();
+ _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged;
+ UpdateAccentColor(_uiSettings);
+
+ Reapply();
+
+ IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None;
+ }
+
+ private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender));
+
+ private void UpdateAccentColor(UISettings sender)
+ {
+ _currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent);
+ if (ColorizationMode == ColorizationMode.WindowsAccentColor)
+ {
+ ThemeColor = _currentSystemAccentColor;
+ }
+ }
+
+ private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
+ {
+ _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
+ }
+
+ private void Save()
+ {
+ SettingsModel.SaveSettings(_settings);
+ _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
+ }
+
+ private void Reapply()
+ {
+ // Theme services recalculates effective color and opacity based on current settings.
+ EffectiveBackdrop = _themeService.Current.BackdropParameters;
+ OnPropertyChanged(nameof(EffectiveBackdrop));
+ OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness));
+ OnPropertyChanged(nameof(EffectiveBackgroundImageSource));
+ OnPropertyChanged(nameof(EffectiveThemeColor));
+ OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount));
+
+ // LOAD BEARING:
+ // We need to cycle through the EffectiveTheme property to force reload of resources.
+ _elementThemeOverride = ElementTheme.Light;
+ OnPropertyChanged(nameof(EffectiveTheme));
+ _elementThemeOverride = ElementTheme.Dark;
+ OnPropertyChanged(nameof(EffectiveTheme));
+ _elementThemeOverride = null;
+ OnPropertyChanged(nameof(EffectiveTheme));
+ }
+
+ [RelayCommand]
+ private void ResetBackgroundImageProperties()
+ {
+ BackgroundImageBrightness = 0;
+ BackgroundImageBlurAmount = 0;
+ BackgroundImageFit = BackgroundImageFit.UniformToFill;
+ BackgroundImageOpacity = 100;
+ ColorIntensity = 0;
+ }
+
+ public void Dispose()
+ {
+ _uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged;
+ _themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs
new file mode 100644
index 0000000000..52102df30a
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.CmdPal.UI.ViewModels;
+
+public enum BackgroundImageFit
+{
+ Fill,
+ UniformToFill,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs
new file mode 100644
index 0000000000..57a65f1882
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.CmdPal.UI.ViewModels;
+
+public enum ColorizationMode
+{
+ None,
+ WindowsAccentColor,
+ CustomColor,
+ Image,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
index 003a0bfb9e..4118ac64db 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;
@@ -44,6 +45,9 @@ public partial class MainListPage : DynamicListPage,
private List>? _filteredItems;
private List>? _filteredApps;
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;
@@ -155,42 +159,18 @@ public partial class MainListPage : DynamicListPage,
public override IListItem[] GetItems()
{
- if (string.IsNullOrEmpty(SearchText))
+ lock (_tlcManager.TopLevelCommands)
{
- lock (_tlcManager.TopLevelCommands)
- {
- return _tlcManager
- .TopLevelCommands
- .Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
- .ToArray();
- }
- }
- else
- {
- lock (_tlcManager.TopLevelCommands)
- {
- 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 items = Enumerable.Empty>()
- .Concat(_filteredItems is not null ? _filteredItems : [])
- .Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : [])
- .Concat(limitedApps)
- .OrderByDescending(o => o.Score)
-
- // Add fallback items post-sort so they are always at the end of the list
- // and eventually ordered based on user preference
- .Concat(_fallbackItems is not null ? _fallbackItems.Where(w => !string.IsNullOrEmpty(w.Item.Title)) : [])
- .Select(s => s.Item)
- .ToArray();
- return items;
- }
+ // Either return the top-level commands (no search text), or the merged and
+ // filtered results.
+ return string.IsNullOrEmpty(SearchText)
+ ? _tlcManager.TopLevelCommands.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title)).ToArray()
+ : MainListPageResultFactory.Create(
+ _filteredItems,
+ _scoredFallbackItems?.ToList(),
+ _filteredApps,
+ _fallbackItems,
+ _appResultLimit);
}
}
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..f1bddf5197
--- /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 eventually ordered based on user preference.
+ 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 dae50b3f3e..e210359f76 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
@@ -11,7 +11,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
+using Microsoft.UI;
using Windows.Foundation;
+using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -62,6 +64,24 @@ public partial class SettingsModel : ObservableObject
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
+ public UserTheme Theme { get; set; } = UserTheme.Default;
+
+ public ColorizationMode ColorizationMode { get; set; }
+
+ public Color CustomThemeColor { get; set; } = Colors.Transparent;
+
+ public int CustomThemeColorIntensity { get; set; } = 100;
+
+ public int BackgroundImageOpacity { get; set; } = 20;
+
+ public int BackgroundImageBlurAmount { get; set; }
+
+ public int BackgroundImageBrightness { get; set; }
+
+ public BackgroundImageFit BackgroundImageFit { get; set; }
+
+ public string? BackgroundImagePath { get; set; }
+
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
index 586670bff7..6ac9acacc4 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs
@@ -4,6 +4,8 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
+using Microsoft.CmdPal.Core.Common.Services;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
@@ -29,6 +31,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
+ public AppearanceSettingsViewModel Appearance { get; }
+
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
@@ -179,6 +183,9 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_settings = settings;
_serviceProvider = serviceProvider;
+ var themeService = serviceProvider.GetRequiredService();
+ Appearance = new AppearanceSettingsViewModel(themeService, _settings);
+
var activeProviders = GetCommandProviders();
var allProviderSettings = _settings.ProviderSettings;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs
new file mode 100644
index 0000000000..290668f3f5
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.CmdPal.UI.ViewModels;
+
+public enum UserTheme
+{
+ Default,
+ Light,
+ Dark,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml
index f9a9e37ea1..d8d4655291 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml
@@ -4,19 +4,23 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
- xmlns:local="using:Microsoft.CmdPal.UI">
+ xmlns:local="using:Microsoft.CmdPal.UI"
+ xmlns:services="using:Microsoft.CmdPal.UI.Services">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
index 53f47286b2..a44682218f 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs
@@ -24,9 +24,11 @@ using Microsoft.CmdPal.Ext.WindowsTerminal;
using Microsoft.CmdPal.Ext.WindowWalker;
using Microsoft.CmdPal.Ext.WinGet;
using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
using Microsoft.CmdPal.UI.ViewModels.Models;
+using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerToys.Telemetry;
@@ -112,6 +114,17 @@ public partial class App : Application
// Root services
services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext());
+ AddBuiltInCommands(services);
+
+ AddCoreServices(services);
+
+ AddUIServices(services);
+
+ return services.BuildServiceProvider();
+ }
+
+ private static void AddBuiltInCommands(ServiceCollection services)
+ {
// Built-in Commands. Order matters - this is the order they'll be presented by default.
var allApps = new AllAppsCommandProvider();
var files = new IndexerCommandsProvider();
@@ -154,17 +167,32 @@ public partial class App : Application
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ }
+ private static void AddUIServices(ServiceCollection services)
+ {
// Models
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
var sm = SettingsModel.LoadSettings();
services.AddSingleton(sm);
var state = AppStateModel.LoadState();
services.AddSingleton(state);
- services.AddSingleton();
+
+ // Services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ private static void AddCoreServices(ServiceCollection services)
+ {
+ // Core services
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -174,7 +202,5 @@ public partial class App : Application
// ViewModels
services.AddSingleton();
services.AddSingleton();
-
- return services.BuildServiceProvider();
}
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs
new file mode 100644
index 0000000000..743e68d690
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs
@@ -0,0 +1,412 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Numerics;
+using ManagedCommon;
+using Microsoft.Graphics.Canvas.Effects;
+using Microsoft.UI;
+using Microsoft.UI.Composition;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Hosting;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+internal sealed partial class BlurImageControl : Control
+{
+ private const string ImageSourceParameterName = "ImageSource";
+
+ private const string BrightnessEffectName = "Brightness";
+ private const string BrightnessOverlayEffectName = "BrightnessOverlay";
+ private const string BlurEffectName = "Blur";
+ private const string TintBlendEffectName = "TintBlend";
+ private const string TintEffectName = "Tint";
+
+#pragma warning disable CA1507 // Use nameof to express symbol names ... some of these refer to effect properties that are separate from the class properties
+ private static readonly string BrightnessSource1AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source1Amount");
+ private static readonly string BrightnessSource2AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source2Amount");
+ private static readonly string BrightnessOverlayColorEffectProperty = GetPropertyName(BrightnessOverlayEffectName, "Color");
+ private static readonly string BlurBlurAmountEffectProperty = GetPropertyName(BlurEffectName, "BlurAmount");
+ private static readonly string TintColorEffectProperty = GetPropertyName(TintEffectName, "Color");
+#pragma warning restore CA1507
+
+ private static readonly string[] AnimatableProperties = [
+ BrightnessSource1AmountEffectProperty,
+ BrightnessSource2AmountEffectProperty,
+ BrightnessOverlayColorEffectProperty,
+ BlurBlurAmountEffectProperty,
+ TintColorEffectProperty
+ ];
+
+ public static readonly DependencyProperty ImageSourceProperty =
+ DependencyProperty.Register(
+ nameof(ImageSource),
+ typeof(ImageSource),
+ typeof(BlurImageControl),
+ new PropertyMetadata(null, OnImageChanged));
+
+ public static readonly DependencyProperty ImageStretchProperty =
+ DependencyProperty.Register(
+ nameof(ImageStretch),
+ typeof(Stretch),
+ typeof(BlurImageControl),
+ new PropertyMetadata(Stretch.UniformToFill, OnImageStretchChanged));
+
+ public static readonly DependencyProperty ImageOpacityProperty =
+ DependencyProperty.Register(
+ nameof(ImageOpacity),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(1.0, OnOpacityChanged));
+
+ public static readonly DependencyProperty ImageBrightnessProperty =
+ DependencyProperty.Register(
+ nameof(ImageBrightness),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(1.0, OnBrightnessChanged));
+
+ public static readonly DependencyProperty BlurAmountProperty =
+ DependencyProperty.Register(
+ nameof(BlurAmount),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(0.0, OnBlurAmountChanged));
+
+ public static readonly DependencyProperty TintColorProperty =
+ DependencyProperty.Register(
+ nameof(TintColor),
+ typeof(Color),
+ typeof(BlurImageControl),
+ new PropertyMetadata(Colors.Transparent, OnVisualPropertyChanged));
+
+ public static readonly DependencyProperty TintIntensityProperty =
+ DependencyProperty.Register(
+ nameof(TintIntensity),
+ typeof(double),
+ typeof(BlurImageControl),
+ new PropertyMetadata(0.0, OnVisualPropertyChanged));
+
+ private Compositor? _compositor;
+ private SpriteVisual? _effectVisual;
+ private CompositionEffectBrush? _effectBrush;
+ private CompositionSurfaceBrush? _imageBrush;
+
+ public BlurImageControl()
+ {
+ this.DefaultStyleKey = typeof(BlurImageControl);
+ this.Loaded += OnLoaded;
+ this.SizeChanged += OnSizeChanged;
+ }
+
+ public ImageSource ImageSource
+ {
+ get => (ImageSource)GetValue(ImageSourceProperty);
+ set => SetValue(ImageSourceProperty, value);
+ }
+
+ public Stretch ImageStretch
+ {
+ get => (Stretch)GetValue(ImageStretchProperty);
+ set => SetValue(ImageStretchProperty, value);
+ }
+
+ public double ImageOpacity
+ {
+ get => (double)GetValue(ImageOpacityProperty);
+ set => SetValue(ImageOpacityProperty, value);
+ }
+
+ public double ImageBrightness
+ {
+ get => (double)GetValue(ImageBrightnessProperty);
+ set => SetValue(ImageBrightnessProperty, Math.Clamp(value, -1, 1));
+ }
+
+ public double BlurAmount
+ {
+ get => (double)GetValue(BlurAmountProperty);
+ set => SetValue(BlurAmountProperty, value);
+ }
+
+ public Color TintColor
+ {
+ get => (Color)GetValue(TintColorProperty);
+ set => SetValue(TintColorProperty, value);
+ }
+
+ public double TintIntensity
+ {
+ get => (double)GetValue(TintIntensityProperty);
+ set => SetValue(TintIntensityProperty, value);
+ }
+
+ private static void OnImageStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._imageBrush != null)
+ {
+ control._imageBrush.Stretch = ConvertStretch((Stretch)e.NewValue);
+ }
+ }
+
+ private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._compositor != null)
+ {
+ control.UpdateEffect();
+ }
+ }
+
+ private static void OnOpacityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._effectVisual != null)
+ {
+ control._effectVisual.Opacity = (float)(double)e.NewValue;
+ }
+ }
+
+ private static void OnBlurAmountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._effectBrush != null)
+ {
+ control.UpdateEffect();
+ }
+ }
+
+ private static void OnBrightnessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is BlurImageControl control && control._effectBrush != null)
+ {
+ control.UpdateEffect();
+ }
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ InitializeComposition();
+ }
+
+ private void OnSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if (_effectVisual != null)
+ {
+ _effectVisual.Size = new Vector2(
+ (float)Math.Max(1, e.NewSize.Width),
+ (float)Math.Max(1, e.NewSize.Height));
+ }
+ }
+
+ private static void OnImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not BlurImageControl control)
+ {
+ return;
+ }
+
+ control.EnsureEffect(force: true);
+ control.UpdateEffect();
+ }
+
+ private void InitializeComposition()
+ {
+ var visual = ElementCompositionPreview.GetElementVisual(this);
+ _compositor = visual.Compositor;
+
+ _effectVisual = _compositor.CreateSpriteVisual();
+ _effectVisual.Size = new Vector2(
+ (float)Math.Max(1, ActualWidth),
+ (float)Math.Max(1, ActualHeight));
+ _effectVisual.Opacity = (float)ImageOpacity;
+
+ ElementCompositionPreview.SetElementChildVisual(this, _effectVisual);
+
+ UpdateEffect();
+ }
+
+ private void EnsureEffect(bool force = false)
+ {
+ if (_compositor is null)
+ {
+ return;
+ }
+
+ if (_effectBrush is not null && !force)
+ {
+ return;
+ }
+
+ var imageSource = new CompositionEffectSourceParameter(ImageSourceParameterName);
+
+ // 1) Brightness via ArithmeticCompositeEffect
+ // We blend between the original image and either black or white,
+ // depending on whether we want to darken or brighten. BrightnessEffect isn't supported
+ // in the composition graph.
+ var brightnessEffect = new ArithmeticCompositeEffect
+ {
+ Name = BrightnessEffectName,
+ Source1 = imageSource, // original image
+ Source2 = new ColorSourceEffect
+ {
+ Name = BrightnessOverlayEffectName,
+ Color = Colors.Black, // we'll swap black/white via properties
+ },
+
+ MultiplyAmount = 0.0f,
+ Source1Amount = 1.0f, // original
+ Source2Amount = 0.0f, // overlay
+ Offset = 0.0f,
+ };
+
+ // 2) Blur
+ var blurEffect = new GaussianBlurEffect
+ {
+ Name = BlurEffectName,
+ BlurAmount = 0.0f,
+ BorderMode = EffectBorderMode.Hard,
+ Optimization = EffectOptimization.Balanced,
+ Source = brightnessEffect,
+ };
+
+ // 3) Tint (always in the chain; intensity via alpha)
+ var tintEffect = new BlendEffect
+ {
+ Name = TintBlendEffectName,
+ Background = blurEffect,
+ Foreground = new ColorSourceEffect
+ {
+ Name = TintEffectName,
+ Color = Colors.Transparent,
+ },
+ Mode = BlendEffectMode.Multiply,
+ };
+
+ var effectFactory = _compositor.CreateEffectFactory(tintEffect, AnimatableProperties);
+
+ _effectBrush?.Dispose();
+ _effectBrush = effectFactory.CreateBrush();
+
+ // Set initial source
+ if (ImageSource is not null)
+ {
+ _imageBrush ??= _compositor.CreateSurfaceBrush();
+ LoadImageAsync(ImageSource);
+ _effectBrush.SetSourceParameter(ImageSourceParameterName, _imageBrush);
+ }
+ else
+ {
+ _effectBrush.SetSourceParameter(ImageSourceParameterName, _compositor.CreateBackdropBrush());
+ }
+
+ if (_effectVisual is not null)
+ {
+ _effectVisual.Brush = _effectBrush;
+ }
+ }
+
+ private void UpdateEffect()
+ {
+ if (_compositor is null)
+ {
+ return;
+ }
+
+ EnsureEffect();
+ if (_effectBrush is null)
+ {
+ return;
+ }
+
+ var props = _effectBrush.Properties;
+
+ // Brightness
+ var b = (float)Math.Clamp(ImageBrightness, -1.0, 1.0);
+
+ float source1Amount;
+ float source2Amount;
+ Color overlayColor;
+
+ if (b >= 0)
+ {
+ // Brighten: blend towards white
+ overlayColor = Colors.White;
+ source1Amount = 1.0f - b; // original image contribution
+ source2Amount = b; // white overlay contribution
+ }
+ else
+ {
+ // Darken: blend towards black
+ overlayColor = Colors.Black;
+ var t = -b; // 0..1
+ source1Amount = 1.0f - t; // original image
+ source2Amount = t; // black overlay
+ }
+
+ props.InsertScalar(BrightnessSource1AmountEffectProperty, source1Amount);
+ props.InsertScalar(BrightnessSource2AmountEffectProperty, source2Amount);
+ props.InsertColor(BrightnessOverlayColorEffectProperty, overlayColor);
+
+ // Blur
+ props.InsertScalar(BlurBlurAmountEffectProperty, (float)BlurAmount);
+
+ // Tint
+ var tintColor = TintColor;
+ var clampedIntensity = (float)Math.Clamp(TintIntensity, 0.0, 1.0);
+
+ var adjustedColor = Color.FromArgb(
+ (byte)(clampedIntensity * 255),
+ tintColor.R,
+ tintColor.G,
+ tintColor.B);
+
+ props.InsertColor(TintColorEffectProperty, adjustedColor);
+ }
+
+ private void LoadImageAsync(ImageSource imageSource)
+ {
+ try
+ {
+ if (imageSource is Microsoft.UI.Xaml.Media.Imaging.BitmapImage bitmapImage)
+ {
+ _imageBrush ??= _compositor?.CreateSurfaceBrush();
+ if (_imageBrush is null)
+ {
+ return;
+ }
+
+ var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource);
+ loadedSurface.LoadCompleted += (_, _) =>
+ {
+ if (_imageBrush is not null)
+ {
+ _imageBrush.Surface = loadedSurface;
+ _imageBrush.Stretch = ConvertStretch(ImageStretch);
+ _imageBrush.BitmapInterpolationMode = CompositionBitmapInterpolationMode.Linear;
+ }
+ };
+
+ _effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Failed to load image for BlurImageControl: {0}", ex);
+ }
+ }
+
+ private static CompositionStretch ConvertStretch(Stretch stretch)
+ {
+ return stretch switch
+ {
+ Stretch.None => CompositionStretch.None,
+ Stretch.Fill => CompositionStretch.Fill,
+ Stretch.Uniform => CompositionStretch.Uniform,
+ Stretch.UniformToFill => CompositionStretch.UniformToFill,
+ _ => CompositionStretch.UniformToFill,
+ };
+ }
+
+ private static string GetPropertyName(string effectName, string propertyName) => $"{effectName}.{propertyName}";
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml
new file mode 100644
index 0000000000..105010bbd2
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs
new file mode 100644
index 0000000000..7267e894fa
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.ObjectModel;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+public sealed partial class ColorPalette : UserControl
+{
+ public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPalette), null!)!;
+
+ public static readonly DependencyProperty CustomPaletteColumnCountProperty = DependencyProperty.Register(nameof(CustomPaletteColumnCount), typeof(int), typeof(ColorPalette), new PropertyMetadata(10))!;
+
+ public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPalette), new PropertyMetadata(null!))!;
+
+ public event EventHandler? SelectedColorChanged;
+
+ private Color? _selectedColor;
+
+ public Color? SelectedColor
+ {
+ get => _selectedColor;
+
+ set
+ {
+ if (_selectedColor != value)
+ {
+ _selectedColor = value;
+ if (value is not null)
+ {
+ SetValue(SelectedColorProperty, value);
+ }
+ else
+ {
+ ClearValue(SelectedColorProperty);
+ }
+ }
+ }
+ }
+
+ public ObservableCollection PaletteColors
+ {
+ get => (ObservableCollection)GetValue(PaletteColorsProperty)!;
+ set => SetValue(PaletteColorsProperty, value);
+ }
+
+ public int CustomPaletteColumnCount
+ {
+ get => (int)GetValue(CustomPaletteColumnCountProperty);
+ set => SetValue(CustomPaletteColumnCountProperty, value);
+ }
+
+ public ColorPalette()
+ {
+ PaletteColors = [];
+ InitializeComponent();
+ }
+
+ private void ListViewBase_OnItemClick(object sender, ItemClickEventArgs e)
+ {
+ if (e.ClickedItem is Color color)
+ {
+ SelectedColor = color;
+ SelectedColorChanged?.Invoke(this, color);
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml
new file mode 100644
index 0000000000..92a556f7a7
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs
new file mode 100644
index 0000000000..ff82fffd4e
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs
@@ -0,0 +1,146 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.ObjectModel;
+using ManagedCommon;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+public sealed partial class ColorPickerButton : UserControl
+{
+ public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPickerButton), new PropertyMetadata(new ObservableCollection()))!;
+
+ public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(Colors.Black))!;
+
+ public static readonly DependencyProperty IsAlphaEnabledProperty = DependencyProperty.Register(nameof(IsAlphaEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(defaultValue: false))!;
+
+ public static readonly DependencyProperty IsValueEditorEnabledProperty = DependencyProperty.Register(nameof(IsValueEditorEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!;
+
+ public static readonly DependencyProperty HasSelectedColorProperty = DependencyProperty.Register(nameof(HasSelectedColor), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!;
+
+ private Color _selectedColor;
+
+ public Color SelectedColor
+ {
+ get
+ {
+ return _selectedColor;
+ }
+
+ set
+ {
+ if (_selectedColor != value)
+ {
+ _selectedColor = value;
+ SetValue(SelectedColorProperty, value);
+ HasSelectedColor = true;
+ }
+ }
+ }
+
+ public bool HasSelectedColor
+ {
+ get { return (bool)GetValue(HasSelectedColorProperty); }
+ set { SetValue(HasSelectedColorProperty, value); }
+ }
+
+ public bool IsAlphaEnabled
+ {
+ get => (bool)GetValue(IsAlphaEnabledProperty);
+ set => SetValue(IsAlphaEnabledProperty, value);
+ }
+
+ public bool IsValueEditorEnabled
+ {
+ get { return (bool)GetValue(IsValueEditorEnabledProperty); }
+ set { SetValue(IsValueEditorEnabledProperty, value); }
+ }
+
+ public ObservableCollection PaletteColors
+ {
+ get { return (ObservableCollection)GetValue(PaletteColorsProperty); }
+ set { SetValue(PaletteColorsProperty, value); }
+ }
+
+ public ColorPickerButton()
+ {
+ this.InitializeComponent();
+
+ IsEnabledChanged -= ColorPickerButton_IsEnabledChanged;
+ SetEnabledState();
+ IsEnabledChanged += ColorPickerButton_IsEnabledChanged;
+ }
+
+ private void ColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ SetEnabledState();
+ }
+
+ private void SetEnabledState()
+ {
+ if (this.IsEnabled)
+ {
+ ColorPreviewBorder.Opacity = 1;
+ }
+ else
+ {
+ ColorPreviewBorder.Opacity = 0.2;
+ }
+ }
+
+ private void ColorPalette_OnSelectedColorChanged(object? sender, Color? e)
+ {
+ if (e.HasValue)
+ {
+ HasSelectedColor = true;
+ SelectedColor = e.Value;
+ }
+ }
+
+ private void FlyoutBase_OnOpened(object? sender, object e)
+ {
+ if (sender is not Flyout flyout || (flyout.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter)
+ {
+ return;
+ }
+
+ FlyoutRoot!.UpdateLayout();
+ flyoutPresenter.UpdateLayout();
+
+ // Logger.LogInfo($"FlyoutBase_OnOpened: {flyoutPresenter}, {FlyoutRoot!.ActualWidth}");
+ flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth;
+ flyoutPresenter.MinWidth = 660;
+ flyoutPresenter.Width = FlyoutRoot!.ActualWidth;
+ }
+
+ private void FlyoutRoot_OnSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if ((ColorPickerFlyout!.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter)
+ {
+ return;
+ }
+
+ FlyoutRoot!.UpdateLayout();
+ flyoutPresenter.UpdateLayout();
+
+ flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth;
+ flyoutPresenter.MinWidth = 660;
+ flyoutPresenter.Width = FlyoutRoot!.ActualWidth;
+ }
+
+ private Thickness ToDropDownPadding(bool hasColor)
+ {
+ return hasColor ? new Thickness(3, 3, 8, 3) : new Thickness(8, 4, 8, 4);
+ }
+
+ private void ResetButton_Click(object sender, RoutedEventArgs e)
+ {
+ HasSelectedColor = false;
+ ColorPickerFlyout?.Hide();
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml
new file mode 100644
index 0000000000..a30d1fafdf
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs
new file mode 100644
index 0000000000..96cd5d6aac
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs
@@ -0,0 +1,123 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.UI.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+public sealed partial class CommandPalettePreview : UserControl
+{
+ public static readonly DependencyProperty PreviewBackgroundOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
+
+ public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
+
+ public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, PropertyChangedCallback));
+
+ public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
+
+ public static readonly DependencyProperty PreviewBackgroundImageFitProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageFit), typeof(BackgroundImageFit), typeof(CommandPalettePreview), new PropertyMetadata(default(BackgroundImageFit)));
+
+ public static readonly DependencyProperty PreviewBackgroundImageBrightnessProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBrightness), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
+
+ public static readonly DependencyProperty PreviewBackgroundImageBlurAmountProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBlurAmount), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
+
+ public static readonly DependencyProperty PreviewBackgroundImageTintProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTint), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
+
+ public static readonly DependencyProperty PreviewBackgroundImageTintIntensityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTintIntensity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
+
+ public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed));
+
+ public BackgroundImageFit PreviewBackgroundImageFit
+ {
+ get { return (BackgroundImageFit)GetValue(PreviewBackgroundImageFitProperty); }
+ set { SetValue(PreviewBackgroundImageFitProperty, value); }
+ }
+
+ public double PreviewBackgroundOpacity
+ {
+ get { return (double)GetValue(PreviewBackgroundOpacityProperty); }
+ set { SetValue(PreviewBackgroundOpacityProperty, value); }
+ }
+
+ public Color PreviewBackgroundColor
+ {
+ get { return (Color)GetValue(PreviewBackgroundColorProperty); }
+ set { SetValue(PreviewBackgroundColorProperty, value); }
+ }
+
+ public ImageSource PreviewBackgroundImageSource
+ {
+ get { return (ImageSource)GetValue(PreviewBackgroundImageSourceProperty); }
+ set { SetValue(PreviewBackgroundImageSourceProperty, value); }
+ }
+
+ public int PreviewBackgroundImageOpacity
+ {
+ get { return (int)GetValue(PreviewBackgroundImageOpacityProperty); }
+ set { SetValue(PreviewBackgroundImageOpacityProperty, value); }
+ }
+
+ public double PreviewBackgroundImageBrightness
+ {
+ get => (double)GetValue(PreviewBackgroundImageBrightnessProperty);
+ set => SetValue(PreviewBackgroundImageBrightnessProperty, value);
+ }
+
+ public double PreviewBackgroundImageBlurAmount
+ {
+ get => (double)GetValue(PreviewBackgroundImageBlurAmountProperty);
+ set => SetValue(PreviewBackgroundImageBlurAmountProperty, value);
+ }
+
+ public Color PreviewBackgroundImageTint
+ {
+ get => (Color)GetValue(PreviewBackgroundImageTintProperty);
+ set => SetValue(PreviewBackgroundImageTintProperty, value);
+ }
+
+ public int PreviewBackgroundImageTintIntensity
+ {
+ get => (int)GetValue(PreviewBackgroundImageTintIntensityProperty);
+ set => SetValue(PreviewBackgroundImageTintIntensityProperty, value);
+ }
+
+ public Visibility ShowBackgroundImage
+ {
+ get => (Visibility)GetValue(ShowBackgroundImageProperty);
+ set => SetValue(ShowBackgroundImageProperty, value);
+ }
+
+ public CommandPalettePreview()
+ {
+ InitializeComponent();
+ }
+
+ private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not CommandPalettePreview preview)
+ {
+ return;
+ }
+
+ preview.ShowBackgroundImage = e.NewValue is ImageSource ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private double ToOpacity(int value) => value / 100.0;
+
+ private double ToTintIntensity(int value) => value / 100.0;
+
+ private Stretch ToStretch(BackgroundImageFit fit)
+ {
+ return fit switch
+ {
+ BackgroundImageFit.Fill => Stretch.Fill,
+ BackgroundImageFit.UniformToFill => Stretch.UniformToFill,
+ _ => Stretch.None,
+ };
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml
new file mode 100644
index 0000000000..58c4e890a6
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs
new file mode 100644
index 0000000000..828fa76c74
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.UI.Helpers;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Markup;
+using Microsoft.UI.Xaml.Media;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+[ContentProperty(Name = nameof(PreviewContent))]
+public sealed partial class ScreenPreview : UserControl
+{
+ public static readonly DependencyProperty PreviewContentProperty =
+ DependencyProperty.Register(nameof(PreviewContent), typeof(object), typeof(ScreenPreview), new PropertyMetadata(null!))!;
+
+ public object PreviewContent
+ {
+ get => GetValue(PreviewContentProperty)!;
+ set => SetValue(PreviewContentProperty, value);
+ }
+
+ public ScreenPreview()
+ {
+ InitializeComponent();
+
+ var wallpaperHelper = new WallpaperHelper();
+ WallpaperImage!.Source = wallpaperHelper.GetWallpaperImage()!;
+ ScreenBorder!.Background = new SolidColorBrush(wallpaperHelper.GetWallpaperColor());
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml
index 80eb1a3ad6..d248c24f89 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml
@@ -4,9 +4,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUi="using:Microsoft.CmdPal.UI"
- xmlns:converters="using:CommunityToolkit.WinUI.Converters"
- xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:h="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
@@ -22,6 +21,7 @@
MinHeight="32"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
+ h:TextBoxCaretColor.SyncWithForeground="True"
AutomationProperties.AutomationId="MainSearchBox"
KeyDown="FilterBox_KeyDown"
PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}"
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs
index 169b34a8b0..0d6fd58afa 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs
@@ -208,21 +208,32 @@ public sealed partial class SearchBar : UserControl,
e.Handled = true;
}
+ else if (e.Key == VirtualKey.Left)
+ {
+ // Check if we're in a grid view, and if so, send grid navigation command
+ var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true };
+
+ // Special handling is required if we're in grid view.
+ if (isGridView)
+ {
+ WeakReferenceMessenger.Default.Send();
+ e.Handled = true;
+ }
+ }
else if (e.Key == VirtualKey.Right)
{
// Check if the "replace search text with suggestion" feature from 0.4-0.5 is enabled.
// If it isn't, then only use the suggestion when the caret is at the end of the input.
if (!IsTextToSuggestEnabled)
{
- if (_textToSuggest != null &&
+ if (!string.IsNullOrEmpty(_textToSuggest) &&
FilterBox.SelectionStart == FilterBox.Text.Length)
{
FilterBox.Text = _textToSuggest;
FilterBox.Select(_textToSuggest.Length, 0);
e.Handled = true;
+ return;
}
-
- return;
}
// Here, we're using the "replace search text with suggestion" feature.
@@ -232,6 +243,20 @@ public sealed partial class SearchBar : UserControl,
_lastText = null;
DoFilterBoxUpdate();
}
+
+ // Wouldn't want to perform text completion *and* move the selected item, so only perform this if text suggestion wasn't performed.
+ if (!e.Handled)
+ {
+ // Check if we're in a grid view, and if so, send grid navigation command
+ var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true };
+
+ // Special handling is required if we're in grid view.
+ if (isGridView)
+ {
+ WeakReferenceMessenger.Default.Send();
+ e.Handled = true;
+ }
+ }
}
else if (e.Key == VirtualKey.Down)
{
@@ -274,6 +299,8 @@ public sealed partial class SearchBar : UserControl,
e.Key == VirtualKey.Up ||
e.Key == VirtualKey.Down ||
+ e.Key == VirtualKey.Left ||
+ e.Key == VirtualKey.Right ||
e.Key == VirtualKey.RightMenu ||
e.Key == VirtualKey.LeftMenu ||
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs
new file mode 100644
index 0000000000..2d7567c346
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs
@@ -0,0 +1,37 @@
+// 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.Controls;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+internal sealed class UVBounds
+{
+ public double UMin { get; }
+
+ public double UMax { get; }
+
+ public double VMin { get; }
+
+ public double VMax { get; }
+
+ public UVBounds(Orientation orientation, Rect rect)
+ {
+ if (orientation == Orientation.Horizontal)
+ {
+ UMin = rect.Left;
+ UMax = rect.Right;
+ VMin = rect.Top;
+ VMax = rect.Bottom;
+ }
+ else
+ {
+ UMin = rect.Top;
+ UMax = rect.Bottom;
+ VMin = rect.Left;
+ VMax = rect.Right;
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs
new file mode 100644
index 0000000000..1b75c31564
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs
@@ -0,0 +1,96 @@
+// 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 Microsoft.UI.Xaml.Controls;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+[DebuggerDisplay("U = {U} V = {V}")]
+internal struct UvMeasure
+{
+ internal double U { get; set; }
+
+ internal double V { get; set; }
+
+ internal static UvMeasure Zero => default(UvMeasure);
+
+ public UvMeasure(Orientation orientation, Size size)
+ : this(orientation, size.Width, size.Height)
+ {
+ }
+
+ public UvMeasure(Orientation orientation, double width, double height)
+ {
+ if (orientation == Orientation.Horizontal)
+ {
+ U = width;
+ V = height;
+ }
+ else
+ {
+ U = height;
+ V = width;
+ }
+ }
+
+ public UvMeasure Add(double u, double v)
+ {
+ UvMeasure result = default(UvMeasure);
+ result.U = U + u;
+ result.V = V + v;
+ return result;
+ }
+
+ public UvMeasure Add(UvMeasure measure)
+ {
+ return Add(measure.U, measure.V);
+ }
+
+ public Size ToSize(Orientation orientation)
+ {
+ if (orientation != Orientation.Horizontal)
+ {
+ return new Size(V, U);
+ }
+
+ return new Size(U, V);
+ }
+
+ public Point GetPoint(Orientation orientation)
+ {
+ return orientation is Orientation.Horizontal ? new Point(U, V) : new Point(V, U);
+ }
+
+ public Size GetSize(Orientation orientation)
+ {
+ return orientation is Orientation.Horizontal ? new Size(U, V) : new Size(V, U);
+ }
+
+ public static bool operator ==(UvMeasure measure1, UvMeasure measure2)
+ {
+ return measure1.U == measure2.U && measure1.V == measure2.V;
+ }
+
+ public static bool operator !=(UvMeasure measure1, UvMeasure measure2)
+ {
+ return !(measure1 == measure2);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is UvMeasure measure && this == measure;
+ }
+
+ public bool Equals(UvMeasure value)
+ {
+ return this == value;
+ }
+
+ public override int GetHashCode()
+ {
+ return base.GetHashCode();
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs
new file mode 100644
index 0000000000..ea0101bfa3
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs
@@ -0,0 +1,416 @@
+// 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.Controls;
+using Microsoft.CmdPal.Core.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+///
+/// Arranges elements by wrapping them to fit the available space.
+/// When is set to Orientation.Horizontal, element are arranged in rows until the available width is reached and then to a new row.
+/// When is set to Orientation.Vertical, element are arranged in columns until the available height is reached.
+///
+public sealed partial class WrapPanel : Panel
+{
+ private struct UvRect
+ {
+ public UvMeasure Position { get; set; }
+
+ public UvMeasure Size { get; set; }
+
+ public Rect ToRect(Orientation orientation)
+ {
+ return orientation switch
+ {
+ Orientation.Vertical => new Rect(Position.V, Position.U, Size.V, Size.U),
+ Orientation.Horizontal => new Rect(Position.U, Position.V, Size.U, Size.V),
+ _ => ThrowArgumentException(),
+ };
+ }
+
+ private static Rect ThrowArgumentException()
+ {
+ throw new ArgumentException("The input orientation is not valid.");
+ }
+ }
+
+ private struct Row
+ {
+ public List ChildrenRects { get; }
+
+ public UvMeasure Size { get; set; }
+
+ public UvRect Rect
+ {
+ get
+ {
+ UvRect result;
+ if (ChildrenRects.Count <= 0)
+ {
+ result = default(UvRect);
+ result.Position = UvMeasure.Zero;
+ result.Size = Size;
+ return result;
+ }
+
+ result = default(UvRect);
+ result.Position = ChildrenRects.First().Position;
+ result.Size = Size;
+ return result;
+ }
+ }
+
+ public Row(List childrenRects, UvMeasure size)
+ {
+ ChildrenRects = childrenRects;
+ Size = size;
+ }
+
+ public void Add(UvMeasure position, UvMeasure size)
+ {
+ ChildrenRects.Add(new UvRect
+ {
+ Position = position,
+ Size = size,
+ });
+
+ Size = new UvMeasure
+ {
+ U = position.U + size.U,
+ V = Math.Max(Size.V, size.V),
+ };
+ }
+ }
+
+ ///
+ /// Gets or sets a uniform Horizontal distance (in pixels) between items when is set to Horizontal,
+ /// or between columns of items when is set to Vertical.
+ ///
+ public double HorizontalSpacing
+ {
+ get { return (double)GetValue(HorizontalSpacingProperty); }
+ set { SetValue(HorizontalSpacingProperty, value); }
+ }
+
+ private bool IsSectionItem(UIElement element) => element is FrameworkElement fe && fe.DataContext is ListItemViewModel item && item.IsSectionOrSeparator;
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty HorizontalSpacingProperty =
+ DependencyProperty.Register(
+ nameof(HorizontalSpacing),
+ typeof(double),
+ typeof(WrapPanel),
+ new PropertyMetadata(0d, LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets a uniform Vertical distance (in pixels) between items when is set to Vertical,
+ /// or between rows of items when is set to Horizontal.
+ ///
+ public double VerticalSpacing
+ {
+ get { return (double)GetValue(VerticalSpacingProperty); }
+ set { SetValue(VerticalSpacingProperty, value); }
+ }
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty VerticalSpacingProperty =
+ DependencyProperty.Register(
+ nameof(VerticalSpacing),
+ typeof(double),
+ typeof(WrapPanel),
+ new PropertyMetadata(0d, LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets the orientation of the WrapPanel.
+ /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls.
+ /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added.
+ ///
+ public Orientation Orientation
+ {
+ get { return (Orientation)GetValue(OrientationProperty); }
+ set { SetValue(OrientationProperty, value); }
+ }
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty OrientationProperty =
+ DependencyProperty.Register(
+ nameof(Orientation),
+ typeof(Orientation),
+ typeof(WrapPanel),
+ new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets the distance between the border and its child object.
+ ///
+ ///
+ /// The dimensions of the space between the border and its child as a Thickness value.
+ /// Thickness is a structure that stores dimension values using pixel measures.
+ ///
+ public Thickness Padding
+ {
+ get { return (Thickness)GetValue(PaddingProperty); }
+ set { SetValue(PaddingProperty, value); }
+ }
+
+ ///
+ /// Identifies the Padding dependency property.
+ ///
+ /// The identifier for the dependency property.
+ public static readonly DependencyProperty PaddingProperty =
+ DependencyProperty.Register(
+ nameof(Padding),
+ typeof(Thickness),
+ typeof(WrapPanel),
+ new PropertyMetadata(default(Thickness), LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets a value indicating how to arrange child items
+ ///
+ public StretchChild StretchChild
+ {
+ get { return (StretchChild)GetValue(StretchChildProperty); }
+ set { SetValue(StretchChildProperty, value); }
+ }
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ /// The identifier for the dependency property.
+ public static readonly DependencyProperty StretchChildProperty =
+ DependencyProperty.Register(
+ nameof(StretchChild),
+ typeof(StretchChild),
+ typeof(WrapPanel),
+ new PropertyMetadata(StretchChild.None, LayoutPropertyChanged));
+
+ ///
+ /// Identifies the IsFullLine attached dependency property.
+ /// If true, the child element will occupy the entire width of the panel and force a line break before and after itself.
+ ///
+ public static readonly DependencyProperty IsFullLineProperty =
+ DependencyProperty.RegisterAttached(
+ "IsFullLine",
+ typeof(bool),
+ typeof(WrapPanel),
+ new PropertyMetadata(false, OnIsFullLineChanged));
+
+ public static bool GetIsFullLine(DependencyObject obj)
+ {
+ return (bool)obj.GetValue(IsFullLineProperty);
+ }
+
+ public static void SetIsFullLine(DependencyObject obj, bool value)
+ {
+ obj.SetValue(IsFullLineProperty, value);
+ }
+
+ private static void OnIsFullLineChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (FindVisualParentWrapPanel(d) is WrapPanel wp)
+ {
+ wp.InvalidateMeasure();
+ }
+ }
+
+ private static WrapPanel? FindVisualParentWrapPanel(DependencyObject child)
+ {
+ var parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(child);
+
+ while (parent != null)
+ {
+ if (parent is WrapPanel wrapPanel)
+ {
+ return wrapPanel;
+ }
+
+ parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(parent);
+ }
+
+ return null;
+ }
+
+ private static void LayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is WrapPanel wp)
+ {
+ wp.InvalidateMeasure();
+ wp.InvalidateArrange();
+ }
+ }
+
+ private readonly List _rows = new List();
+
+ ///
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var childAvailableSize = new Size(
+ availableSize.Width - Padding.Left - Padding.Right,
+ availableSize.Height - Padding.Top - Padding.Bottom);
+ foreach (var child in Children)
+ {
+ child.Measure(childAvailableSize);
+ }
+
+ var requiredSize = UpdateRows(availableSize);
+ return requiredSize;
+ }
+
+ ///
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ if ((Orientation == Orientation.Horizontal && finalSize.Width < DesiredSize.Width) ||
+ (Orientation == Orientation.Vertical && finalSize.Height < DesiredSize.Height))
+ {
+ // We haven't received our desired size. We need to refresh the rows.
+ UpdateRows(finalSize);
+ }
+
+ if (_rows.Count > 0)
+ {
+ // Now that we have all the data, we do the actual arrange pass
+ var childIndex = 0;
+ foreach (var row in _rows)
+ {
+ foreach (var rect in row.ChildrenRects)
+ {
+ var child = Children[childIndex++];
+ while (child.Visibility == Visibility.Collapsed)
+ {
+ // Collapsed children are not added into the rows,
+ // we skip them.
+ child = Children[childIndex++];
+ }
+
+ var arrangeRect = new UvRect
+ {
+ Position = rect.Position,
+ Size = new UvMeasure { U = rect.Size.U, V = row.Size.V },
+ };
+
+ var finalRect = arrangeRect.ToRect(Orientation);
+ child.Arrange(finalRect);
+ }
+ }
+ }
+
+ return finalSize;
+ }
+
+ private Size UpdateRows(Size availableSize)
+ {
+ _rows.Clear();
+
+ var paddingStart = new UvMeasure(Orientation, Padding.Left, Padding.Top);
+ var paddingEnd = new UvMeasure(Orientation, Padding.Right, Padding.Bottom);
+
+ if (Children.Count == 0)
+ {
+ return paddingStart.Add(paddingEnd).ToSize(Orientation);
+ }
+
+ var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height);
+ var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing);
+ var position = new UvMeasure(Orientation, Padding.Left, Padding.Top);
+
+ var currentRow = new Row(new List(), default);
+ var finalMeasure = new UvMeasure(Orientation, width: 0.0, height: 0.0);
+
+ void CommitRow()
+ {
+ // Only adds if the row has a content
+ if (currentRow.ChildrenRects.Count > 0)
+ {
+ _rows.Add(currentRow);
+
+ position.V += currentRow.Size.V + spacingMeasure.V;
+ }
+
+ position.U = paddingStart.U;
+
+ currentRow = new Row(new List(), default);
+ }
+
+ void Arrange(UIElement child, bool isLast = false)
+ {
+ if (child.Visibility == Visibility.Collapsed)
+ {
+ return;
+ }
+
+ var isFullLine = IsSectionItem(child);
+ var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize);
+
+ if (isFullLine)
+ {
+ if (currentRow.ChildrenRects.Count > 0)
+ {
+ CommitRow();
+ }
+
+ // Forces the width to fill all the available space
+ // (Total width - Padding Left - Padding Right)
+ desiredMeasure.U = parentMeasure.U - paddingStart.U - paddingEnd.U;
+
+ // Adds the Section Header to the row
+ currentRow.Add(position, desiredMeasure);
+
+ // Updates the global measures
+ position.U += desiredMeasure.U + spacingMeasure.U;
+ finalMeasure.U = Math.Max(finalMeasure.U, position.U);
+
+ CommitRow();
+ }
+ else
+ {
+ // Checks if the item can fit in the row
+ if ((desiredMeasure.U + position.U + paddingEnd.U) > parentMeasure.U)
+ {
+ CommitRow();
+ }
+
+ if (isLast)
+ {
+ desiredMeasure.U = parentMeasure.U - position.U;
+ }
+
+ currentRow.Add(position, desiredMeasure);
+
+ position.U += desiredMeasure.U + spacingMeasure.U;
+ finalMeasure.U = Math.Max(finalMeasure.U, position.U);
+ }
+ }
+
+ var lastIndex = Children.Count - 1;
+ for (var i = 0; i < lastIndex; i++)
+ {
+ Arrange(Children[i]);
+ }
+
+ Arrange(Children[lastIndex], StretchChild == StretchChild.Last);
+
+ if (currentRow.ChildrenRects.Count > 0)
+ {
+ _rows.Add(currentRow);
+ }
+
+ if (_rows.Count == 0)
+ {
+ return paddingStart.Add(paddingEnd).ToSize(Orientation);
+ }
+
+ var lastRowRect = _rows.Last().Rect;
+ finalMeasure.V = lastRowRect.Position.V + lastRowRect.Size.V;
+ return finalMeasure.Add(paddingEnd).ToSize(Orientation);
+ }
+}
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/Converters/GridItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs
index c93470e3e3..f638f3f09e 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs
@@ -18,8 +18,23 @@ internal sealed partial class GridItemTemplateSelector : DataTemplateSelector
public DataTemplate? Gallery { get; set; }
+ public DataTemplate? Section { get; set; }
+
+ public DataTemplate? Separator { get; set; }
+
protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject)
{
+ if (item is ListItemViewModel element && element.IsSectionOrSeparator)
+ {
+ if (dependencyObject is UIElement li)
+ {
+ li.IsTabStop = false;
+ li.IsHitTestVisible = false;
+ }
+
+ return string.IsNullOrWhiteSpace(element.Section) ? Separator : Section;
+ }
+
return GridProperties switch
{
SmallGridPropertiesViewModel => Small,
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs
new file mode 100644
index 0000000000..7fb810f5dc
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs
@@ -0,0 +1,47 @@
+// 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.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Microsoft.CmdPal.UI;
+
+public sealed partial class ListItemTemplateSelector : DataTemplateSelector
+{
+ public DataTemplate? ListItem { get; set; }
+
+ public DataTemplate? Separator { get; set; }
+
+ public DataTemplate? Section { get; set; }
+
+ protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container)
+ {
+ DataTemplate? dataTemplate = ListItem;
+
+ if (container is ListViewItem listItem)
+ {
+ if (item is ListItemViewModel element)
+ {
+ if (container is ListViewItem li && element.IsSectionOrSeparator)
+ {
+ li.IsEnabled = false;
+ li.AllowFocusWhenDisabled = false;
+ li.AllowFocusOnInteraction = false;
+ li.IsHitTestVisible = false;
+ dataTemplate = string.IsNullOrWhiteSpace(element.Section) ? Separator : Section;
+ }
+ else
+ {
+ listItem.IsEnabled = true;
+ listItem.AllowFocusWhenDisabled = true;
+ listItem.AllowFocusOnInteraction = true;
+ listItem.IsHitTestVisible = true;
+ }
+ }
+ }
+
+ return dataTemplate;
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml
index 7cf720198a..859a74eb18 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml
@@ -28,6 +28,8 @@
8