diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index c655bb1b55..a7d02dcb21 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -335,3 +335,7 @@ azp feedbackhub needinfo reportbug + +#ffmpeg +crf +nostdin diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index d4be728886..f839c5976c 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -141,8 +141,11 @@ BITSPIXEL bla BLACKFRAME BLENDFUNCTION +blittable Blockquotes blt +bluelightreduction +bluelightreductionstate BLURBEHIND BLURREGION bmi @@ -250,6 +253,7 @@ colorformat colorhistory colorhistorylimit COLORKEY +colorref comctl comdlg comexp @@ -1113,6 +1117,7 @@ NEWPLUSSHELLEXTENSIONWIN newrow nicksnettravels NIF +nightlight NLog NLSTEXT NMAKE @@ -1483,6 +1488,7 @@ rgh rgn rgs rguid +rhk RIDEV RIGHTSCROLLBAR riid @@ -1588,6 +1594,7 @@ SHGDNF SHGFI SHIL shinfo +shk shlwapi shobjidl SHORTCUTATLEAST @@ -1858,8 +1865,10 @@ Uniquifies unitconverter unittests UNLEN +Uninitializes UNORM unremapped +Unsubscribes unvirtualized unwide unzoom diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 83289fa102..e3ebffc20c 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -60,6 +60,8 @@ "PowerToys.FancyZonesEditorCommon.dll", "PowerToys.FancyZonesModuleInterface.dll", "PowerToys.FancyZones.exe", + "FancyZonesCLI.exe", + "FancyZonesCLI.dll", "PowerToys.GcodePreviewHandler.dll", "PowerToys.GcodePreviewHandler.exe", @@ -351,6 +353,11 @@ "Microsoft.SemanticKernel.Connectors.Ollama.dll", "OllamaSharp.dll", + "boost_regex-vc143-mt-gd-x32-1_87.dll", + "boost_regex-vc143-mt-gd-x64-1_87.dll", + "boost_regex-vc143-mt-x32-1_87.dll", + "boost_regex-vc143-mt-x64-1_87.dll", + "UnitsNet.dll", "UtfUnknown.dll", "Wpf.Ui.dll" diff --git a/.pipelines/versionAndSignCheck.ps1 b/.pipelines/versionAndSignCheck.ps1 index 1bb271300d..f90e59afd6 100644 --- a/.pipelines/versionAndSignCheck.ps1 +++ b/.pipelines/versionAndSignCheck.ps1 @@ -52,7 +52,12 @@ $nullVersionExceptions = @( "System.Diagnostics.EventLog.Messages.dll", "Microsoft.Windows.Widgets.dll", "AdaptiveCards.ObjectModel.WinUI3.dll", - "AdaptiveCards.Rendering.WinUI3.dll") -join '|'; + "AdaptiveCards.Rendering.WinUI3.dll", + "boost_regex_vc143_mt_gd_x32_1_87.dll", + "boost_regex_vc143_mt_gd_x64_1_87.dll", + "boost_regex_vc143_mt_x32_1_87.dll", + "boost_regex_vc143_mt_x64_1_87.dll" + ) -join '|'; $totalFailure = 0; Write-Host $DirPath; diff --git a/COMMUNITY.md b/COMMUNITY.md index d145cafd57..c18bacc8c9 100644 --- a/COMMUNITY.md +++ b/COMMUNITY.md @@ -121,6 +121,9 @@ PowerToys Awake is a tool to keep your computer awake. Randy contributed Registry Preview and some very early conversations about keyboard remapping. +### [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon +Kayla was a former lead for PowerToys and helped create multiple utilities, maintained the GitHub repo, and collaborated with the community to improve the overall product + ### [@oldnewthing](https://github.com/oldnewthing) - Raymond Chen Find My Mouse is based on Raymond Chen's SuperSonar. @@ -180,7 +183,6 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter ## PowerToys core team -- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Lead - [@craigloewen-msft](https://github.com/craigloewen-msft) - Craig Loewen - Product Manager - [@niels9001](https://github.com/niels9001/) - Niels Laute - Product Manager - [@dhowett](https://github.com/dhowett) - Dustin Howett - Dev Lead @@ -209,6 +211,7 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter ## Former PowerToys core team members - [@indierawk2k2](https://github.com/indierawk2k2) - Mike Harsh - Product Manager +- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Product Manager - [@ethanfangg](https://github.com/ethanfangg) - Ethan Fang - Product Manager - [@plante-msft](https://github.com/plante-msft) - Connor Plante - Product Manager - [@joadoumie](https://github.com/joadoumie) - Jordi Adoumie - Product Manager diff --git a/Directory.Packages.props b/Directory.Packages.props index 3d64052a21..eb04903b7e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,6 +40,7 @@ + diff --git a/PowerToys.slnx b/PowerToys.slnx index c946514fb5..1884b2d58b 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -370,6 +370,10 @@ + + + + diff --git a/src/common/ManagedCsWin32/CLSID.cs b/src/common/ManagedCsWin32/CLSID.cs index 6087ba575b..00315fe737 100644 --- a/src/common/ManagedCsWin32/CLSID.cs +++ b/src/common/ManagedCsWin32/CLSID.cs @@ -16,4 +16,5 @@ public static partial class CLSID public static readonly Guid CollatorDataSource = new Guid("9E175B8B-F52A-11D8-B9A5-505054503030"); public static readonly Guid ApplicationActivationManager = new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C"); public static readonly Guid VirtualDesktopManager = new("aa509086-5ca9-4c25-8f95-589d3c07b48a"); + public static readonly Guid DesktopWallpaper = new("C2CF3110-460E-4FC1-B9D0-8A1C0C9CC4BD"); } diff --git a/src/common/ManagedCsWin32/Ole32.cs b/src/common/ManagedCsWin32/Ole32.cs index 20181f3626..cf56c80373 100644 --- a/src/common/ManagedCsWin32/Ole32.cs +++ b/src/common/ManagedCsWin32/Ole32.cs @@ -16,6 +16,12 @@ public static partial class Ole32 CLSCTX dwClsContext, ref Guid riid, out IntPtr rReturnedComObject); + + [LibraryImport("ole32.dll")] + internal static partial int CoInitializeEx(nint pvReserved, uint dwCoInit); + + [LibraryImport("ole32.dll")] + internal static partial void CoUninitialize(); } [Flags] diff --git a/src/common/UITestAutomation/ScreenRecording.cs b/src/common/UITestAutomation/ScreenRecording.cs new file mode 100644 index 0000000000..57e844936d --- /dev/null +++ b/src/common/UITestAutomation/ScreenRecording.cs @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + /// + /// Provides methods for recording the screen during UI tests. + /// Requires FFmpeg to be installed and available in PATH. + /// + internal class ScreenRecording : IDisposable + { + private readonly string outputDirectory; + private readonly string framesDirectory; + private readonly string outputFilePath; + private readonly List capturedFrames; + private readonly SemaphoreSlim recordingLock = new(1, 1); + private readonly Stopwatch recordingStopwatch = new(); + private readonly string? ffmpegPath; + private CancellationTokenSource? recordingCancellation; + private Task? recordingTask; + private bool isRecording; + private int frameCount; + + [DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("gdi32.dll")] + private static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + [DllImport("user32.dll")] + private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [DllImport("user32.dll")] + private static extern bool GetCursorInfo(out ScreenCapture.CURSORINFO pci); + + [DllImport("user32.dll")] + private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags); + + private const int CURSORSHOWING = 0x00000001; + private const int DESKTOPHORZRES = 118; + private const int DESKTOPVERTRES = 117; + private const int DINORMAL = 0x0003; + private const int TargetFps = 15; // 15 FPS for good balance of quality and size + + /// + /// Initializes a new instance of the class. + /// + /// Directory where the recording will be saved. + public ScreenRecording(string outputDirectory) + { + this.outputDirectory = outputDirectory; + string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + framesDirectory = Path.Combine(outputDirectory, $"frames_{timestamp}"); + outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4"); + capturedFrames = new List(); + frameCount = 0; + + // Check if FFmpeg is available + ffmpegPath = FindFfmpeg(); + if (ffmpegPath == null) + { + Console.WriteLine("FFmpeg not found. Screen recording will be disabled."); + Console.WriteLine("To enable video recording, install FFmpeg: https://ffmpeg.org/download.html"); + } + } + + /// + /// Gets a value indicating whether screen recording is available (FFmpeg found). + /// + public bool IsAvailable => ffmpegPath != null; + + /// + /// Starts recording the screen. + /// + /// A task representing the asynchronous operation. + public async Task StartRecordingAsync() + { + await recordingLock.WaitAsync(); + try + { + if (isRecording || !IsAvailable) + { + return; + } + + // Create frames directory + Directory.CreateDirectory(framesDirectory); + + recordingCancellation = new CancellationTokenSource(); + isRecording = true; + recordingStopwatch.Start(); + + // Start the recording task + recordingTask = Task.Run(() => RecordFrames(recordingCancellation.Token)); + + Console.WriteLine($"Started screen recording at {TargetFps} FPS"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start recording: {ex.Message}"); + isRecording = false; + } + finally + { + recordingLock.Release(); + } + } + + /// + /// Stops recording and encodes video. + /// + /// A task representing the asynchronous operation. + public async Task StopRecordingAsync() + { + await recordingLock.WaitAsync(); + try + { + if (!isRecording || recordingCancellation == null) + { + return; + } + + // Signal cancellation + recordingCancellation.Cancel(); + + // Wait for recording task to complete + if (recordingTask != null) + { + await recordingTask; + } + + recordingStopwatch.Stop(); + isRecording = false; + + double duration = recordingStopwatch.Elapsed.TotalSeconds; + Console.WriteLine($"Recording stopped. Captured {capturedFrames.Count} frames in {duration:F2} seconds"); + + // Encode to video + await EncodeToVideoAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"Error stopping recording: {ex.Message}"); + } + finally + { + Cleanup(); + recordingLock.Release(); + } + } + + /// + /// Records frames from the screen. + /// + private void RecordFrames(CancellationToken cancellationToken) + { + try + { + int frameInterval = 1000 / TargetFps; + var frameTimer = Stopwatch.StartNew(); + + while (!cancellationToken.IsCancellationRequested) + { + var frameStart = frameTimer.ElapsedMilliseconds; + + try + { + CaptureFrame(); + } + catch (Exception ex) + { + Console.WriteLine($"Error capturing frame: {ex.Message}"); + } + + // Sleep for remaining time to maintain target FPS + var frameTime = frameTimer.ElapsedMilliseconds - frameStart; + var sleepTime = Math.Max(0, frameInterval - (int)frameTime); + + if (sleepTime > 0) + { + Thread.Sleep(sleepTime); + } + } + } + catch (OperationCanceledException) + { + // Expected when stopping + } + catch (Exception ex) + { + Console.WriteLine($"Error during recording: {ex.Message}"); + } + } + + /// + /// Captures a single frame. + /// + private void CaptureFrame() + { + IntPtr hdc = GetDC(IntPtr.Zero); + int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES); + int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES); + ReleaseDC(IntPtr.Zero, hdc); + + Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight); + using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb)) + { + using (Graphics g = Graphics.FromImage(bitmap)) + { + g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size); + + ScreenCapture.CURSORINFO cursorInfo; + cursorInfo.CbSize = Marshal.SizeOf(); + if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING) + { + IntPtr hdcDest = g.GetHdc(); + DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL); + g.ReleaseHdc(hdcDest); + } + } + + string framePath = Path.Combine(framesDirectory, $"frame_{frameCount:D6}.jpg"); + bitmap.Save(framePath, ImageFormat.Jpeg); + capturedFrames.Add(framePath); + frameCount++; + } + } + + /// + /// Encodes captured frames to video using ffmpeg. + /// + private async Task EncodeToVideoAsync() + { + if (capturedFrames.Count == 0) + { + Console.WriteLine("No frames captured"); + return; + } + + try + { + // Build ffmpeg command with proper non-interactive flags + string inputPattern = Path.Combine(framesDirectory, "frame_%06d.jpg"); + + // -y: overwrite without asking + // -nostdin: disable interaction + // -loglevel error: only show errors + // -stats: show encoding progress + string args = $"-y -nostdin -loglevel error -stats -framerate {TargetFps} -i \"{inputPattern}\" -c:v libx264 -pix_fmt yuv420p -crf 23 \"{outputFilePath}\""; + + Console.WriteLine($"Encoding {capturedFrames.Count} frames to video..."); + + var startInfo = new ProcessStartInfo + { + FileName = ffmpegPath!, + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, // Important: redirect stdin to prevent hanging + CreateNoWindow = true, + }; + + using var process = Process.Start(startInfo); + if (process != null) + { + // Close stdin immediately to ensure FFmpeg doesn't wait for input + process.StandardInput.Close(); + + // Read output streams asynchronously to prevent deadlock + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + // Wait for process to exit + await process.WaitForExitAsync(); + + // Get the output + string stdout = await outputTask; + string stderr = await errorTask; + + if (process.ExitCode == 0 && File.Exists(outputFilePath)) + { + var fileInfo = new FileInfo(outputFilePath); + Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024 / 1024:F1} MB)"); + } + else + { + Console.WriteLine($"FFmpeg encoding failed with exit code {process.ExitCode}"); + if (!string.IsNullOrWhiteSpace(stderr)) + { + Console.WriteLine($"FFmpeg error: {stderr}"); + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error encoding video: {ex.Message}"); + } + } + + /// + /// Finds ffmpeg executable. + /// + private static string? FindFfmpeg() + { + // Check if ffmpeg is in PATH + var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty(); + + foreach (var dir in pathDirs) + { + var ffmpegPath = Path.Combine(dir, "ffmpeg.exe"); + if (File.Exists(ffmpegPath)) + { + return ffmpegPath; + } + } + + // Check common installation locations + var commonPaths = new[] + { + @"C:\.tools\ffmpeg\bin\ffmpeg.exe", + @"C:\ffmpeg\bin\ffmpeg.exe", + @"C:\Program Files\ffmpeg\bin\ffmpeg.exe", + @"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe", + @$"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\WinGet\Links\ffmpeg.exe", + }; + + foreach (var path in commonPaths) + { + if (File.Exists(path)) + { + return path; + } + } + + return null; + } + + /// + /// Gets the path to the recorded video file. + /// + public string OutputFilePath => outputFilePath; + + /// + /// Gets the directory containing recordings. + /// + public string OutputDirectory => outputDirectory; + + /// + /// Cleans up resources. + /// + private void Cleanup() + { + recordingCancellation?.Dispose(); + recordingCancellation = null; + recordingTask = null; + + // Clean up frames directory if it exists + try + { + if (Directory.Exists(framesDirectory)) + { + Directory.Delete(framesDirectory, true); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to cleanup frames directory: {ex.Message}"); + } + } + + /// + /// Disposes resources. + /// + public void Dispose() + { + if (isRecording) + { + StopRecordingAsync().GetAwaiter().GetResult(); + } + + Cleanup(); + recordingLock.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/common/UITestAutomation/SessionHelper.cs b/src/common/UITestAutomation/SessionHelper.cs index 0ca3eb3ddd..fef220a647 100644 --- a/src/common/UITestAutomation/SessionHelper.cs +++ b/src/common/UITestAutomation/SessionHelper.cs @@ -130,9 +130,13 @@ namespace Microsoft.PowerToys.UITest /// /// The path to the application executable. /// Optional command line arguments to pass to the application. - public void StartExe(string appPath, string[]? args = null) + public void StartExe(string appPath, string[]? args = null, string? enableModules = null) { var opts = new AppiumOptions(); + if (!string.IsNullOrEmpty(enableModules)) + { + opts.AddAdditionalCapability("enableModules", enableModules); + } if (scope == PowerToysModule.PowerToysSettings) { @@ -169,27 +173,66 @@ namespace Microsoft.PowerToys.UITest private void TryLaunchPowerToysSettings(AppiumOptions opts) { - try + if (opts.ToCapabilities().HasCapability("enableModules")) { - var runnerProcessInfo = new ProcessStartInfo + var modulesString = (string)opts.ToCapabilities().GetCapability("enableModules"); + var modulesArray = modulesString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + SettingsConfigHelper.ConfigureGlobalModuleSettings(modulesArray); + } + else + { + SettingsConfigHelper.ConfigureGlobalModuleSettings(); + } + + const int maxTries = 3; + const int delayMs = 5000; + const int maxRetries = 3; + + for (int tryCount = 1; tryCount <= maxTries; tryCount++) + { + try { - FileName = locationPath + runnerPath, - Verb = "runas", - Arguments = "--open-settings", - }; + var runnerProcessInfo = new ProcessStartInfo + { + FileName = locationPath + runnerPath, + Verb = "runas", + Arguments = "--open-settings", + }; - ExitExe(runnerProcessInfo.FileName); - runner = Process.Start(runnerProcessInfo); + ExitExe(runnerProcessInfo.FileName); - WaitForWindowAndSetCapability(opts, "PowerToys Settings", 5000, 5); + // Verify process was killed + string exeName = Path.GetFileNameWithoutExtension(runnerProcessInfo.FileName); + var remainingProcesses = Process.GetProcessesByName(exeName); - // Exit CmdPal UI before launching new process if use installer for test - ExitExeByName("Microsoft.CmdPal.UI"); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to launch PowerToys Settings: {ex.Message}", ex); + runner = Process.Start(runnerProcessInfo); + + if (WaitForWindowAndSetCapability(opts, "PowerToys Settings", delayMs, maxRetries)) + { + // Exit CmdPal UI before launching new process if use installer for test + ExitExeByName("Microsoft.CmdPal.UI"); + return; + } + + // Window not found, kill all PowerToys processes and retry + if (tryCount < maxTries) + { + KillPowerToysProcesses(); + } + } + catch (Exception ex) + { + if (tryCount == maxTries) + { + throw new InvalidOperationException($"Failed to launch PowerToys Settings after {maxTries} attempts: {ex.Message}", ex); + } + + // Kill processes and retry + KillPowerToysProcesses(); + } } + + throw new InvalidOperationException($"Failed to launch PowerToys Settings: Window not found after {maxTries} attempts."); } private void TryLaunchCommandPalette(AppiumOptions opts) @@ -211,7 +254,10 @@ namespace Microsoft.PowerToys.UITest var process = Process.Start(processStartInfo); process?.WaitForExit(); - WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10); + if (!WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10)) + { + throw new TimeoutException("Failed to find Command Palette window after multiple attempts."); + } } catch (Exception ex) { @@ -219,7 +265,7 @@ namespace Microsoft.PowerToys.UITest } } - private void WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries) + private bool WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries) { for (int attempt = 1; attempt <= maxRetries; attempt++) { @@ -230,18 +276,16 @@ namespace Microsoft.PowerToys.UITest { var hexHwnd = window[0].HWnd.ToString("x"); opts.AddAdditionalCapability("appTopLevelWindow", hexHwnd); - return; + return true; } if (attempt < maxRetries) { Thread.Sleep(delayMs); } - else - { - throw new TimeoutException($"Failed to find {windowName} window after multiple attempts."); - } } + + return false; } /// @@ -292,17 +336,17 @@ namespace Microsoft.PowerToys.UITest catch (Exception ex) { // Handle exceptions if needed - Debug.WriteLine($"Exception during Cleanup: {ex.Message}"); + Console.WriteLine($"Exception during Cleanup: {ex.Message}"); } } /// /// Restarts now exe and takes control of it. /// - public void RestartScopeExe() + public void RestartScopeExe(string? enableModules = null) { ExitScopeExe(); - StartExe(locationPath + sessionPath, this.commandLineArgs); + StartExe(locationPath + sessionPath, commandLineArgs, enableModules); } public WindowsDriver GetRoot() @@ -327,5 +371,31 @@ namespace Microsoft.PowerToys.UITest this.ExitExe(winAppDriverProcessInfo.FileName); SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo); } + + private void KillPowerToysProcesses() + { + var powerToysProcessNames = new[] { "PowerToys", "Microsoft.CmdPal.UI" }; + + foreach (var processName in powerToysProcessNames) + { + try + { + var processes = Process.GetProcessesByName(processName); + + foreach (var process in processes) + { + process.Kill(); + process.WaitForExit(); + } + + // Verify processes are actually gone + var remainingProcesses = Process.GetProcessesByName(processName); + } + catch (Exception ex) + { + Console.WriteLine($"[KillPowerToysProcesses] Failed to kill process {processName}: {ex.Message}"); + } + } + } } } diff --git a/src/common/UITestAutomation/SettingsConfigHelper.cs b/src/common/UITestAutomation/SettingsConfigHelper.cs index 0a01891dc4..81e5e3c180 100644 --- a/src/common/UITestAutomation/SettingsConfigHelper.cs +++ b/src/common/UITestAutomation/SettingsConfigHelper.cs @@ -26,14 +26,13 @@ namespace Microsoft.PowerToys.UITest /// /// Configures global PowerToys settings to enable only specified modules and disable all others. /// - /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. - /// Thrown when modulesToEnable is null. + /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. If null or empty, all modules will be disabled. /// Thrown when settings file operations fail. [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] - public static void ConfigureGlobalModuleSettings(params string[] modulesToEnable) + public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable) { - ArgumentNullException.ThrowIfNull(modulesToEnable); + modulesToEnable ??= Array.Empty(); try { diff --git a/src/common/UITestAutomation/UITestBase.cs b/src/common/UITestAutomation/UITestBase.cs index 1c72be05f4..877f384104 100644 --- a/src/common/UITestAutomation/UITestBase.cs +++ b/src/common/UITestAutomation/UITestBase.cs @@ -29,6 +29,8 @@ namespace Microsoft.PowerToys.UITest public string? ScreenshotDirectory { get; set; } + public string? RecordingDirectory { get; set; } + public static MonitorInfoData.ParamsWrapper MonitorInfoData { get; set; } = new MonitorInfoData.ParamsWrapper() { Monitors = new List() }; private readonly PowerToysModule scope; @@ -36,6 +38,7 @@ namespace Microsoft.PowerToys.UITest private readonly string[]? commandLineArgs; private SessionHelper? sessionHelper; private System.Threading.Timer? screenshotTimer; + private ScreenRecording? screenRecording; public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings, WindowSize size = WindowSize.UnSpecified, string[]? commandLineArgs = null) { @@ -65,12 +68,35 @@ namespace Microsoft.PowerToys.UITest CloseOtherApplications(); if (IsInPipeline) { - ScreenshotDirectory = Path.Combine(this.TestContext.TestResultsDirectory ?? string.Empty, "UITestScreenshots_" + Guid.NewGuid().ToString()); + string baseDirectory = this.TestContext.TestResultsDirectory ?? string.Empty; + ScreenshotDirectory = Path.Combine(baseDirectory, "UITestScreenshots_" + Guid.NewGuid().ToString()); Directory.CreateDirectory(ScreenshotDirectory); + RecordingDirectory = Path.Combine(baseDirectory, "UITestRecordings_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(RecordingDirectory); + // Take screenshot every 1 second screenshotTimer = new System.Threading.Timer(ScreenCapture.TimerCallback, ScreenshotDirectory, TimeSpan.Zero, TimeSpan.FromMilliseconds(1000)); + // Start screen recording (requires FFmpeg) + try + { + screenRecording = new ScreenRecording(RecordingDirectory); + if (screenRecording.IsAvailable) + { + _ = screenRecording.StartRecordingAsync(); + } + else + { + screenRecording = null; + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start screen recording: {ex.Message}"); + screenRecording = null; + } + // Escape Popups before starting System.Windows.Forms.SendKeys.SendWait("{ESC}"); } @@ -88,15 +114,36 @@ namespace Microsoft.PowerToys.UITest if (IsInPipeline) { screenshotTimer?.Change(Timeout.Infinite, Timeout.Infinite); - Dispose(); + + // Stop screen recording + if (screenRecording != null) + { + try + { + screenRecording.StopRecordingAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to stop screen recording: {ex.Message}"); + } + } + if (TestContext.CurrentTestOutcome is UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Unknown) { Task.Delay(1000).Wait(); AddScreenShotsToTestResultsDirectory(); + AddRecordingsToTestResultsDirectory(); AddLogFilesToTestResultsDirectory(); } + else + { + // Clean up recording if test passed + CleanupRecordingDirectory(); + } + + Dispose(); } this.Session.Cleanup(); @@ -106,6 +153,7 @@ namespace Microsoft.PowerToys.UITest public void Dispose() { screenshotTimer?.Dispose(); + screenRecording?.Dispose(); GC.SuppressFinalize(this); } @@ -600,6 +648,47 @@ namespace Microsoft.PowerToys.UITest } } + /// + /// Adds screen recordings to test results directory when test fails. + /// + protected void AddRecordingsToTestResultsDirectory() + { + if (RecordingDirectory != null && Directory.Exists(RecordingDirectory)) + { + // Add video files (MP4) + var videoFiles = Directory.GetFiles(RecordingDirectory, "*.mp4"); + foreach (string file in videoFiles) + { + this.TestContext.AddResultFile(file); + var fileInfo = new FileInfo(file); + Console.WriteLine($"Added video recording: {Path.GetFileName(file)} ({fileInfo.Length / 1024 / 1024:F1} MB)"); + } + + if (videoFiles.Length == 0) + { + Console.WriteLine("No video recording available (FFmpeg not found). Screenshots are still captured."); + } + } + } + + /// + /// Cleans up recording directory when test passes. + /// + private void CleanupRecordingDirectory() + { + if (RecordingDirectory != null && Directory.Exists(RecordingDirectory)) + { + try + { + Directory.Delete(RecordingDirectory, true); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to cleanup recording directory: {ex.Message}"); + } + } + } + /// /// Copies PowerToys log files to test results directory when test fails. /// Renames files to include the directory structure after \PowerToys. @@ -689,11 +778,11 @@ namespace Microsoft.PowerToys.UITest /// /// Restart scope exe. /// - public void RestartScopeExe() + public Session RestartScopeExe(string? enableModules = null) { - this.sessionHelper!.RestartScopeExe(); + this.sessionHelper!.RestartScopeExe(enableModules); this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), this.scope, this.size); - return; + return Session; } /// diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp index 170dde5b0a..a5973a396f 100644 --- a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -50,6 +50,7 @@ enum class ScheduleMode Off, FixedHours, SunsetToSunrise, + FollowNightLight, // add more later }; @@ -61,6 +62,8 @@ inline std::wstring ToString(ScheduleMode mode) return L"SunsetToSunrise"; case ScheduleMode::FixedHours: return L"FixedHours"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; default: return L"Off"; } @@ -72,6 +75,8 @@ inline ScheduleMode FromString(const std::wstring& str) return ScheduleMode::SunsetToSunrise; if (str == L"FixedHours") return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; return ScheduleMode::Off; } @@ -167,7 +172,9 @@ public: ToString(g_settings.m_scheduleMode), { { L"Off", L"Disable the schedule" }, { L"FixedHours", L"Set hours manually" }, - { L"SunsetToSunrise", L"Use sunrise/sunset times" } }); + { L"SunsetToSunrise", L"Use sunrise/sunset times" }, + { L"FollowNightLight", L"Follow Windows Night Light state" } + }); // Integer spinners settings.add_int_spinner( diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index 845e24fa93..b6684da54e 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -13,10 +13,12 @@ #include #include "LightSwitchStateManager.h" #include +#include SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; HANDLE g_ServiceStopEvent = nullptr; +static LightSwitchStateManager* g_stateManagerPtr = nullptr; VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); @@ -168,7 +170,15 @@ static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateMan } // Use shared helper (handles wraparound logic) - bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + bool shouldBeLight = false; + if (s.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !IsNightLightEnabled(); + } + else + { + shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + } // Compare current system/apps theme bool currentSystemLight = GetCurrentSystemTheme(); @@ -199,15 +209,40 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) // Initialization // ──────────────────────────────────────────────────────────────── static LightSwitchStateManager stateManager; + g_stateManagerPtr = &stateManager; LightSwitchSettings::instance().InitFileWatcher(); HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent(); + static std::unique_ptr g_nightLightWatcher; + LightSwitchSettings::instance().LoadSettings(); const auto& settings = LightSwitchSettings::instance().settings(); + // after loading settings: + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + SYSTEMTIME st; GetLocalTime(&st); int nowMinutes = st.wHour * 60 + st.wMinute; @@ -274,6 +309,31 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) ResetEvent(hSettingsChanged); LightSwitchSettings::instance().LoadSettings(); stateManager.OnSettingsChanged(); + + const auto& settings = LightSwitchSettings::instance().settings(); + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + + stateManager.OnNightLightChange(); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + continue; } } @@ -285,6 +345,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) CloseHandle(hManualOverride); if (hParent) CloseHandle(hParent); + if (g_nightLightWatcher) + { + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } Logger::info(L"[LightSwitchService] Worker thread exiting cleanly."); return 0; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index a3a505f897..e1c8052de6 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -76,6 +76,7 @@ + @@ -88,6 +89,7 @@ + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters index 795df99aba..55c7bde39b 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -36,6 +36,9 @@ Source Files + + Source Files + @@ -62,6 +65,9 @@ Header Files + + Header Files + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h index d4029d072d..1d1c7953fe 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -19,7 +19,8 @@ enum class ScheduleMode { Off, FixedHours, - SunsetToSunrise + SunsetToSunrise, + FollowNightLight, // Add more in the future }; @@ -31,6 +32,8 @@ inline std::wstring ToString(ScheduleMode mode) return L"FixedHours"; case ScheduleMode::SunsetToSunrise: return L"SunsetToSunrise"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; default: return L"Off"; } @@ -42,6 +45,8 @@ inline ScheduleMode FromString(const std::wstring& str) return ScheduleMode::SunsetToSunrise; if (str == L"FixedHours") return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; else return ScheduleMode::Off; } diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp index 4fba4ae9a6..f562d38c41 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -31,7 +31,10 @@ void LightSwitchStateManager::OnSettingsChanged() void LightSwitchStateManager::OnTick(int currentMinutes) { std::lock_guard lock(_stateMutex); - EvaluateAndApplyIfNeeded(); + if (_state.lastAppliedMode != ScheduleMode::FollowNightLight) + { + EvaluateAndApplyIfNeeded(); + } } // Called when manual override is triggered @@ -49,8 +52,38 @@ void LightSwitchStateManager::OnManualOverride() _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).", - (_state.isSystemLightActive ? L"light" : L"dark"), - (_state.isAppsLightActive ? L"light" : L"dark")); + (_state.isSystemLightActive ? L"light" : L"dark"), + (_state.isAppsLightActive ? L"light" : L"dark")); + } + + EvaluateAndApplyIfNeeded(); +} + +// Runs with the registry observer detects a change in Night Light settings. +void LightSwitchStateManager::OnNightLightChange() +{ + std::lock_guard lock(_stateMutex); + + bool newNightLightState = IsNightLightEnabled(); + + // In Follow Night Light mode, treat a Night Light toggle as a boundary + if (_state.lastAppliedMode == ScheduleMode::FollowNightLight && _state.isManualOverride) + { + Logger::info(L"[LightSwitchStateManager] Night Light changed while manual override active; " + L"treating as a boundary and clearing manual override."); + _state.isManualOverride = false; + } + + if (newNightLightState != _state.isNightLightActive) + { + Logger::info(L"[LightSwitchStateManager] Night Light toggled to {}", + newNightLightState ? L"ON" : L"OFF"); + + _state.isNightLightActive = newNightLightState; + } + else + { + Logger::debug(L"[LightSwitchStateManager] Night Light change event fired, but no actual change."); } EvaluateAndApplyIfNeeded(); @@ -77,9 +110,9 @@ void LightSwitchStateManager::SyncInitialThemeState() _state.isSystemLightActive = GetCurrentSystemTheme(); _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})", - _state.isSystemLightActive ? L"light" : L"dark"); + _state.isSystemLightActive ? L"light" : L"dark"); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})", - _state.isAppsLightActive ? L"light" : L"dark"); + _state.isAppsLightActive ? L"light" : L"dark"); } static std::pair update_sun_times(auto& settings) @@ -194,7 +227,15 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastAppliedMode = _currentSettings.scheduleMode; - bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + bool shouldBeLight = false; + if (_currentSettings.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !_state.isNightLightActive; + } + else + { + shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + } bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight); bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight); @@ -227,6 +268,3 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastTickMinutes = now; } - - - diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h index 5c9bcc6e25..c4f39a2e9a 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -9,6 +9,7 @@ struct LightSwitchState bool isManualOverride = false; bool isSystemLightActive = false; bool isAppsLightActive = false; + bool isNightLightActive = false; int lastEvaluatedDay = -1; int lastTickMinutes = -1; @@ -32,6 +33,9 @@ public: // Called when manual override is toggled (via shortcut or system change). void OnManualOverride(); + // Called when night light changes in windows settings + void OnNightLightChange(); + // Initial sync at startup to align internal state with system theme void SyncInitialThemeState(); diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp new file mode 100644 index 0000000000..8da19c6595 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp @@ -0,0 +1 @@ +#include "NightLightRegistryObserver.h" diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h new file mode 100644 index 0000000000..2806c28316 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h @@ -0,0 +1,134 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class NightLightRegistryObserver +{ +public: + NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function callback) : + _root(root), _subkey(subkey), _callback(std::move(callback)), _stop(false) + { + _thread = std::thread([this]() { this->Run(); }); + } + + ~NightLightRegistryObserver() + { + Stop(); + } + + void Stop() + { + _stop = true; + + { + std::lock_guard lock(_mutex); + if (_event) + SetEvent(_event); + } + + if (_thread.joinable()) + _thread.join(); + + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + + +private: + void Run() + { + { + std::lock_guard lock(_mutex); + if (RegOpenKeyExW(_root, _subkey.c_str(), 0, KEY_NOTIFY, &_hKey) != ERROR_SUCCESS) + return; + + _event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!_event) + { + RegCloseKey(_hKey); + _hKey = nullptr; + return; + } + } + + while (!_stop) + { + HKEY hKeyLocal = nullptr; + HANDLE eventLocal = nullptr; + + { + std::lock_guard lock(_mutex); + if (_stop) + break; + + hKeyLocal = _hKey; + eventLocal = _event; + } + + if (!hKeyLocal || !eventLocal) + break; + + if (_stop) + break; + + if (RegNotifyChangeKeyValue(hKeyLocal, FALSE, REG_NOTIFY_CHANGE_LAST_SET, eventLocal, TRUE) != ERROR_SUCCESS) + break; + + DWORD wait = WaitForSingleObject(eventLocal, INFINITE); + if (_stop || wait == WAIT_FAILED) + break; + + ResetEvent(eventLocal); + + if (!_stop && _callback) + { + try + { + _callback(); + } + catch (...) + { + } + } + } + + { + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + } + + + HKEY _root; + std::wstring _subkey; + std::function _callback; + HANDLE _event = nullptr; + HKEY _hKey = nullptr; + std::thread _thread; + std::atomic _stop; + std::mutex _mutex; +}; \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h index 4872864eff..8015c9b3e6 100644 --- a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h @@ -11,4 +11,7 @@ enum class SettingId Sunset_Offset, ChangeSystem, ChangeApps -}; \ No newline at end of file +}; + +constexpr wchar_t PERSONALIZATION_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr wchar_t NIGHT_LIGHT_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.bluelightreduction.bluelightreductionstate\\windows.data.bluelightreduction.bluelightreductionstate"; diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp index 9633ab2fde..cfa858c636 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp @@ -3,6 +3,7 @@ #include #include #include "ThemeHelper.h" +#include // Controls changing the themes. @@ -10,7 +11,7 @@ static void ResetColorPrevalence() { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -31,7 +32,7 @@ void SetAppsTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -50,7 +51,7 @@ void SetSystemTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -79,7 +80,7 @@ bool GetCurrentSystemTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -98,7 +99,7 @@ bool GetCurrentAppsTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -109,3 +110,30 @@ bool GetCurrentAppsTheme() return value == 1; // true = light, false = dark } + +bool IsNightLightEnabled() +{ + HKEY hKey; + const wchar_t* path = NIGHT_LIGHT_REGISTRY_PATH; + + if (RegOpenKeyExW(HKEY_CURRENT_USER, path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return false; + + // RegGetValueW will set size to the size of the data and we expect that to be at least 25 bytes (we need to access bytes 23 and 24) + DWORD size = 0; + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, nullptr, &size) != ERROR_SUCCESS || size < 25) + { + RegCloseKey(hKey); + return false; + } + + std::vector data(size); + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, data.data(), &size) != ERROR_SUCCESS) + { + RegCloseKey(hKey); + return false; + } + + RegCloseKey(hKey); + return data[23] == 0x10 && data[24] == 0x00; +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h index 5985fd95c8..e8d45e9c2a 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h @@ -3,3 +3,4 @@ void SetSystemTheme(bool dark); void SetAppsTheme(bool dark); bool GetCurrentSystemTheme(); bool GetCurrentAppsTheme(); +bool IsNightLightEnabled(); \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs index 7cad62decb..5f857aa391 100644 --- a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs +++ b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs @@ -617,6 +617,8 @@ namespace MouseUtils.UITests private void LaunchFromSetting(bool reload = false, bool launchAsAdmin = false) { + Session = RestartScopeExe("FindMyMouse,MouseHighlighter,MouseJump,MousePointerCrosshairs,CursorWrap"); + // this.Session.Attach(PowerToysModule.PowerToysSettings); this.Session.SetMainWindowSize(WindowSize.Large); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs index a381cfda6b..1e8642fff7 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs @@ -19,6 +19,8 @@ public partial class DetailsViewModel(IDetails _details, WeakReference Metadata { get; private set; } = []; @@ -40,6 +42,21 @@ public partial class DetailsViewModel(IDetails _details, WeakReference, IRecipient { @@ -460,4 +461,12 @@ public partial class ShellViewModel : ObservableObject, { _navigationCts?.Cancel(); } + + public void Dispose() + { + _handleInvokeTask?.Dispose(); + _navigationCts?.Dispose(); + + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs new file mode 100644 index 0000000000..71e150a7d2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.WinUI; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable +{ + private static readonly ObservableCollection WindowsColorSwatches = [ + + // row 0 + Color.FromArgb(255, 255, 185, 0), // #ffb900 + Color.FromArgb(255, 255, 140, 0), // #ff8c00 + Color.FromArgb(255, 247, 99, 12), // #f7630c + Color.FromArgb(255, 202, 80, 16), // #ca5010 + Color.FromArgb(255, 218, 59, 1), // #da3b01 + Color.FromArgb(255, 239, 105, 80), // #ef6950 + + // row 1 + Color.FromArgb(255, 209, 52, 56), // #d13438 + Color.FromArgb(255, 255, 67, 67), // #ff4343 + Color.FromArgb(255, 231, 72, 86), // #e74856 + Color.FromArgb(255, 232, 17, 35), // #e81123 + Color.FromArgb(255, 234, 0, 94), // #ea005e + Color.FromArgb(255, 195, 0, 82), // #c30052 + + // row 2 + Color.FromArgb(255, 227, 0, 140), // #e3008c + Color.FromArgb(255, 191, 0, 119), // #bf0077 + Color.FromArgb(255, 194, 57, 179), // #c239b3 + Color.FromArgb(255, 154, 0, 137), // #9a0089 + Color.FromArgb(255, 0, 120, 212), // #0078d4 + Color.FromArgb(255, 0, 99, 177), // #0063b1 + + // row 3 + Color.FromArgb(255, 142, 140, 216), // #8e8cd8 + Color.FromArgb(255, 107, 105, 214), // #6b69d6 + Color.FromArgb(255, 135, 100, 184), // #8764b8 + Color.FromArgb(255, 116, 77, 169), // #744da9 + Color.FromArgb(255, 177, 70, 194), // #b146c2 + Color.FromArgb(255, 136, 23, 152), // #881798 + + // row 4 + Color.FromArgb(255, 0, 153, 188), // #0099bc + Color.FromArgb(255, 45, 125, 154), // #2d7d9a + Color.FromArgb(255, 0, 183, 195), // #00b7c3 + Color.FromArgb(255, 3, 131, 135), // #038387 + Color.FromArgb(255, 0, 178, 148), // #00b294 + Color.FromArgb(255, 1, 133, 116), // #018574 + + // row 5 + Color.FromArgb(255, 0, 204, 106), // #00cc6a + Color.FromArgb(255, 16, 137, 62), // #10893e + Color.FromArgb(255, 122, 117, 116), // #7a7574 + Color.FromArgb(255, 93, 90, 88), // #5d5a58 + Color.FromArgb(255, 104, 118, 138), // #68768a + Color.FromArgb(255, 81, 92, 107), // #515c6b + + // row 6 + Color.FromArgb(255, 86, 124, 115), // #567c73 + Color.FromArgb(255, 72, 104, 96), // #486860 + Color.FromArgb(255, 73, 130, 5), // #498205 + Color.FromArgb(255, 16, 124, 16), // #107c10 + Color.FromArgb(255, 118, 118, 118), // #767676 + Color.FromArgb(255, 76, 74, 72), // #4c4a48 + + // row 7 + Color.FromArgb(255, 105, 121, 126), // #69797e + Color.FromArgb(255, 74, 84, 89), // #4a5459 + Color.FromArgb(255, 100, 124, 100), // #647c64 + Color.FromArgb(255, 82, 94, 84), // #525e54 + Color.FromArgb(255, 132, 117, 69), // #847545 + Color.FromArgb(255, 126, 115, 95), // #7e735f + ]; + + private readonly SettingsModel _settings; + private readonly UISettings _uiSettings; + private readonly IThemeService _themeService; + private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread(); + + private ElementTheme? _elementThemeOverride; + private Color _currentSystemAccentColor; + + public ObservableCollection Swatches => WindowsColorSwatches; + + public int ThemeIndex + { + get => (int)_settings.Theme; + set => Theme = (UserTheme)value; + } + + public UserTheme Theme + { + get => _settings.Theme; + set + { + if (_settings.Theme != value) + { + _settings.Theme = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ThemeIndex)); + Save(); + } + } + } + + public ColorizationMode ColorizationMode + { + get => _settings.ColorizationMode; + set + { + if (_settings.ColorizationMode != value) + { + _settings.ColorizationMode = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorizationModeIndex)); + OnPropertyChanged(nameof(IsCustomTintVisible)); + OnPropertyChanged(nameof(IsCustomTintIntensityVisible)); + OnPropertyChanged(nameof(IsBackgroundControlsVisible)); + OnPropertyChanged(nameof(IsNoBackgroundVisible)); + OnPropertyChanged(nameof(IsAccentColorControlsVisible)); + + if (value == ColorizationMode.WindowsAccentColor) + { + ThemeColor = _currentSystemAccentColor; + } + + IsColorizationDetailsExpanded = value != ColorizationMode.None; + + Save(); + } + } + } + + public int ColorizationModeIndex + { + get => (int)_settings.ColorizationMode; + set => ColorizationMode = (ColorizationMode)value; + } + + public Color ThemeColor + { + get => _settings.CustomThemeColor; + set + { + if (_settings.CustomThemeColor != value) + { + _settings.CustomThemeColor = value; + + OnPropertyChanged(); + + if (ColorIntensity == 0) + { + ColorIntensity = 100; + } + + Save(); + } + } + } + + public int ColorIntensity + { + get => _settings.CustomThemeColorIntensity; + set + { + _settings.CustomThemeColorIntensity = value; + OnPropertyChanged(); + Save(); + } + } + + public string BackgroundImagePath + { + get => _settings.BackgroundImagePath ?? string.Empty; + set + { + if (_settings.BackgroundImagePath != value) + { + _settings.BackgroundImagePath = value; + OnPropertyChanged(); + + if (BackgroundImageOpacity == 0) + { + BackgroundImageOpacity = 100; + } + + Save(); + } + } + } + + public int BackgroundImageOpacity + { + get => _settings.BackgroundImageOpacity; + set + { + if (_settings.BackgroundImageOpacity != value) + { + _settings.BackgroundImageOpacity = value; + OnPropertyChanged(); + Save(); + } + } + } + + public int BackgroundImageBrightness + { + get => _settings.BackgroundImageBrightness; + set + { + if (_settings.BackgroundImageBrightness != value) + { + _settings.BackgroundImageBrightness = value; + OnPropertyChanged(); + Save(); + } + } + } + + public int BackgroundImageBlurAmount + { + get => _settings.BackgroundImageBlurAmount; + set + { + if (_settings.BackgroundImageBlurAmount != value) + { + _settings.BackgroundImageBlurAmount = value; + OnPropertyChanged(); + Save(); + } + } + } + + public BackgroundImageFit BackgroundImageFit + { + get => _settings.BackgroundImageFit; + set + { + if (_settings.BackgroundImageFit != value) + { + _settings.BackgroundImageFit = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(BackgroundImageFitIndex)); + Save(); + } + } + } + + public int BackgroundImageFitIndex + { + // Naming between UI facing string and enum is a bit confusing, but the enum fields + // are based on XAML Stretch enum values. So I'm choosing to keep the confusion here, close + // to the UI. + // - BackgroundImageFit.Fill corresponds to "Stretch" + // - BackgroundImageFit.UniformToFill corresponds to "Fill" + get => BackgroundImageFit switch + { + BackgroundImageFit.Fill => 1, + _ => 0, + }; + set => BackgroundImageFit = value switch + { + 1 => BackgroundImageFit.Fill, + _ => BackgroundImageFit.UniformToFill, + }; + } + + [ObservableProperty] + public partial bool IsColorizationDetailsExpanded { get; set; } + + public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image; + + public bool IsCustomTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image; + + public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image; + + public bool IsNoBackgroundVisible => _settings.ColorizationMode is ColorizationMode.None; + + public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor; + + public AcrylicBackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f); + + public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme; + + public Color EffectiveThemeColor => ColorizationMode switch + { + ColorizationMode.WindowsAccentColor => _currentSystemAccentColor, + ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor, + _ => Colors.Transparent, + }; + + // Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen). + public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f); + + public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0; + + public ImageSource? EffectiveBackgroundImageSource => + ColorizationMode is ColorizationMode.Image + && !string.IsNullOrWhiteSpace(BackgroundImagePath) + && Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri) + ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri) + : null; + + public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings) + { + _themeService = themeService; + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + _settings = settings; + + _uiSettings = new UISettings(); + _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged; + UpdateAccentColor(_uiSettings); + + Reapply(); + + IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None; + } + + private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender)); + + private void UpdateAccentColor(UISettings sender) + { + _currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent); + if (ColorizationMode == ColorizationMode.WindowsAccentColor) + { + ThemeColor = _currentSystemAccentColor; + } + } + + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); + } + + private void Save() + { + SettingsModel.SaveSettings(_settings); + _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); + } + + private void Reapply() + { + // Theme services recalculates effective color and opacity based on current settings. + EffectiveBackdrop = _themeService.Current.BackdropParameters; + OnPropertyChanged(nameof(EffectiveBackdrop)); + OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness)); + OnPropertyChanged(nameof(EffectiveBackgroundImageSource)); + OnPropertyChanged(nameof(EffectiveThemeColor)); + OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount)); + + // LOAD BEARING: + // We need to cycle through the EffectiveTheme property to force reload of resources. + _elementThemeOverride = ElementTheme.Light; + OnPropertyChanged(nameof(EffectiveTheme)); + _elementThemeOverride = ElementTheme.Dark; + OnPropertyChanged(nameof(EffectiveTheme)); + _elementThemeOverride = null; + OnPropertyChanged(nameof(EffectiveTheme)); + } + + [RelayCommand] + private void ResetBackgroundImageProperties() + { + BackgroundImageBrightness = 0; + BackgroundImageBlurAmount = 0; + BackgroundImageFit = BackgroundImageFit.UniformToFill; + BackgroundImageOpacity = 100; + ColorIntensity = 0; + } + + public void Dispose() + { + _uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged; + _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs new file mode 100644 index 0000000000..52102df30a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public enum BackgroundImageFit +{ + Fill, + UniformToFill, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs new file mode 100644 index 0000000000..57a65f1882 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public enum ColorizationMode +{ + None, + WindowsAccentColor, + CustomColor, + Image, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 160fe5d279..d8a64525b8 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -12,6 +12,7 @@ using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Ext.Apps; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.State; +using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; using Microsoft.CommandPalette.Extensions; @@ -33,7 +34,10 @@ public partial class MainListPage : DynamicListPage, private readonly AppStateModel _appStateModel; private List>? _filteredItems; private List>? _filteredApps; - private IEnumerable>? _fallbackItems; + private List>? _fallbackItems; + + // Keep as IEnumerable for deferred execution. Fallback item titles are updated + // asynchronously, so scoring must happen lazily when GetItems is called. private IEnumerable>? _scoredFallbackItems; private bool _includeApps; private bool _filteredItemsIncludesApps; @@ -146,44 +150,24 @@ public partial class MainListPage : DynamicListPage, public override IListItem[] GetItems() { - if (string.IsNullOrEmpty(SearchText)) + lock (_tlcManager.TopLevelCommands) { - lock (_tlcManager.TopLevelCommands) + // Either return the top-level commands (no search text), or the merged and + // filtered results. + if (string.IsNullOrWhiteSpace(SearchText)) { - return _tlcManager - .TopLevelCommands + return _tlcManager.TopLevelCommands .Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title)) .ToArray(); } - } - else - { - lock (_tlcManager.TopLevelCommands) + else { - var limitedApps = new List>(); - - // Fuzzy matching can produce a lot of results, so we want to limit the - // number of apps we show at once if it's a large set. - if (_filteredApps?.Count > 0) - { - limitedApps = _filteredApps.OrderByDescending(s => s.Score).Take(_appResultLimit).ToList(); - } - - var orderedFallbacks = _fallbackItems? - .Where(w => !string.IsNullOrEmpty(w.Item.Title)) - .OrderByDescending(o => o.Score).ToList(); - - var items = Enumerable.Empty>() - .Concat(_filteredItems is not null ? _filteredItems : []) - .Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : []) - .Concat(limitedApps) - .OrderByDescending(o => o.Score) - - // Now append the fallbacks that weren't scored (because they weren't in the global fallbacks list) - .Concat(orderedFallbacks ?? []) - .Select(s => s.Item) - .ToArray(); - return items; + return MainListPageResultFactory.Create( + _filteredItems, + _scoredFallbackItems?.ToList(), + _filteredApps, + _fallbackItems, + _appResultLimit); } } } @@ -402,15 +386,8 @@ public partial class MainListPage : DynamicListPage, return; } - _scoredFallbackItems = ListHelpers.FilterListWithScores(newFallbacksForScoring ?? [], SearchText, scoreItem); - - if (token.IsCancellationRequested) - { - return; - } - Func scoreFallbackItem = (a, b) => { return ScoreFallbackItem(a, b, _settings.FallbackRanks); }; - _fallbackItems = ListHelpers.FilterListWithScores(newFallbacks ?? [], SearchText, scoreFallbackItem); + _fallbackItems = [.. ListHelpers.FilterListWithScores(newFallbacks ?? [], SearchText, scoreFallbackItem)]; if (token.IsCancellationRequested) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs new file mode 100644 index 0000000000..1490512d57 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma warning disable IDE0007 // Use implicit type + +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.UI.ViewModels.Commands; + +internal static class MainListPageResultFactory +{ + /// + /// Creates a merged and ordered array of results from multiple scored input lists, + /// applying an application result limit and filtering fallback items as needed. + /// + public static IListItem[] Create( + IList>? filteredItems, + IList>? scoredFallbackItems, + IList>? filteredApps, + IList>? fallbackItems, + int appResultLimit) + { + if (appResultLimit < 0) + { + throw new ArgumentOutOfRangeException( + nameof(appResultLimit), "App result limit must be non-negative."); + } + + int len1 = filteredItems?.Count ?? 0; + int len2 = scoredFallbackItems?.Count ?? 0; + + // Apps are pre-sorted, so we just need to take the top N, limited by appResultLimit. + int len3 = Math.Min(filteredApps?.Count ?? 0, appResultLimit); + + // Allocate the exact size of the result array. + int totalCount = len1 + len2 + len3 + GetNonEmptyFallbackItemsCount(fallbackItems); + var result = new IListItem[totalCount]; + + // Three-way stable merge of already-sorted lists. + int idx1 = 0, idx2 = 0, idx3 = 0; + int writePos = 0; + + // Merge while all three lists have items. To maintain a stable sort, the + // priority is: list1 > list2 > list3 when scores are equal. + while (idx1 < len1 && idx2 < len2 && idx3 < len3) + { + // Using null-forgiving operator as we have already checked against lengths. + int score1 = filteredItems![idx1].Score; + int score2 = scoredFallbackItems![idx2].Score; + int score3 = filteredApps![idx3].Score; + + if (score1 >= score2 && score1 >= score3) + { + result[writePos++] = filteredItems[idx1++].Item; + } + else if (score2 >= score3) + { + result[writePos++] = scoredFallbackItems[idx2++].Item; + } + else + { + result[writePos++] = filteredApps[idx3++].Item; + } + } + + // Two-way merges for remaining pairs. + while (idx1 < len1 && idx2 < len2) + { + if (filteredItems![idx1].Score >= scoredFallbackItems![idx2].Score) + { + result[writePos++] = filteredItems[idx1++].Item; + } + else + { + result[writePos++] = scoredFallbackItems[idx2++].Item; + } + } + + while (idx1 < len1 && idx3 < len3) + { + if (filteredItems![idx1].Score >= filteredApps![idx3].Score) + { + result[writePos++] = filteredItems[idx1++].Item; + } + else + { + result[writePos++] = filteredApps[idx3++].Item; + } + } + + while (idx2 < len2 && idx3 < len3) + { + if (scoredFallbackItems![idx2].Score >= filteredApps![idx3].Score) + { + result[writePos++] = scoredFallbackItems[idx2++].Item; + } + else + { + result[writePos++] = filteredApps[idx3++].Item; + } + } + + // Drain remaining items from a non-empty list. + while (idx1 < len1) + { + result[writePos++] = filteredItems![idx1++].Item; + } + + while (idx2 < len2) + { + result[writePos++] = scoredFallbackItems![idx2++].Item; + } + + while (idx3 < len3) + { + result[writePos++] = filteredApps![idx3++].Item; + } + + // Append filtered fallback items. Fallback items are added post-sort so they are + // always at the end of the list and are sorted by user settings. + if (fallbackItems is not null) + { + for (int i = 0; i < fallbackItems.Count; i++) + { + var item = fallbackItems[i].Item; + if (!string.IsNullOrEmpty(item.Title)) + { + result[writePos++] = item; + } + } + } + + return result; + } + + private static int GetNonEmptyFallbackItemsCount(IList>? fallbackItems) + { + int fallbackItemsCount = 0; + + if (fallbackItems is not null) + { + for (int i = 0; i < fallbackItems.Count; i++) + { + if (!string.IsNullOrEmpty(fallbackItems[i].Item.Title)) + { + fallbackItemsCount++; + } + } + } + + return fallbackItemsCount; + } +} +#pragma warning restore IDE0007 // Use implicit type diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..140811c784 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class MainWindowViewModel : ObservableObject, IDisposable +{ + private readonly IThemeService _themeService; + private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!; + + [ObservableProperty] + public partial ImageSource? BackgroundImageSource { get; private set; } + + [ObservableProperty] + public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill; + + [ObservableProperty] + public partial double BackgroundImageOpacity { get; private set; } + + [ObservableProperty] + public partial Color BackgroundImageTint { get; private set; } + + [ObservableProperty] + public partial double BackgroundImageTintIntensity { get; private set; } + + [ObservableProperty] + public partial int BackgroundImageBlurAmount { get; private set; } + + [ObservableProperty] + public partial double BackgroundImageBrightness { get; private set; } + + [ObservableProperty] + public partial bool ShowBackgroundImage { get; private set; } + + public MainWindowViewModel(IThemeService themeService) + { + _themeService = themeService; + _themeService.ThemeChanged += ThemeService_ThemeChanged; + } + + private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e) + { + _uiDispatcherQueue.TryEnqueue(() => + { + BackgroundImageSource = _themeService.Current.BackgroundImageSource; + BackgroundImageStretch = _themeService.Current.BackgroundImageStretch; + BackgroundImageOpacity = _themeService.Current.BackgroundImageOpacity; + + BackgroundImageBrightness = _themeService.Current.BackgroundBrightness; + BackgroundImageTint = _themeService.Current.Tint; + BackgroundImageTintIntensity = _themeService.Current.TintIntensity; + BackgroundImageBlurAmount = _themeService.Current.BlurAmount; + + ShowBackgroundImage = BackgroundImageSource != null; + }); + } + + public void Dispose() + { + _themeService.ThemeChanged -= ThemeService_ThemeChanged; + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj index 6b1b018273..1c85aa939b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj @@ -23,11 +23,12 @@ + compile - + compile diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs index be9d103b2d..8bc2a42a92 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -411,6 +411,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } + /// + /// Looks up a localized string similar to Pick background image. + /// + public static string builtin_settings_appearance_pick_background_image_title { + get { + return ResourceManager.GetString("builtin_settings_appearance_pick_background_image_title", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} extensions found. /// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx index 9a658e38f1..bb7637e133 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx @@ -239,4 +239,7 @@ {0} extensions installed + + Pick background image + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs new file mode 100644 index 0000000000..efb7ca1fa1 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/AcrylicBackdropParameters.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +public sealed record AcrylicBackdropParameters(Color TintColor, Color FallbackColor, float TintOpacity, float LuminosityOpacity); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs new file mode 100644 index 0000000000..546742b8f4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Provides theme-related values for the Command Palette and notifies listeners about +/// changes that affect visual appearance (theme, tint, background image, and backdrop). +/// +/// +/// Implementations are expected to monitor system/app theme changes and raise +/// accordingly. Consumers should call +/// once to hook required sources and then query properties/methods for the current visuals. +/// +public interface IThemeService +{ + /// + /// Occurs when the effective theme or any visual-affecting setting changes. + /// + /// + /// Triggered for changes such as app theme (light/dark/default), background image, + /// tint/accent, or backdrop parameters that would require UI to refresh styling. + /// + event EventHandler? ThemeChanged; + + /// + /// Initializes the theme service and starts listening for theme-related changes. + /// + /// + /// Safe to call once during application startup before consuming the service. + /// + void Initialize(); + + /// + /// Gets the current theme settings. + /// + ThemeSnapshot Current { get; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs new file mode 100644 index 0000000000..96197dc376 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Event arguments for theme-related changes. +public class ThemeChangedEventArgs : EventArgs; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs new file mode 100644 index 0000000000..244fd41fba --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Represents a snapshot of theme-related visual settings, including accent color, theme preference, and background +/// image configuration, for use in rendering the Command Palette UI. +/// +public sealed class ThemeSnapshot +{ + /// + /// Gets the accent tint color used by the Command Palette visuals. + /// + public required Color Tint { get; init; } + + /// + /// Gets the accent tint color used by the Command Palette visuals. + /// + public required float TintIntensity { get; init; } + + /// + /// Gets the configured application theme preference. + /// + public required ElementTheme Theme { get; init; } + + /// + /// Gets the image source to render as the background, if any. + /// + /// + /// Returns when no background image is configured. + /// + public required ImageSource? BackgroundImageSource { get; init; } + + /// + /// Gets the stretch mode used to lay out the background image. + /// + public required Stretch BackgroundImageStretch { get; init; } + + /// + /// Gets the opacity applied to the background image. + /// + /// + /// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque. + /// + public required double BackgroundImageOpacity { get; init; } + + /// + /// Gets the effective acrylic backdrop parameters based on current settings and theme. + /// + /// The resolved AcrylicBackdropParameters to apply. + public required AcrylicBackdropParameters BackdropParameters { get; init; } + + public required int BlurAmount { get; init; } + + public required float BackgroundBrightness { get; init; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index fbec08c6c8..483fe3fdc3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -11,7 +11,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.UI; using Windows.Foundation; +using Windows.UI; namespace Microsoft.CmdPal.UI.ViewModels; @@ -64,6 +66,24 @@ public partial class SettingsModel : ObservableObject public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; + public UserTheme Theme { get; set; } = UserTheme.Default; + + public ColorizationMode ColorizationMode { get; set; } + + public Color CustomThemeColor { get; set; } = Colors.Transparent; + + public int CustomThemeColorIntensity { get; set; } = 100; + + public int BackgroundImageOpacity { get; set; } = 20; + + public int BackgroundImageBlurAmount { get; set; } + + public int BackgroundImageBrightness { get; set; } + + public BackgroundImageFit BackgroundImageFit { get; set; } + + public string? BackgroundImagePath { get; set; } + // END SETTINGS /////////////////////////////////////////////////////////////////////////// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 93d7cf7829..947a025e69 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -29,6 +30,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged public event PropertyChangedEventHandler? PropertyChanged; + public AppearanceSettingsViewModel Appearance { get; } + public HotkeySettings? Hotkey { get => _settings.Hotkey; @@ -176,11 +179,13 @@ public partial class SettingsViewModel : INotifyPropertyChanged public SettingsExtensionsViewModel Extensions { get; } - public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler) + public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler, IThemeService themeService) { _settings = settings; _topLevelCommandManager = topLevelCommandManager; + Appearance = new AppearanceSettingsViewModel(themeService, _settings); + var activeProviders = GetCommandProviders(); var allProviderSettings = _settings.ProviderSettings; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs new file mode 100644 index 0000000000..290668f3f5 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public enum UserTheme +{ + Default, + Light, + Dark, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml index f9a9e37ea1..d8d4655291 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml @@ -4,19 +4,23 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.CmdPal.UI.Controls" - xmlns:local="using:Microsoft.CmdPal.UI"> + xmlns:local="using:Microsoft.CmdPal.UI" + xmlns:services="using:Microsoft.CmdPal.UI.Services"> - - - - - - - + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 53f47286b2..a44682218f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -24,9 +24,11 @@ using Microsoft.CmdPal.Ext.WindowsTerminal; using Microsoft.CmdPal.Ext.WindowWalker; using Microsoft.CmdPal.Ext.WinGet; using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; @@ -112,6 +114,17 @@ public partial class App : Application // Root services services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext()); + AddBuiltInCommands(services); + + AddCoreServices(services); + + AddUIServices(services); + + return services.BuildServiceProvider(); + } + + private static void AddBuiltInCommands(ServiceCollection services) + { // Built-in Commands. Order matters - this is the order they'll be presented by default. var allApps = new AllAppsCommandProvider(); var files = new IndexerCommandsProvider(); @@ -154,17 +167,32 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + } + private static void AddUIServices(ServiceCollection services) + { // Models - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); var sm = SettingsModel.LoadSettings(); services.AddSingleton(sm); var state = AppStateModel.LoadState(); services.AddSingleton(state); - services.AddSingleton(); + + // Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + } + + private static void AddCoreServices(ServiceCollection services) + { + // Core services + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -174,7 +202,5 @@ public partial class App : Application // ViewModels services.AddSingleton(); services.AddSingleton(); - - return services.BuildServiceProvider(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs new file mode 100644 index 0000000000..743e68d690 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; +using ManagedCommon; +using Microsoft.Graphics.Canvas.Effects; +using Microsoft.UI; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +internal sealed partial class BlurImageControl : Control +{ + private const string ImageSourceParameterName = "ImageSource"; + + private const string BrightnessEffectName = "Brightness"; + private const string BrightnessOverlayEffectName = "BrightnessOverlay"; + private const string BlurEffectName = "Blur"; + private const string TintBlendEffectName = "TintBlend"; + private const string TintEffectName = "Tint"; + +#pragma warning disable CA1507 // Use nameof to express symbol names ... some of these refer to effect properties that are separate from the class properties + private static readonly string BrightnessSource1AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source1Amount"); + private static readonly string BrightnessSource2AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source2Amount"); + private static readonly string BrightnessOverlayColorEffectProperty = GetPropertyName(BrightnessOverlayEffectName, "Color"); + private static readonly string BlurBlurAmountEffectProperty = GetPropertyName(BlurEffectName, "BlurAmount"); + private static readonly string TintColorEffectProperty = GetPropertyName(TintEffectName, "Color"); +#pragma warning restore CA1507 + + private static readonly string[] AnimatableProperties = [ + BrightnessSource1AmountEffectProperty, + BrightnessSource2AmountEffectProperty, + BrightnessOverlayColorEffectProperty, + BlurBlurAmountEffectProperty, + TintColorEffectProperty + ]; + + public static readonly DependencyProperty ImageSourceProperty = + DependencyProperty.Register( + nameof(ImageSource), + typeof(ImageSource), + typeof(BlurImageControl), + new PropertyMetadata(null, OnImageChanged)); + + public static readonly DependencyProperty ImageStretchProperty = + DependencyProperty.Register( + nameof(ImageStretch), + typeof(Stretch), + typeof(BlurImageControl), + new PropertyMetadata(Stretch.UniformToFill, OnImageStretchChanged)); + + public static readonly DependencyProperty ImageOpacityProperty = + DependencyProperty.Register( + nameof(ImageOpacity), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(1.0, OnOpacityChanged)); + + public static readonly DependencyProperty ImageBrightnessProperty = + DependencyProperty.Register( + nameof(ImageBrightness), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(1.0, OnBrightnessChanged)); + + public static readonly DependencyProperty BlurAmountProperty = + DependencyProperty.Register( + nameof(BlurAmount), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(0.0, OnBlurAmountChanged)); + + public static readonly DependencyProperty TintColorProperty = + DependencyProperty.Register( + nameof(TintColor), + typeof(Color), + typeof(BlurImageControl), + new PropertyMetadata(Colors.Transparent, OnVisualPropertyChanged)); + + public static readonly DependencyProperty TintIntensityProperty = + DependencyProperty.Register( + nameof(TintIntensity), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(0.0, OnVisualPropertyChanged)); + + private Compositor? _compositor; + private SpriteVisual? _effectVisual; + private CompositionEffectBrush? _effectBrush; + private CompositionSurfaceBrush? _imageBrush; + + public BlurImageControl() + { + this.DefaultStyleKey = typeof(BlurImageControl); + this.Loaded += OnLoaded; + this.SizeChanged += OnSizeChanged; + } + + public ImageSource ImageSource + { + get => (ImageSource)GetValue(ImageSourceProperty); + set => SetValue(ImageSourceProperty, value); + } + + public Stretch ImageStretch + { + get => (Stretch)GetValue(ImageStretchProperty); + set => SetValue(ImageStretchProperty, value); + } + + public double ImageOpacity + { + get => (double)GetValue(ImageOpacityProperty); + set => SetValue(ImageOpacityProperty, value); + } + + public double ImageBrightness + { + get => (double)GetValue(ImageBrightnessProperty); + set => SetValue(ImageBrightnessProperty, Math.Clamp(value, -1, 1)); + } + + public double BlurAmount + { + get => (double)GetValue(BlurAmountProperty); + set => SetValue(BlurAmountProperty, value); + } + + public Color TintColor + { + get => (Color)GetValue(TintColorProperty); + set => SetValue(TintColorProperty, value); + } + + public double TintIntensity + { + get => (double)GetValue(TintIntensityProperty); + set => SetValue(TintIntensityProperty, value); + } + + private static void OnImageStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._imageBrush != null) + { + control._imageBrush.Stretch = ConvertStretch((Stretch)e.NewValue); + } + } + + private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._compositor != null) + { + control.UpdateEffect(); + } + } + + private static void OnOpacityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectVisual != null) + { + control._effectVisual.Opacity = (float)(double)e.NewValue; + } + } + + private static void OnBlurAmountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectBrush != null) + { + control.UpdateEffect(); + } + } + + private static void OnBrightnessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectBrush != null) + { + control.UpdateEffect(); + } + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + InitializeComposition(); + } + + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if (_effectVisual != null) + { + _effectVisual.Size = new Vector2( + (float)Math.Max(1, e.NewSize.Width), + (float)Math.Max(1, e.NewSize.Height)); + } + } + + private static void OnImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not BlurImageControl control) + { + return; + } + + control.EnsureEffect(force: true); + control.UpdateEffect(); + } + + private void InitializeComposition() + { + var visual = ElementCompositionPreview.GetElementVisual(this); + _compositor = visual.Compositor; + + _effectVisual = _compositor.CreateSpriteVisual(); + _effectVisual.Size = new Vector2( + (float)Math.Max(1, ActualWidth), + (float)Math.Max(1, ActualHeight)); + _effectVisual.Opacity = (float)ImageOpacity; + + ElementCompositionPreview.SetElementChildVisual(this, _effectVisual); + + UpdateEffect(); + } + + private void EnsureEffect(bool force = false) + { + if (_compositor is null) + { + return; + } + + if (_effectBrush is not null && !force) + { + return; + } + + var imageSource = new CompositionEffectSourceParameter(ImageSourceParameterName); + + // 1) Brightness via ArithmeticCompositeEffect + // We blend between the original image and either black or white, + // depending on whether we want to darken or brighten. BrightnessEffect isn't supported + // in the composition graph. + var brightnessEffect = new ArithmeticCompositeEffect + { + Name = BrightnessEffectName, + Source1 = imageSource, // original image + Source2 = new ColorSourceEffect + { + Name = BrightnessOverlayEffectName, + Color = Colors.Black, // we'll swap black/white via properties + }, + + MultiplyAmount = 0.0f, + Source1Amount = 1.0f, // original + Source2Amount = 0.0f, // overlay + Offset = 0.0f, + }; + + // 2) Blur + var blurEffect = new GaussianBlurEffect + { + Name = BlurEffectName, + BlurAmount = 0.0f, + BorderMode = EffectBorderMode.Hard, + Optimization = EffectOptimization.Balanced, + Source = brightnessEffect, + }; + + // 3) Tint (always in the chain; intensity via alpha) + var tintEffect = new BlendEffect + { + Name = TintBlendEffectName, + Background = blurEffect, + Foreground = new ColorSourceEffect + { + Name = TintEffectName, + Color = Colors.Transparent, + }, + Mode = BlendEffectMode.Multiply, + }; + + var effectFactory = _compositor.CreateEffectFactory(tintEffect, AnimatableProperties); + + _effectBrush?.Dispose(); + _effectBrush = effectFactory.CreateBrush(); + + // Set initial source + if (ImageSource is not null) + { + _imageBrush ??= _compositor.CreateSurfaceBrush(); + LoadImageAsync(ImageSource); + _effectBrush.SetSourceParameter(ImageSourceParameterName, _imageBrush); + } + else + { + _effectBrush.SetSourceParameter(ImageSourceParameterName, _compositor.CreateBackdropBrush()); + } + + if (_effectVisual is not null) + { + _effectVisual.Brush = _effectBrush; + } + } + + private void UpdateEffect() + { + if (_compositor is null) + { + return; + } + + EnsureEffect(); + if (_effectBrush is null) + { + return; + } + + var props = _effectBrush.Properties; + + // Brightness + var b = (float)Math.Clamp(ImageBrightness, -1.0, 1.0); + + float source1Amount; + float source2Amount; + Color overlayColor; + + if (b >= 0) + { + // Brighten: blend towards white + overlayColor = Colors.White; + source1Amount = 1.0f - b; // original image contribution + source2Amount = b; // white overlay contribution + } + else + { + // Darken: blend towards black + overlayColor = Colors.Black; + var t = -b; // 0..1 + source1Amount = 1.0f - t; // original image + source2Amount = t; // black overlay + } + + props.InsertScalar(BrightnessSource1AmountEffectProperty, source1Amount); + props.InsertScalar(BrightnessSource2AmountEffectProperty, source2Amount); + props.InsertColor(BrightnessOverlayColorEffectProperty, overlayColor); + + // Blur + props.InsertScalar(BlurBlurAmountEffectProperty, (float)BlurAmount); + + // Tint + var tintColor = TintColor; + var clampedIntensity = (float)Math.Clamp(TintIntensity, 0.0, 1.0); + + var adjustedColor = Color.FromArgb( + (byte)(clampedIntensity * 255), + tintColor.R, + tintColor.G, + tintColor.B); + + props.InsertColor(TintColorEffectProperty, adjustedColor); + } + + private void LoadImageAsync(ImageSource imageSource) + { + try + { + if (imageSource is Microsoft.UI.Xaml.Media.Imaging.BitmapImage bitmapImage) + { + _imageBrush ??= _compositor?.CreateSurfaceBrush(); + if (_imageBrush is null) + { + return; + } + + var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource); + loadedSurface.LoadCompleted += (_, _) => + { + if (_imageBrush is not null) + { + _imageBrush.Surface = loadedSurface; + _imageBrush.Stretch = ConvertStretch(ImageStretch); + _imageBrush.BitmapInterpolationMode = CompositionBitmapInterpolationMode.Linear; + } + }; + + _effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to load image for BlurImageControl: {0}", ex); + } + } + + private static CompositionStretch ConvertStretch(Stretch stretch) + { + return stretch switch + { + Stretch.None => CompositionStretch.None, + Stretch.Fill => CompositionStretch.Fill, + Stretch.Uniform => CompositionStretch.Uniform, + Stretch.UniformToFill => CompositionStretch.UniformToFill, + _ => CompositionStretch.UniformToFill, + }; + } + + private static string GetPropertyName(string effectName, string propertyName) => $"{effectName}.{propertyName}"; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml new file mode 100644 index 0000000000..105010bbd2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs new file mode 100644 index 0000000000..7267e894fa --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ColorPalette : UserControl +{ + public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPalette), null!)!; + + public static readonly DependencyProperty CustomPaletteColumnCountProperty = DependencyProperty.Register(nameof(CustomPaletteColumnCount), typeof(int), typeof(ColorPalette), new PropertyMetadata(10))!; + + public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPalette), new PropertyMetadata(null!))!; + + public event EventHandler? SelectedColorChanged; + + private Color? _selectedColor; + + public Color? SelectedColor + { + get => _selectedColor; + + set + { + if (_selectedColor != value) + { + _selectedColor = value; + if (value is not null) + { + SetValue(SelectedColorProperty, value); + } + else + { + ClearValue(SelectedColorProperty); + } + } + } + } + + public ObservableCollection PaletteColors + { + get => (ObservableCollection)GetValue(PaletteColorsProperty)!; + set => SetValue(PaletteColorsProperty, value); + } + + public int CustomPaletteColumnCount + { + get => (int)GetValue(CustomPaletteColumnCountProperty); + set => SetValue(CustomPaletteColumnCountProperty, value); + } + + public ColorPalette() + { + PaletteColors = []; + InitializeComponent(); + } + + private void ListViewBase_OnItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is Color color) + { + SelectedColor = color; + SelectedColorChanged?.Invoke(this, color); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml new file mode 100644 index 0000000000..92a556f7a7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs new file mode 100644 index 0000000000..ff82fffd4e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using ManagedCommon; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ColorPickerButton : UserControl +{ + public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection), typeof(ColorPickerButton), new PropertyMetadata(new ObservableCollection()))!; + + public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(Colors.Black))!; + + public static readonly DependencyProperty IsAlphaEnabledProperty = DependencyProperty.Register(nameof(IsAlphaEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(defaultValue: false))!; + + public static readonly DependencyProperty IsValueEditorEnabledProperty = DependencyProperty.Register(nameof(IsValueEditorEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!; + + public static readonly DependencyProperty HasSelectedColorProperty = DependencyProperty.Register(nameof(HasSelectedColor), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!; + + private Color _selectedColor; + + public Color SelectedColor + { + get + { + return _selectedColor; + } + + set + { + if (_selectedColor != value) + { + _selectedColor = value; + SetValue(SelectedColorProperty, value); + HasSelectedColor = true; + } + } + } + + public bool HasSelectedColor + { + get { return (bool)GetValue(HasSelectedColorProperty); } + set { SetValue(HasSelectedColorProperty, value); } + } + + public bool IsAlphaEnabled + { + get => (bool)GetValue(IsAlphaEnabledProperty); + set => SetValue(IsAlphaEnabledProperty, value); + } + + public bool IsValueEditorEnabled + { + get { return (bool)GetValue(IsValueEditorEnabledProperty); } + set { SetValue(IsValueEditorEnabledProperty, value); } + } + + public ObservableCollection PaletteColors + { + get { return (ObservableCollection)GetValue(PaletteColorsProperty); } + set { SetValue(PaletteColorsProperty, value); } + } + + public ColorPickerButton() + { + this.InitializeComponent(); + + IsEnabledChanged -= ColorPickerButton_IsEnabledChanged; + SetEnabledState(); + IsEnabledChanged += ColorPickerButton_IsEnabledChanged; + } + + private void ColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + SetEnabledState(); + } + + private void SetEnabledState() + { + if (this.IsEnabled) + { + ColorPreviewBorder.Opacity = 1; + } + else + { + ColorPreviewBorder.Opacity = 0.2; + } + } + + private void ColorPalette_OnSelectedColorChanged(object? sender, Color? e) + { + if (e.HasValue) + { + HasSelectedColor = true; + SelectedColor = e.Value; + } + } + + private void FlyoutBase_OnOpened(object? sender, object e) + { + if (sender is not Flyout flyout || (flyout.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter) + { + return; + } + + FlyoutRoot!.UpdateLayout(); + flyoutPresenter.UpdateLayout(); + + // Logger.LogInfo($"FlyoutBase_OnOpened: {flyoutPresenter}, {FlyoutRoot!.ActualWidth}"); + flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth; + flyoutPresenter.MinWidth = 660; + flyoutPresenter.Width = FlyoutRoot!.ActualWidth; + } + + private void FlyoutRoot_OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if ((ColorPickerFlyout!.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter) + { + return; + } + + FlyoutRoot!.UpdateLayout(); + flyoutPresenter.UpdateLayout(); + + flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth; + flyoutPresenter.MinWidth = 660; + flyoutPresenter.Width = FlyoutRoot!.ActualWidth; + } + + private Thickness ToDropDownPadding(bool hasColor) + { + return hasColor ? new Thickness(3, 3, 8, 3) : new Thickness(8, 4, 8, 4); + } + + private void ResetButton_Click(object sender, RoutedEventArgs e) + { + HasSelectedColor = false; + ColorPickerFlyout?.Hide(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml new file mode 100644 index 0000000000..a30d1fafdf --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs new file mode 100644 index 0000000000..96cd5d6aac --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class CommandPalettePreview : UserControl +{ + public static readonly DependencyProperty PreviewBackgroundOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color))); + + public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, PropertyChangedCallback)); + + public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0)); + + public static readonly DependencyProperty PreviewBackgroundImageFitProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageFit), typeof(BackgroundImageFit), typeof(CommandPalettePreview), new PropertyMetadata(default(BackgroundImageFit))); + + public static readonly DependencyProperty PreviewBackgroundImageBrightnessProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBrightness), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundImageBlurAmountProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBlurAmount), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundImageTintProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTint), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color))); + + public static readonly DependencyProperty PreviewBackgroundImageTintIntensityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTintIntensity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0)); + + public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed)); + + public BackgroundImageFit PreviewBackgroundImageFit + { + get { return (BackgroundImageFit)GetValue(PreviewBackgroundImageFitProperty); } + set { SetValue(PreviewBackgroundImageFitProperty, value); } + } + + public double PreviewBackgroundOpacity + { + get { return (double)GetValue(PreviewBackgroundOpacityProperty); } + set { SetValue(PreviewBackgroundOpacityProperty, value); } + } + + public Color PreviewBackgroundColor + { + get { return (Color)GetValue(PreviewBackgroundColorProperty); } + set { SetValue(PreviewBackgroundColorProperty, value); } + } + + public ImageSource PreviewBackgroundImageSource + { + get { return (ImageSource)GetValue(PreviewBackgroundImageSourceProperty); } + set { SetValue(PreviewBackgroundImageSourceProperty, value); } + } + + public int PreviewBackgroundImageOpacity + { + get { return (int)GetValue(PreviewBackgroundImageOpacityProperty); } + set { SetValue(PreviewBackgroundImageOpacityProperty, value); } + } + + public double PreviewBackgroundImageBrightness + { + get => (double)GetValue(PreviewBackgroundImageBrightnessProperty); + set => SetValue(PreviewBackgroundImageBrightnessProperty, value); + } + + public double PreviewBackgroundImageBlurAmount + { + get => (double)GetValue(PreviewBackgroundImageBlurAmountProperty); + set => SetValue(PreviewBackgroundImageBlurAmountProperty, value); + } + + public Color PreviewBackgroundImageTint + { + get => (Color)GetValue(PreviewBackgroundImageTintProperty); + set => SetValue(PreviewBackgroundImageTintProperty, value); + } + + public int PreviewBackgroundImageTintIntensity + { + get => (int)GetValue(PreviewBackgroundImageTintIntensityProperty); + set => SetValue(PreviewBackgroundImageTintIntensityProperty, value); + } + + public Visibility ShowBackgroundImage + { + get => (Visibility)GetValue(ShowBackgroundImageProperty); + set => SetValue(ShowBackgroundImageProperty, value); + } + + public CommandPalettePreview() + { + InitializeComponent(); + } + + private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not CommandPalettePreview preview) + { + return; + } + + preview.ShowBackgroundImage = e.NewValue is ImageSource ? Visibility.Visible : Visibility.Collapsed; + } + + private double ToOpacity(int value) => value / 100.0; + + private double ToTintIntensity(int value) => value / 100.0; + + private Stretch ToStretch(BackgroundImageFit fit) + { + return fit switch + { + BackgroundImageFit.Fill => Stretch.Fill, + BackgroundImageFit.UniformToFill => Stretch.UniformToFill, + _ => Stretch.None, + }; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs index b438fb0b61..57ad2c1d8c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml.Controls; @@ -19,7 +20,8 @@ public sealed partial class FallbackRanker : UserControl var settings = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; - viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler); + var themeService = App.Current.Services.GetService()!; + viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); } private void ListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml new file mode 100644 index 0000000000..58c4e890a6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs new file mode 100644 index 0000000000..828fa76c74 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.CmdPal.UI.Controls; + +[ContentProperty(Name = nameof(PreviewContent))] +public sealed partial class ScreenPreview : UserControl +{ + public static readonly DependencyProperty PreviewContentProperty = + DependencyProperty.Register(nameof(PreviewContent), typeof(object), typeof(ScreenPreview), new PropertyMetadata(null!))!; + + public object PreviewContent + { + get => GetValue(PreviewContentProperty)!; + set => SetValue(PreviewContentProperty, value); + } + + public ScreenPreview() + { + InitializeComponent(); + + var wallpaperHelper = new WallpaperHelper(); + WallpaperImage!.Source = wallpaperHelper.GetWallpaperImage()!; + ScreenBorder!.Background = new SolidColorBrush(wallpaperHelper.GetWallpaperColor()); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml index 80eb1a3ad6..d248c24f89 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml @@ -4,9 +4,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:cmdpalUi="using:Microsoft.CmdPal.UI" - xmlns:converters="using:CommunityToolkit.WinUI.Converters" - xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:h="using:Microsoft.CmdPal.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> @@ -22,6 +21,7 @@ MinHeight="32" VerticalAlignment="Stretch" VerticalContentAlignment="Stretch" + h:TextBoxCaretColor.SyncWithForeground="True" AutomationProperties.AutomationId="MainSearchBox" KeyDown="FilterBox_KeyDown" PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs new file mode 100644 index 0000000000..5f54682aaf --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Converters; + +/// +/// Gets a color, either black or white, depending on the brightness of the supplied color. +/// +public sealed partial class ContrastBrushConverter : IValueConverter +{ + /// + /// Gets or sets the alpha channel threshold below which a default color is used instead of black/white. + /// + public byte AlphaThreshold { get; set; } = 128; + + /// + public object Convert( + object value, + Type targetType, + object parameter, + string language) + { + Color comparisonColor; + Color? defaultColor = null; + + // Get the changing color to compare against + if (value is Color valueColor) + { + comparisonColor = valueColor; + } + else if (value is SolidColorBrush valueBrush) + { + comparisonColor = valueBrush.Color; + } + else + { + // Invalid color value provided + return DependencyProperty.UnsetValue; + } + + // Get the default color when transparency is high + if (parameter is Color parameterColor) + { + defaultColor = parameterColor; + } + else if (parameter is SolidColorBrush parameterBrush) + { + defaultColor = parameterBrush.Color; + } + + if (comparisonColor.A < AlphaThreshold && + defaultColor.HasValue) + { + // If the transparency is less than 50 %, just use the default brush + // This can commonly be something like the TextControlForeground brush + return new SolidColorBrush(defaultColor.Value); + } + else + { + // Chose a white/black brush based on contrast to the base color + return UseLightContrastColor(comparisonColor) + ? new SolidColorBrush(Colors.White) + : new SolidColorBrush(Colors.Black); + } + } + + /// + public object ConvertBack( + object value, + Type targetType, + object parameter, + string language) + { + return DependencyProperty.UnsetValue; + } + + /// + /// Determines whether a light or dark contrast color should be used with the given displayed color. + /// + /// + /// This code is using the WinUI algorithm. + /// + private bool UseLightContrastColor(Color displayedColor) + { + // The selection ellipse should be light if and only if the chosen color + // contrasts more with black than it does with white. + // To find how much something contrasts with white, we use the equation + // for relative luminance, which is given by + // + // L = 0.2126 * Rg + 0.7152 * Gg + 0.0722 * Bg + // + // where Xg = { X/3294 if X <= 10, (R/269 + 0.0513)^2.4 otherwise } + // + // If L is closer to 1, then the color is closer to white; if it is closer to 0, + // then the color is closer to black. This is based on the fact that the human + // eye perceives green to be much brighter than red, which in turn is perceived to be + // brighter than blue. + // + // If the third dimension is value, then we won't be updating the spectrum's displayed colors, + // so in that case we should use a value of 1 when considering the backdrop + // for the selection ellipse. + var rg = displayedColor.R <= 10 + ? displayedColor.R / 3294.0 + : Math.Pow((displayedColor.R / 269.0) + 0.0513, 2.4); + var gg = displayedColor.G <= 10 + ? displayedColor.G / 3294.0 + : Math.Pow((displayedColor.G / 269.0) + 0.0513, 2.4); + var bg = displayedColor.B <= 10 + ? displayedColor.B / 3294.0 + : Math.Pow((displayedColor.B / 269.0) + 0.0513, 2.4); + + return (0.2126 * rg) + (0.7152 * gg) + (0.0722 * bg) <= 0.5; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs new file mode 100644 index 0000000000..033c03a0b9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.CmdPal.UI; + +public partial class DetailsSizeToGridLengthConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is ContentSize size) + { + // This converter calculates the Star width for the LIST. + // + // The input 'size' (ContentSize) represents the TARGET WIDTH desired for the DETAILS PANEL. + // + // To ensure the Details Panel achieves its target size (e.g. ContentSize.Large), + // we must shrink the List and let the Details fill the available space. + // (e.g., A larger target size for Details results in a smaller Star value for the List). + var starValue = size switch + { + ContentSize.Small => 3.0, + ContentSize.Medium => 2.0, + ContentSize.Large => 1.0, + _ => 3.0, + }; + + return new GridLength(starValue, GridUnitType.Star); + } + + return new GridLength(3.0, GridUnitType.Star); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs index 012e8dc789..f00c230da5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs @@ -10,6 +10,8 @@ internal static class BindTransformers { public static bool Negate(bool value) => !value; + public static Visibility NegateVisibility(Visibility value) => value == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; + public static Visibility EmptyToCollapsed(string? input) => string.IsNullOrEmpty(input) ? Visibility.Collapsed : Visibility.Visible; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs new file mode 100644 index 0000000000..2492f7f7c9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Helpers; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// Extension methods for . +/// +internal static class ColorExtensions +{ + /// Input color. + public static double CalculateBrightness(this Color color) + { + return color.ToHsv().V; + } + + /// + /// Allows to change the brightness by a factor based on the HSV color space. + /// + /// Input color. + /// The brightness adjustment factor, ranging from -1 to 1. + /// Updated color. + public static Color UpdateBrightness(this Color color, double brightnessFactor) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1); + + var hsvColor = color.ToHsv(); + return ColorHelper.FromHsv(hsvColor.H, hsvColor.S, Math.Clamp(hsvColor.V + brightnessFactor, 0, 1), hsvColor.A); + } + + /// + /// Updates the color by adjusting brightness, saturation, and luminance factors. + /// + /// Input color. + /// The brightness adjustment factor, ranging from -1 to 1. + /// The saturation adjustment factor, ranging from -1 to 1. Defaults to 0. + /// The luminance adjustment factor, ranging from -1 to 1. Defaults to 0. + /// Updated color. + public static Color Update(this Color color, double brightnessFactor, double saturationFactor = 0, double luminanceFactor = 0) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(saturationFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(saturationFactor, -1); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(luminanceFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(luminanceFactor, -1); + + var hsv = color.ToHsv(); + + var rgb = ColorHelper.FromHsv( + hsv.H, + Clamp01(hsv.S + saturationFactor), + Clamp01(hsv.V + brightnessFactor)); + + if (luminanceFactor == 0) + { + return rgb; + } + + var hsl = rgb.ToHsl(); + var lightness = Clamp01(hsl.L + luminanceFactor); + return ColorHelper.FromHsl(hsl.H, hsl.S, lightness); + } + + /// + /// Linearly interpolates between two colors in HSV space. + /// Hue is blended along the shortest arc on the color wheel (wrap-aware). + /// Saturation, Value, and Alpha are blended linearly. + /// + /// Start color. + /// End color. + /// Interpolation factor in [0,1]. + /// Interpolated color. + public static Color LerpHsv(this Color a, Color b, double t) + { + t = Clamp01(t); + + // Convert to HSV + var hslA = a.ToHsv(); + var hslB = b.ToHsv(); + + var h1 = hslA.H; + var h2 = hslB.H; + + // Handle near-gray hues (undefined hue) by inheriting the other's hue + const double satEps = 1e-4f; + if (hslA.S < satEps && hslB.S >= satEps) + { + h1 = h2; + } + else if (hslB.S < satEps && hslA.S >= satEps) + { + h2 = h1; + } + + return ColorHelper.FromHsv( + hue: LerpHueDegrees(h1, h2, t), + saturation: Lerp(hslA.S, hslB.S, t), + value: Lerp(hslA.V, hslB.V, t), + alpha: (byte)Math.Round(Lerp(hslA.A, hslB.A, t))); + } + + private static double LerpHueDegrees(double a, double b, double t) + { + a = Mod360(a); + b = Mod360(b); + var delta = ((b - a + 540f) % 360f) - 180f; + return Mod360(a + (delta * t)); + } + + private static double Mod360(double angle) + { + angle %= 360f; + if (angle < 0f) + { + angle += 360f; + } + + return angle; + } + + private static double Lerp(double a, double b, double t) => a + ((b - a) * t); + + private static double Clamp01(double x) => Math.Clamp(x, 0, 1); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs new file mode 100644 index 0000000000..f5103b9efc --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using CommunityToolkit.WinUI; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Rectangle = Microsoft.UI.Xaml.Shapes.Rectangle; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// Attached property to color internal caret/overlay rectangles inside a TextBox +/// so they follow the TextBox's actual Foreground brush. +/// +public static class TextBoxCaretColor +{ + public static readonly DependencyProperty SyncWithForegroundProperty = + DependencyProperty.RegisterAttached("SyncWithForeground", typeof(bool), typeof(TextBoxCaretColor), new PropertyMetadata(false, OnSyncCaretRectanglesChanged))!; + + private static readonly ConditionalWeakTable States = []; + + public static void SetSyncWithForeground(DependencyObject obj, bool value) + { + obj.SetValue(SyncWithForegroundProperty, value); + } + + public static bool GetSyncWithForeground(DependencyObject obj) + { + return (bool)obj.GetValue(SyncWithForegroundProperty); + } + + private static void OnSyncCaretRectanglesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not TextBox tb) + { + return; + } + + if ((bool)e.NewValue) + { + Attach(tb); + } + else + { + Detach(tb); + } + } + + private static void Attach(TextBox tb) + { + if (States.TryGetValue(tb, out var st) && st.IsHooked) + { + return; + } + + st ??= new State(); + st.IsHooked = true; + States.Remove(tb); + States.Add(tb, st); + + tb.Loaded += TbOnLoaded; + tb.Unloaded += TbOnUnloaded; + tb.GotFocus += TbOnGotFocus; + + st.ForegroundToken = tb.RegisterPropertyChangedCallback(Control.ForegroundProperty!, (_, _) => Apply(tb)); + + if (tb.IsLoaded) + { + Apply(tb); + } + } + + private static void Detach(TextBox tb) + { + if (!States.TryGetValue(tb, out var st)) + { + return; + } + + tb.Loaded -= TbOnLoaded; + tb.Unloaded -= TbOnUnloaded; + tb.GotFocus -= TbOnGotFocus; + + if (st.ForegroundToken != 0) + { + tb.UnregisterPropertyChangedCallback(Control.ForegroundProperty!, st.ForegroundToken); + st.ForegroundToken = 0; + } + + st.IsHooked = false; + } + + private static void TbOnLoaded(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Apply(tb); + } + } + + private static void TbOnUnloaded(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Detach(tb); + } + } + + private static void TbOnGotFocus(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Apply(tb); + } + } + + private static void Apply(TextBox tb) + { + try + { + ApplyCore(tb); + } + catch (COMException) + { + // ignore + } + } + + private static void ApplyCore(TextBox tb) + { + // Ensure template is realized + tb.ApplyTemplate(); + + // Find the internal ScrollContentPresenter within the TextBox template + var scp = tb.FindDescendant(s => s.Name == "ScrollContentPresenter"); + if (scp is null) + { + return; + } + + var brush = tb.Foreground; // use the actual current foreground brush + if (brush == null) + { + brush = new SolidColorBrush(Colors.Black); + } + + foreach (var rect in scp.FindDescendants().OfType()) + { + try + { + rect.Fill = brush; + rect.CompositeMode = ElementCompositeMode.SourceOver; + rect.Opacity = 0.9; + } + catch + { + // best-effort; some rectangles might be template-owned + } + } + } + + private sealed class State + { + public long ForegroundToken { get; set; } + + public bool IsHooked { get; set; } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs new file mode 100644 index 0000000000..9772d33b1d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using ManagedCommon; +using ManagedCsWin32; +using Microsoft.UI; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// Lightweight helper to access wallpaper information. +/// +internal sealed partial class WallpaperHelper +{ + private readonly IDesktopWallpaper? _desktopWallpaper; + + public WallpaperHelper() + { + try + { + var desktopWallpaper = ComHelper.CreateComInstance( + ref Unsafe.AsRef(in CLSID.DesktopWallpaper), + CLSCTX.ALL); + + _desktopWallpaper = desktopWallpaper; + } + catch (Exception ex) + { + // If COM initialization fails, keep helper usable with safe fallbacks + Logger.LogError("Failed to initialize DesktopWallpaper COM interface", ex); + _desktopWallpaper = null; + } + } + + private string? GetWallpaperPathForFirstMonitor() + { + try + { + if (_desktopWallpaper is null) + { + return null; + } + + _desktopWallpaper.GetMonitorDevicePathCount(out var monitorCount); + + for (uint i = 0; monitorCount != 0 && i < monitorCount; i++) + { + _desktopWallpaper.GetMonitorDevicePathAt(i, out var monitorId); + if (string.IsNullOrEmpty(monitorId)) + { + continue; + } + + _desktopWallpaper.GetWallpaper(monitorId, out var wallpaperPath); + + if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath)) + { + return wallpaperPath; + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to query wallpaper path", ex); + } + + return null; + } + + /// + /// Gets the wallpaper background color. + /// + /// The wallpaper background color, or black if it cannot be determined. + public Color GetWallpaperColor() + { + try + { + if (_desktopWallpaper is null) + { + return Colors.Black; + } + + _desktopWallpaper.GetBackgroundColor(out var colorref); + var r = (byte)(colorref.Value & 0x000000FF); + var g = (byte)((colorref.Value & 0x0000FF00) >> 8); + var b = (byte)((colorref.Value & 0x00FF0000) >> 16); + return Color.FromArgb(255, r, g, b); + } + catch (Exception ex) + { + Logger.LogError("Failed to load wallpaper color", ex); + return Colors.Black; + } + } + + /// + /// Gets the wallpaper image for the primary monitor. + /// + /// The wallpaper image, or null if it cannot be determined. + public BitmapImage? GetWallpaperImage() + { + try + { + var path = GetWallpaperPathForFirstMonitor(); + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var image = new BitmapImage(); + using var stream = File.OpenRead(path); + var randomAccessStream = stream.AsRandomAccessStream(); + if (randomAccessStream == null) + { + Logger.LogError("Failed to convert file stream to RandomAccessStream for wallpaper image."); + return null; + } + + image.SetSource(randomAccessStream); + return image; + } + catch (Exception ex) + { + Logger.LogError("Failed to load wallpaper image", ex); + return null; + } + } + + // blittable type for COM interop + [StructLayout(LayoutKind.Sequential)] + internal readonly partial struct COLORREF + { + internal readonly uint Value; + } + + // blittable type for COM interop + [StructLayout(LayoutKind.Sequential)] + internal readonly partial struct RECT + { + internal readonly int Left; + internal readonly int Top; + internal readonly int Right; + internal readonly int Bottom; + } + + // COM interface for IDesktopWallpaper, GeneratedComInterface to be AOT compatible + [GeneratedComInterface] + [Guid("B92B56A9-8B55-4E14-9A89-0199BBB6F93B")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal partial interface IDesktopWallpaper + { + void SetWallpaper( + [MarshalAs(UnmanagedType.LPWStr)] string? monitorId, + [MarshalAs(UnmanagedType.LPWStr)] string wallpaper); + + void GetWallpaper( + [MarshalAs(UnmanagedType.LPWStr)] string? monitorId, + [MarshalAs(UnmanagedType.LPWStr)] out string wallpaper); + + void GetMonitorDevicePathAt(uint monitorIndex, [MarshalAs(UnmanagedType.LPWStr)] out string monitorId); + + void GetMonitorDevicePathCount(out uint count); + + void GetMonitorRECT([MarshalAs(UnmanagedType.LPWStr)] string? monitorId, out RECT rect); + + void SetBackgroundColor(COLORREF color); + + void GetBackgroundColor(out COLORREF color); + + // Other methods omitted for brevity + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml index c0c0ab811f..32329e17a0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml @@ -2,6 +2,7 @@ x:Class="Microsoft.CmdPal.UI.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pages="using:Microsoft.CmdPal.UI.Pages" @@ -15,6 +16,21 @@ Closed="MainWindow_Closed" mc:Ignorable="d"> + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 1655626714..d9acdb48d9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -15,8 +15,10 @@ using Microsoft.CmdPal.UI.Controls; using Microsoft.CmdPal.UI.Events; using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.Messages; +using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; using Microsoft.UI; @@ -66,7 +68,10 @@ public sealed partial class MainWindow : WindowEx, private readonly KeyboardListener _keyboardListener; private readonly LocalKeyboardListener _localKeyboardListener; private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new(); + private readonly IThemeService _themeService; + private readonly WindowThemeSynchronizer _windowThemeSynchronizer; private bool _ignoreHotKeyWhenFullScreen = true; + private bool _themeServiceInitialized; private DesktopAcrylicController? _acrylicController; private SystemBackdropConfiguration? _configurationSource; @@ -74,13 +79,21 @@ public sealed partial class MainWindow : WindowEx, private WindowPosition _currentWindowPosition = new(); + private MainWindowViewModel ViewModel { get; } + public MainWindow() { InitializeComponent(); + ViewModel = App.Current.Services.GetService()!; + _autoGoHomeTimer = new DispatcherTimer(); _autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick; + _themeService = App.Current.Services.GetRequiredService(); + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + _windowThemeSynchronizer = new WindowThemeSynchronizer(_themeService, this); + _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); unsafe @@ -88,6 +101,8 @@ public sealed partial class MainWindow : WindowEx, CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value); } + SetAcrylic(); + _hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached); _keyboardListener = new KeyboardListener(); @@ -100,8 +115,6 @@ public sealed partial class MainWindow : WindowEx, RestoreWindowPosition(); UpdateWindowPositionInMemory(); - SetAcrylic(); - WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -156,6 +169,11 @@ public sealed partial class MainWindow : WindowEx, WeakReferenceMessenger.Default.Send(new GoHomeMessage(WithAnimation: false, FocusSearch: false)); } + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + UpdateAcrylic(); + } + private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) { if (e.Key == VirtualKey.GoBack) @@ -247,8 +265,6 @@ public sealed partial class MainWindow : WindowEx, _autoGoHomeTimer.Interval = _autoGoHomeInterval; } - // We want to use DesktopAcrylicKind.Thin and custom colors as this is the default material - // other Shell surfaces are using, this cannot be set in XAML however. private void SetAcrylic() { if (DesktopAcrylicController.IsSupported()) @@ -265,41 +281,32 @@ public sealed partial class MainWindow : WindowEx, private void UpdateAcrylic() { - if (_acrylicController != null) + try { - _acrylicController.RemoveAllSystemBackdropTargets(); - _acrylicController.Dispose(); - } - - _acrylicController = GetAcrylicConfig(Content); - - // Enable the system backdrop. - // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call. - _acrylicController.AddSystemBackdropTarget(this.As()); - _acrylicController.SetSystemBackdropConfiguration(_configurationSource); - } - - private static DesktopAcrylicController GetAcrylicConfig(UIElement content) - { - var feContent = content as FrameworkElement; - - return feContent?.ActualTheme == ElementTheme.Light - ? new DesktopAcrylicController() + if (_acrylicController != null) { - Kind = DesktopAcrylicKind.Thin, - TintColor = Color.FromArgb(255, 243, 243, 243), - LuminosityOpacity = 0.90f, - TintOpacity = 0.0f, - FallbackColor = Color.FromArgb(255, 238, 238, 238), + _acrylicController.RemoveAllSystemBackdropTargets(); + _acrylicController.Dispose(); } - : new DesktopAcrylicController() + + var backdrop = _themeService.Current.BackdropParameters; + _acrylicController = new DesktopAcrylicController { - Kind = DesktopAcrylicKind.Thin, - TintColor = Color.FromArgb(255, 32, 32, 32), - LuminosityOpacity = 0.96f, - TintOpacity = 0.5f, - FallbackColor = Color.FromArgb(255, 28, 28, 28), + TintColor = backdrop.TintColor, + TintOpacity = backdrop.TintOpacity, + FallbackColor = backdrop.FallbackColor, + LuminosityOpacity = backdrop.LuminosityOpacity, }; + + // Enable the system backdrop. + // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call. + _acrylicController.AddSystemBackdropTarget(this.As()); + _acrylicController.SetSystemBackdropConfiguration(_configurationSource); + } + catch (Exception ex) + { + Logger.LogError("Failed to update backdrop", ex); + } } private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target) @@ -711,6 +718,19 @@ public sealed partial class MainWindow : WindowEx, internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args) { + if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated) + { + try + { + _themeService.Initialize(); + _themeServiceInitialized = true; + } + catch (Exception ex) + { + Logger.LogError("Failed to initialize ThemeService", ex); + } + } + if (args.WindowActivationState == WindowActivationState.Deactivated) { // Save the current window position before hiding the window @@ -1004,6 +1024,7 @@ public sealed partial class MainWindow : WindowEx, public void Dispose() { _localKeyboardListener.Dispose(); + _windowThemeSynchronizer.Dispose(); DisposeAcrylic(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj index 8397ffc767..54961a5828 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj @@ -68,8 +68,11 @@ + + + @@ -78,10 +81,12 @@ + + @@ -93,6 +98,7 @@ + @@ -207,6 +213,39 @@ + + + MSBuild:Compile + + + + + + MSBuild:Compile + + + + + + MSBuild:Compile + + + + + + Designer + + + MSBuild:Compile + + + + + + MSBuild:Compile + + + MSBuild:Compile diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index fe1a29dd97..ba9b8e736c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -11,7 +11,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:h="using:Microsoft.CmdPal.UI.Helpers" xmlns:help="using:Microsoft.CmdPal.UI.Helpers" - xmlns:labToolkit="using:CommunityToolkit.Labs.WinUI.MarkdownTextBlock" xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" @@ -27,6 +26,7 @@ EmptyValue="Collapsed" NotEmptyValue="Visible" /> + - + @@ -190,7 +190,7 @@ Padding="0,12,0,12" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderBrush="{ThemeResource CmdPal.TopBarBorderBrush}" BorderThickness="0,0,0,1"> @@ -371,7 +371,7 @@ - + @@ -390,7 +390,7 @@ HorizontalAlignment="Stretch" ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderBrush="{ThemeResource CmdPal.DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="{StaticResource ControlCornerRadius}" Visibility="Collapsed"> @@ -518,7 +518,7 @@ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs new file mode 100644 index 0000000000..fe6c8e48e0 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Helpers; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Provides theme appropriate for colorful (accented) appearance. +/// +internal sealed class ColorfulThemeProvider : IThemeProvider +{ + // Fluent dark: #202020 + private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32); + + // Fluent light: #F3F3F3 + private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243); + + private readonly UISettings _uiSettings; + + public string ThemeKey => "colorful"; + + public string ResourcePath => "ms-appx:///Styles/Theme.Colorful.xaml"; + + public ColorfulThemeProvider(UISettings uiSettings) + { + ArgumentNullException.ThrowIfNull(uiSettings); + _uiSettings = uiSettings; + } + + public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context) + { + var isLight = context.Theme == ElementTheme.Light || + (context.Theme == ElementTheme.Default && + _uiSettings.GetColorValue(UIColorType.Background).R > 128); + + var baseColor = isLight ? LightBaseColor : DarkBaseColor; + + // Windows is warping the hue of accent colors and running it through some curves to produce their accent shades. + // This will attempt to mimic that behavior. + var accentShades = AccentShades.Compute(context.Tint.LerpHsv(WindowsAccentHueWarpTransform.Transform(context.Tint), 0.5f)); + var blended = isLight ? accentShades.Light3 : accentShades.Dark2; + var colorIntensityUser = (context.ColorIntensity ?? 100) / 100f; + + // For light theme, we want to reduce intensity a bit, and also we need to keep the color fairly light, + // to avoid issues with text box caret. + var colorIntensity = isLight ? 0.6f * colorIntensityUser : colorIntensityUser; + var effectiveBgColor = ColorBlender.Blend(baseColor, blended, colorIntensity); + + return new AcrylicBackdropParameters(effectiveBgColor, effectiveBgColor, 0.8f, 0.8f); + } + + private static class ColorBlender + { + /// + /// Blends a semitransparent tint color over an opaque base color using alpha compositing. + /// + /// The opaque base color (background) + /// The semitransparent tint color (foreground) + /// The intensity of the tint (0.0 - 1.0) + /// The resulting blended color + public static Color Blend(Color baseColor, Color tintColor, float intensity) + { + // Normalize alpha to 0.0 - 1.0 range + intensity = Math.Clamp(intensity, 0f, 1f); + + // Alpha compositing formula: result = tint * alpha + base * (1 - alpha) + var r = (byte)((tintColor.R * intensity) + (baseColor.R * (1 - intensity))); + var g = (byte)((tintColor.G * intensity) + (baseColor.G * (1 - intensity))); + var b = (byte)((tintColor.B * intensity) + (baseColor.B * (1 - intensity))); + + // Result is fully opaque since base is opaque + return Color.FromArgb(255, r, g, b); + } + } + + private static class WindowsAccentHueWarpTransform + { + private static readonly (double HIn, double HOut)[] HueMap = + [ + (0, 0), + (10, 1), + (20, 6), + (30, 10), + (40, 14), + (50, 19), + (60, 36), + (70, 94), + (80, 112), + (90, 120), + (100, 120), + (110, 120), + (120, 120), + (130, 120), + (140, 120), + (150, 125), + (160, 135), + (170, 142), + (180, 178), + (190, 205), + (200, 220), + (210, 229), + (220, 237), + (230, 241), + (240, 243), + (250, 244), + (260, 245), + (270, 248), + (280, 252), + (290, 276), + (300, 293), + (310, 313), + (320, 330), + (330, 349), + (340, 353), + (350, 357) + ]; + + public static Color Transform(Color input, Options? opt = null) + { + opt ??= new Options(); + var hsv = input.ToHsv(); + return ColorHelper.FromHsv( + RemapHueLut(hsv.H), + Clamp01(Math.Pow(hsv.S, opt.SaturationGamma) * opt.SaturationGain), + Clamp01((opt.ValueScaleA * hsv.V) + opt.ValueBiasB), + input.A); + } + + // Hue LUT remap (piecewise-linear with cyclic wrap) + private static double RemapHueLut(double hDeg) + { + // Normalize to [0,360) + hDeg = Mod(hDeg, 360.0); + + // Handle wrap-around case: hDeg is between last entry (350°) and 360° + var last = HueMap[^1]; + var first = HueMap[0]; + if (hDeg >= last.HIn) + { + // Interpolate between last entry and first entry (wrapped by 360°) + var t = (hDeg - last.HIn) / (first.HIn + 360.0 - last.HIn + 1e-12); + var ho = Lerp(last.HOut, first.HOut + 360.0, t); + return Mod(ho, 360.0); + } + + // Find segment [i, i+1] where HueMap[i].HIn <= hDeg < HueMap[i+1].HIn + for (var i = 0; i < HueMap.Length - 1; i++) + { + var a = HueMap[i]; + var b = HueMap[i + 1]; + + if (hDeg >= a.HIn && hDeg < b.HIn) + { + var t = (hDeg - a.HIn) / (b.HIn - a.HIn + 1e-12); + return Lerp(a.HOut, b.HOut, t); + } + } + + // Fallback (shouldn't happen) + return hDeg; + } + + private static double Lerp(double a, double b, double t) => a + ((b - a) * t); + + private static double Mod(double x, double m) => ((x % m) + m) % m; + + private static double Clamp01(double x) => x < 0 ? 0 : (x > 1 ? 1 : x); + + public sealed class Options + { + // Saturation boost (1.0 = no change). Typical: 1.3–1.8 + public double SaturationGain { get; init; } = 1.0; + + // Optional saturation gamma (1.0 = linear). <1.0 raises low S a bit; >1.0 preserves low S. + public double SaturationGamma { get; init; } = 1.0; + + // Value (V) remap: V' = a*V + b (tone curve; clamp applied) + // Example that lifts blacks & compresses whites slightly: a=0.50, b=0.08 + public double ValueScaleA { get; init; } = 0.6; + + public double ValueBiasB { get; init; } = 0.01; + } + } + + private static class AccentShades + { + public static (Color Light3, Color Light2, Color Light1, Color Dark1, Color Dark2, Color Dark3) Compute(Color accent) + { + var light1 = accent.Update(brightnessFactor: 0.15, saturationFactor: -0.12); + var light2 = accent.Update(brightnessFactor: 0.30, saturationFactor: -0.24); + var light3 = accent.Update(brightnessFactor: 0.45, saturationFactor: -0.36); + + var dark1 = accent.UpdateBrightness(brightnessFactor: -0.05f); + var dark2 = accent.UpdateBrightness(brightnessFactor: -0.01f); + var dark3 = accent.UpdateBrightness(brightnessFactor: -0.015f); + + return (light3, light2, light1, dark1, dark2, dark3); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs new file mode 100644 index 0000000000..a9411c3656 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Provides theme identification, resource path resolution, and creation of acrylic +/// backdrop parameters based on the current . +/// +/// +/// Implementations should expose a stable and a valid XAML resource +/// dictionary path via . The +/// method computes +/// using the supplied theme context. +/// +internal interface IThemeProvider +{ + /// + /// Gets the unique key identifying this theme provider. + /// + string ThemeKey { get; } + + /// + /// Gets the resource dictionary path for this theme. + /// + string ResourcePath { get; } + + /// + /// Creates acrylic backdrop parameters based on the provided theme context. + /// + /// The current theme context, including theme, tint, and optional background details. + /// The computed for the backdrop. + AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs new file mode 100644 index 0000000000..8177326259 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Dedicated ResourceDictionary for dynamic overrides that win over base theme resources. Since +/// we can't use a key or name to identify the dictionary in Application resources, we use a dedicated type. +/// +internal sealed partial class MutableOverridesDictionary : ResourceDictionary; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs new file mode 100644 index 0000000000..c393894346 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Provides theme resources and acrylic backdrop parameters matching the default Command Palette theme. +/// +internal sealed class NormalThemeProvider : IThemeProvider +{ + private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32); + private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243); + private readonly UISettings _uiSettings; + + public NormalThemeProvider(UISettings uiSettings) + { + ArgumentNullException.ThrowIfNull(uiSettings); + _uiSettings = uiSettings; + } + + public string ThemeKey => "normal"; + + public string ResourcePath => "ms-appx:///Styles/Theme.Normal.xaml"; + + public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context) + { + var isLight = context.Theme == ElementTheme.Light || + (context.Theme == ElementTheme.Default && + _uiSettings.GetColorValue(UIColorType.Background).R > 128); + + return new AcrylicBackdropParameters( + TintColor: isLight ? LightBaseColor : DarkBaseColor, + FallbackColor: isLight ? LightBaseColor : DarkBaseColor, + TintOpacity: 0.5f, + LuminosityOpacity: isLight ? 0.9f : 0.96f); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs new file mode 100644 index 0000000000..6d0a6f01dd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Simple theme switcher that swaps application ResourceDictionaries at runtime. +/// Can also operate in event-only mode for consumers to apply resources themselves. +/// Exposes a dedicated override dictionary that stays merged and is cleared on theme changes. +/// +internal sealed partial class ResourceSwapper +{ + private readonly Lock _resourceSwapGate = new(); + private readonly Dictionary _themeUris = new(StringComparer.OrdinalIgnoreCase); + private ResourceDictionary? _activeDictionary; + private string? _currentThemeName; + private Uri? _currentThemeUri; + + private ResourceDictionary? _overrideDictionary; + + /// + /// Raised after a theme has been activated. + /// + public event EventHandler? ResourcesSwapped; + + /// + /// Gets or sets a value indicating whether when true (default) ResourceSwapper updates Application.Current.Resources. When false, it only raises ResourcesSwapped. + /// + public bool ApplyToAppResources { get; set; } = true; + + /// + /// Gets name of the currently selected theme (if any). + /// + public string? CurrentThemeName + { + get + { + lock (_resourceSwapGate) + { + return _currentThemeName; + } + } + } + + /// + /// Initializes ResourceSwapper by checking Application resources for an already merged theme dictionary. + /// + public void Initialize() + { + // Find merged dictionary in Application resources that matches a registered theme by URI + // This allows ResourceSwapper to pick up an initial theme set in XAML + var app = Application.Current; + var resourcesMergedDictionaries = app?.Resources?.MergedDictionaries; + if (resourcesMergedDictionaries == null) + { + return; + } + + foreach (var dict in resourcesMergedDictionaries) + { + var uri = dict.Source; + if (uri is null) + { + continue; + } + + var name = GetNameForUri(uri); + if (name is null) + { + continue; + } + + lock (_resourceSwapGate) + { + _currentThemeName = name; + _currentThemeUri = uri; + _activeDictionary = dict; + } + + break; + } + } + + /// + /// Gets uri of the currently selected theme dictionary (if any). + /// + public Uri? CurrentThemeUri + { + get + { + lock (_resourceSwapGate) + { + return _currentThemeUri; + } + } + } + + public static ResourceDictionary GetOverrideDictionary(bool clear = false) + { + var app = Application.Current ?? throw new InvalidOperationException("App is null"); + + if (app.Resources == null) + { + throw new InvalidOperationException("Application.Resources is null"); + } + + // (Re)locate the slot – Hot Reload may rebuild Application.Resources. + var slot = app.Resources!.MergedDictionaries! + .OfType() + .FirstOrDefault(); + + if (slot is null) + { + // If the slot vanished (Hot Reload), create it again at the end so it wins precedence. + slot = new MutableOverridesDictionary(); + app.Resources.MergedDictionaries!.Add(slot); + } + + // Ensure the slot has exactly one child RD we can swap safely. + if (slot.MergedDictionaries!.Count == 0) + { + slot.MergedDictionaries.Add(new ResourceDictionary()); + } + else if (slot.MergedDictionaries.Count > 1) + { + // Normalize to a single child to keep semantics predictable. + var keep = slot.MergedDictionaries[^1]; + slot.MergedDictionaries.Clear(); + slot.MergedDictionaries.Add(keep); + } + + if (clear) + { + // Swap the child dictionary instead of Clear() to avoid reentrancy issues. + var fresh = new ResourceDictionary(); + slot.MergedDictionaries[0] = fresh; + return fresh; + } + + return slot.MergedDictionaries[0]!; + } + + /// + /// Registers a theme name mapped to a XAML ResourceDictionary URI (e.g. ms-appx:///Themes/Red.xaml) + /// + public void RegisterTheme(string name, Uri dictionaryUri) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Theme name is required", nameof(name)); + } + + lock (_resourceSwapGate) + { + _themeUris[name] = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri)); + } + } + + /// + /// Registers a theme with a string URI. + /// + public void RegisterTheme(string name, string dictionaryUri) + { + ArgumentNullException.ThrowIfNull(dictionaryUri); + RegisterTheme(name, new Uri(dictionaryUri)); + } + + /// + /// Removes a previously registered theme. + /// + public bool UnregisterTheme(string name) + { + lock (_resourceSwapGate) + { + return _themeUris.Remove(name); + } + } + + /// + /// Gets the names of all registered themes. + /// + public IEnumerable GetRegisteredThemes() + { + lock (_resourceSwapGate) + { + // return a copy to avoid external mutation + return new List(_themeUris.Keys); + } + } + + /// + /// Activates a theme by name. The dictionary for the given name must be registered first. + /// + public void ActivateTheme(string theme) + { + if (string.IsNullOrWhiteSpace(theme)) + { + throw new ArgumentException("Theme name is required", nameof(theme)); + } + + Uri uri; + lock (_resourceSwapGate) + { + if (!_themeUris.TryGetValue(theme, out uri!)) + { + throw new KeyNotFoundException($"Theme '{theme}' is not registered."); + } + } + + ActivateThemeInternal(theme, uri); + } + + /// + /// Tries to activate a theme by name without throwing. + /// + public bool TryActivateTheme(string theme) + { + if (string.IsNullOrWhiteSpace(theme)) + { + return false; + } + + Uri uri; + lock (_resourceSwapGate) + { + if (!_themeUris.TryGetValue(theme, out uri!)) + { + return false; + } + } + + ActivateThemeInternal(theme, uri); + return true; + } + + /// + /// Activates a theme by URI to a ResourceDictionary. + /// + public void ActivateTheme(Uri dictionaryUri) + { + ArgumentNullException.ThrowIfNull(dictionaryUri); + + ActivateThemeInternal(GetNameForUri(dictionaryUri), dictionaryUri); + } + + /// + /// Clears the currently active theme ResourceDictionary. Also clears the override dictionary. + /// + public void ClearActiveTheme() + { + lock (_resourceSwapGate) + { + var app = Application.Current; + if (app is null) + { + return; + } + + if (_activeDictionary is not null && ApplyToAppResources) + { + _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); + _activeDictionary = null; + } + + // Clear overrides but keep the override dictionary merged for future updates + _overrideDictionary?.Clear(); + + _currentThemeName = null; + _currentThemeUri = null; + } + } + + private void ActivateThemeInternal(string? name, Uri dictionaryUri) + { + lock (_resourceSwapGate) + { + _currentThemeName = name; + _currentThemeUri = dictionaryUri; + } + + if (ApplyToAppResources) + { + ActivateThemeCore(dictionaryUri); + } + + OnResourcesSwapped(new(name, dictionaryUri)); + } + + private void ActivateThemeCore(Uri dictionaryUri) + { + var app = Application.Current ?? throw new InvalidOperationException("Application.Current is null"); + + // Remove previously applied base theme dictionary + if (_activeDictionary is not null) + { + _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); + _activeDictionary = null; + } + + // Load and merge the new base theme dictionary + var newDict = new ResourceDictionary { Source = dictionaryUri }; + app.Resources.MergedDictionaries.Add(newDict); + _activeDictionary = newDict; + + // Ensure override dictionary exists and is merged last, then clear it to avoid leaking stale overrides + _overrideDictionary = GetOverrideDictionary(clear: true); + } + + private string? GetNameForUri(Uri dictionaryUri) + { + lock (_resourceSwapGate) + { + foreach (var (key, value) in _themeUris) + { + if (Uri.Compare(value, dictionaryUri, UriComponents.AbsoluteUri, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) + { + return key; + } + } + + return null; + } + } + + private void OnResourcesSwapped(ResourcesSwappedEventArgs e) + { + ResourcesSwapped?.Invoke(this, e); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs new file mode 100644 index 0000000000..0a5cc15de6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Services; + +public sealed class ResourcesSwappedEventArgs(string? name, Uri dictionaryUri) : EventArgs +{ + public string? Name { get; } = name; + + public Uri DictionaryUri { get; } = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri)); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs new file mode 100644 index 0000000000..67432c8748 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Services; + +internal sealed record ThemeContext +{ + public ElementTheme Theme { get; init; } + + public Color Tint { get; init; } + + public ImageSource? BackgroundImageSource { get; init; } + + public Stretch BackgroundImageStretch { get; init; } + + public double BackgroundImageOpacity { get; init; } + + public int? ColorIntensity { get; init; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs new file mode 100644 index 0000000000..65fbfb24d7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI; +using ManagedCommon; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.UI.ViewManagement; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// ThemeService is a hub that translates user settings and system preferences into concrete +/// theme resources and notifies listeners of changes. +/// +internal sealed partial class ThemeService : IThemeService, IDisposable +{ + private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500); + + private readonly UISettings _uiSettings; + private readonly SettingsModel _settings; + private readonly ResourceSwapper _resourceSwapper; + private readonly NormalThemeProvider _normalThemeProvider; + private readonly ColorfulThemeProvider _colorfulThemeProvider; + + private DispatcherQueue? _dispatcherQueue; + private DispatcherQueueTimer? _dispatcherQueueTimer; + private bool _isInitialized; + private bool _disposed; + private InternalThemeState _currentState; + + public event EventHandler? ThemeChanged; + + public ThemeSnapshot Current => Volatile.Read(ref _currentState).Snapshot; + + /// + /// Initializes the theme service. Must be called after the application window is activated and on UI thread. + /// + public void Initialize() + { + if (_isInitialized) + { + return; + } + + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + if (_dispatcherQueue is null) + { + throw new InvalidOperationException("Failed to get DispatcherQueue for the current thread. Ensure Initialize is called on the UI thread after window activation."); + } + + _dispatcherQueueTimer = _dispatcherQueue.CreateTimer(); + + _resourceSwapper.Initialize(); + _isInitialized = true; + Reload(); + } + + private void Reload() + { + if (!_isInitialized) + { + return; + } + + // provider selection + var intensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100); + IThemeProvider provider = intensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image + ? _colorfulThemeProvider + : _normalThemeProvider; + + // Calculate values + var tint = _settings.ColorizationMode switch + { + ColorizationMode.CustomColor => _settings.CustomThemeColor, + ColorizationMode.WindowsAccentColor => _uiSettings.GetColorValue(UIColorType.Accent), + ColorizationMode.Image => _settings.CustomThemeColor, + _ => Colors.Transparent, + }; + var effectiveTheme = GetElementTheme((ElementTheme)_settings.Theme); + var imageSource = _settings.ColorizationMode == ColorizationMode.Image + ? LoadImageSafe(_settings.BackgroundImagePath) + : null; + var stretch = _settings.BackgroundImageFit switch + { + BackgroundImageFit.Fill => Stretch.Fill, + _ => Stretch.UniformToFill, + }; + var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0; + + // create context and offload to actual theme provider + var context = new ThemeContext + { + Tint = tint, + ColorIntensity = intensity, + Theme = effectiveTheme, + BackgroundImageSource = imageSource, + BackgroundImageStretch = stretch, + BackgroundImageOpacity = opacity, + }; + var backdrop = provider.GetAcrylicBackdrop(context); + var blur = _settings.BackgroundImageBlurAmount; + var brightness = _settings.BackgroundImageBrightness; + + // Create public snapshot (no provider!) + var snapshot = new ThemeSnapshot + { + Tint = tint, + TintIntensity = intensity / 100f, + Theme = effectiveTheme, + BackgroundImageSource = imageSource, + BackgroundImageStretch = stretch, + BackgroundImageOpacity = opacity, + BackdropParameters = backdrop, + BlurAmount = blur, + BackgroundBrightness = brightness / 100f, + }; + + // Bundle with provider for internal use + var newState = new InternalThemeState + { + Snapshot = snapshot, + Provider = provider, + }; + + // Atomic swap + Interlocked.Exchange(ref _currentState, newState); + + _resourceSwapper.TryActivateTheme(provider.ThemeKey); + ThemeChanged?.Invoke(this, new ThemeChangedEventArgs()); + } + + private static BitmapImage? LoadImageSafe(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + // If it looks like a file path and exists, prefer absolute file URI + if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri)) + { + return null; + } + + if (!uri.IsAbsoluteUri && File.Exists(path)) + { + uri = new Uri(Path.GetFullPath(path)); + } + + return new BitmapImage(uri); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to load background image '{path}'. {ex.Message}"); + return null; + } + } + + public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(resourceSwapper); + + _settings = settings; + _settings.SettingsChanged += SettingsOnSettingsChanged; + + _resourceSwapper = resourceSwapper; + + _uiSettings = new UISettings(); + _uiSettings.ColorValuesChanged += UiSettings_ColorValuesChanged; + + _normalThemeProvider = new NormalThemeProvider(_uiSettings); + _colorfulThemeProvider = new ColorfulThemeProvider(_uiSettings); + List providers = [_normalThemeProvider, _colorfulThemeProvider]; + + foreach (var provider in providers) + { + _resourceSwapper.RegisterTheme(provider.ThemeKey, provider.ResourcePath); + } + + _currentState = new InternalThemeState + { + Snapshot = new ThemeSnapshot + { + Tint = Colors.Transparent, + Theme = ElementTheme.Light, + BackdropParameters = new AcrylicBackdropParameters(Colors.Black, Colors.Black, 0.5f, 0.5f), + BackgroundImageOpacity = 1, + BackgroundImageSource = null, + BackgroundImageStretch = Stretch.Fill, + BlurAmount = 0, + TintIntensity = 1.0f, + BackgroundBrightness = 0, + }, + Provider = _normalThemeProvider, + }; + } + + private void RequestReload() + { + if (!_isInitialized || _dispatcherQueueTimer is null) + { + return; + } + + _dispatcherQueueTimer.Debounce(Reload, ReloadDebounceInterval); + } + + private ElementTheme GetElementTheme(ElementTheme theme) + { + return theme switch + { + ElementTheme.Light => ElementTheme.Light, + ElementTheme.Dark => ElementTheme.Dark, + _ => _uiSettings.GetColorValue(UIColorType.Background).CalculateBrightness() < 0.5 + ? ElementTheme.Dark + : ElementTheme.Light, + }; + } + + private void SettingsOnSettingsChanged(SettingsModel sender, object? args) + { + RequestReload(); + } + + private void UiSettings_ColorValuesChanged(UISettings sender, object args) + { + RequestReload(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _dispatcherQueueTimer?.Stop(); + _uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged; + _settings.SettingsChanged -= SettingsOnSettingsChanged; + } + + private sealed class InternalThemeState + { + public required ThemeSnapshot Snapshot { get; init; } + + public required IThemeProvider Provider { get; init; } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs new file mode 100644 index 0000000000..5c250b94ef --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Synchronizes a window's theme with . +/// +internal sealed partial class WindowThemeSynchronizer : IDisposable +{ + private readonly IThemeService _themeService; + private readonly Window _window; + + /// + /// Initializes a new instance of the class and subscribes to theme changes. + /// + /// The theme service to monitor for changes. + /// The window to synchronize. + /// Thrown when or is null. + public WindowThemeSynchronizer(IThemeService themeService, Window window) + { + _themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); + _window = window ?? throw new ArgumentNullException(nameof(window)); + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + } + + /// + /// Unsubscribes from theme change events. + /// + public void Dispose() + { + _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; + } + + /// + /// Applies the current theme to the window when theme changes occur. + /// + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + if (_window.Content is not FrameworkElement fe) + { + return; + } + + var dispatcherQueue = fe.DispatcherQueue; + + if (dispatcherQueue is not null && dispatcherQueue.HasThreadAccess) + { + ApplyRequestedTheme(fe); + } + else + { + dispatcherQueue?.TryEnqueue(() => ApplyRequestedTheme(fe)); + } + } + + private void ApplyRequestedTheme(FrameworkElement fe) + { + // LOAD BEARING: Changing the RequestedTheme to Dark then Light then target forces + // a refresh of the theme. + fe.RequestedTheme = ElementTheme.Dark; + fe.RequestedTheme = ElementTheme.Light; + fe.RequestedTheme = _themeService.Current.Theme; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml new file mode 100644 index 0000000000..b9f31d8443 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +