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 c1564919e2..f5d9f61868 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -144,6 +144,8 @@ BLENDFUNCTION blittable Blockquotes blt +bluelightreduction +bluelightreductionstate BLURBEHIND BLURREGION bmi @@ -1116,6 +1118,7 @@ NEWPLUSSHELLEXTENSIONWIN newrow nicksnettravels NIF +nightlight NLog NLSTEXT NMAKE @@ -1852,6 +1855,8 @@ uitests UITo ULONGLONG ums +UMax +UMin uncompilable UNCPRIORITY UNDNAME diff --git a/.pipelines/versionAndSignCheck.ps1 b/.pipelines/versionAndSignCheck.ps1 index ef99979754..46197df820 100644 --- a/.pipelines/versionAndSignCheck.ps1 +++ b/.pipelines/versionAndSignCheck.ps1 @@ -27,7 +27,8 @@ $versionExceptions = @( "WyHash.dll", "Microsoft.Recognizers.Text.DataTypes.TimexExpression.dll", "ObjectModelCsProjection.dll", - "RendererCsProjection.dll") -join '|'; + "RendererCsProjection.dll", + "Microsoft.ML.OnnxRuntime.dll") -join '|'; $nullVersionExceptions = @( "SkiaSharp.Views.WinUI.Native.dll", "libSkiaSharp.dll", diff --git a/Cpp.Build.props b/Cpp.Build.props index 7b988f0d6f..f146a4d770 100644 --- a/Cpp.Build.props +++ b/Cpp.Build.props @@ -42,11 +42,6 @@ - - true - TurnOffAllWarnings - true - true Use pch.h @@ -116,11 +111,13 @@ - + true true - + false true false diff --git a/Directory.Packages.props b/Directory.Packages.props index 55abf5a565..4710f9a097 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,8 +7,6 @@ - - @@ -72,12 +70,10 @@ This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail. --> - - - - - + + + @@ -117,7 +113,6 @@ - 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 cef2ef641c..11cfd412b0 100644 --- a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -52,6 +52,7 @@ enum class ScheduleMode Off, FixedHours, SunsetToSunrise, + FollowNightLight, // add more later }; @@ -63,6 +64,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"; } @@ -74,6 +77,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; } @@ -178,7 +183,9 @@ public: ToString(g_settings.m_scheduleMode), { { L"Off", L"Disable the schedule" }, { L"FixedHours", L"Set hours manually" }, - { L"SunsetToSunrise", L"Use sunrise/sunset times" } }); + { L"SunsetToSunrise", L"Use sunrise/sunset times" }, + { L"FollowNightLight", L"Follow Windows Night Light state" } + }); // Integer spinners settings.add_int_spinner( diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index 845e24fa93..b6684da54e 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -13,10 +13,12 @@ #include #include "LightSwitchStateManager.h" #include +#include SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; HANDLE g_ServiceStopEvent = nullptr; +static LightSwitchStateManager* g_stateManagerPtr = nullptr; VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); @@ -168,7 +170,15 @@ static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateMan } // Use shared helper (handles wraparound logic) - bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + bool shouldBeLight = false; + if (s.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !IsNightLightEnabled(); + } + else + { + shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + } // Compare current system/apps theme bool currentSystemLight = GetCurrentSystemTheme(); @@ -199,15 +209,40 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) // Initialization // ──────────────────────────────────────────────────────────────── static LightSwitchStateManager stateManager; + g_stateManagerPtr = &stateManager; LightSwitchSettings::instance().InitFileWatcher(); HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent(); + static std::unique_ptr g_nightLightWatcher; + LightSwitchSettings::instance().LoadSettings(); const auto& settings = LightSwitchSettings::instance().settings(); + // after loading settings: + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + SYSTEMTIME st; GetLocalTime(&st); int nowMinutes = st.wHour * 60 + st.wMinute; @@ -274,6 +309,31 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) ResetEvent(hSettingsChanged); LightSwitchSettings::instance().LoadSettings(); stateManager.OnSettingsChanged(); + + const auto& settings = LightSwitchSettings::instance().settings(); + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + + stateManager.OnNightLightChange(); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + continue; } } @@ -285,6 +345,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) CloseHandle(hManualOverride); if (hParent) CloseHandle(hParent); + if (g_nightLightWatcher) + { + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } Logger::info(L"[LightSwitchService] Worker thread exiting cleanly."); return 0; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index a3a505f897..e1c8052de6 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -76,6 +76,7 @@ + @@ -88,6 +89,7 @@ + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters index 795df99aba..55c7bde39b 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -36,6 +36,9 @@ Source Files + + Source Files + @@ -62,6 +65,9 @@ Header Files + + Header Files + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h index d4029d072d..1d1c7953fe 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -19,7 +19,8 @@ enum class ScheduleMode { Off, FixedHours, - SunsetToSunrise + SunsetToSunrise, + FollowNightLight, // Add more in the future }; @@ -31,6 +32,8 @@ inline std::wstring ToString(ScheduleMode mode) return L"FixedHours"; case ScheduleMode::SunsetToSunrise: return L"SunsetToSunrise"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; default: return L"Off"; } @@ -42,6 +45,8 @@ inline ScheduleMode FromString(const std::wstring& str) return ScheduleMode::SunsetToSunrise; if (str == L"FixedHours") return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; else return ScheduleMode::Off; } diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp index 4fba4ae9a6..f562d38c41 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -31,7 +31,10 @@ void LightSwitchStateManager::OnSettingsChanged() void LightSwitchStateManager::OnTick(int currentMinutes) { std::lock_guard lock(_stateMutex); - EvaluateAndApplyIfNeeded(); + if (_state.lastAppliedMode != ScheduleMode::FollowNightLight) + { + EvaluateAndApplyIfNeeded(); + } } // Called when manual override is triggered @@ -49,8 +52,38 @@ void LightSwitchStateManager::OnManualOverride() _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).", - (_state.isSystemLightActive ? L"light" : L"dark"), - (_state.isAppsLightActive ? L"light" : L"dark")); + (_state.isSystemLightActive ? L"light" : L"dark"), + (_state.isAppsLightActive ? L"light" : L"dark")); + } + + EvaluateAndApplyIfNeeded(); +} + +// Runs with the registry observer detects a change in Night Light settings. +void LightSwitchStateManager::OnNightLightChange() +{ + std::lock_guard lock(_stateMutex); + + bool newNightLightState = IsNightLightEnabled(); + + // In Follow Night Light mode, treat a Night Light toggle as a boundary + if (_state.lastAppliedMode == ScheduleMode::FollowNightLight && _state.isManualOverride) + { + Logger::info(L"[LightSwitchStateManager] Night Light changed while manual override active; " + L"treating as a boundary and clearing manual override."); + _state.isManualOverride = false; + } + + if (newNightLightState != _state.isNightLightActive) + { + Logger::info(L"[LightSwitchStateManager] Night Light toggled to {}", + newNightLightState ? L"ON" : L"OFF"); + + _state.isNightLightActive = newNightLightState; + } + else + { + Logger::debug(L"[LightSwitchStateManager] Night Light change event fired, but no actual change."); } EvaluateAndApplyIfNeeded(); @@ -77,9 +110,9 @@ void LightSwitchStateManager::SyncInitialThemeState() _state.isSystemLightActive = GetCurrentSystemTheme(); _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})", - _state.isSystemLightActive ? L"light" : L"dark"); + _state.isSystemLightActive ? L"light" : L"dark"); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})", - _state.isAppsLightActive ? L"light" : L"dark"); + _state.isAppsLightActive ? L"light" : L"dark"); } static std::pair update_sun_times(auto& settings) @@ -194,7 +227,15 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastAppliedMode = _currentSettings.scheduleMode; - bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + bool shouldBeLight = false; + if (_currentSettings.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !_state.isNightLightActive; + } + else + { + shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + } bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight); bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight); @@ -227,6 +268,3 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastTickMinutes = now; } - - - diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h index 5c9bcc6e25..c4f39a2e9a 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -9,6 +9,7 @@ struct LightSwitchState bool isManualOverride = false; bool isSystemLightActive = false; bool isAppsLightActive = false; + bool isNightLightActive = false; int lastEvaluatedDay = -1; int lastTickMinutes = -1; @@ -32,6 +33,9 @@ public: // Called when manual override is toggled (via shortcut or system change). void OnManualOverride(); + // Called when night light changes in windows settings + void OnNightLightChange(); + // Initial sync at startup to align internal state with system theme void SyncInitialThemeState(); diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp new file mode 100644 index 0000000000..8da19c6595 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp @@ -0,0 +1 @@ +#include "NightLightRegistryObserver.h" diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h new file mode 100644 index 0000000000..2806c28316 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h @@ -0,0 +1,134 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class NightLightRegistryObserver +{ +public: + NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function callback) : + _root(root), _subkey(subkey), _callback(std::move(callback)), _stop(false) + { + _thread = std::thread([this]() { this->Run(); }); + } + + ~NightLightRegistryObserver() + { + Stop(); + } + + void Stop() + { + _stop = true; + + { + std::lock_guard lock(_mutex); + if (_event) + SetEvent(_event); + } + + if (_thread.joinable()) + _thread.join(); + + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + + +private: + void Run() + { + { + std::lock_guard lock(_mutex); + if (RegOpenKeyExW(_root, _subkey.c_str(), 0, KEY_NOTIFY, &_hKey) != ERROR_SUCCESS) + return; + + _event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!_event) + { + RegCloseKey(_hKey); + _hKey = nullptr; + return; + } + } + + while (!_stop) + { + HKEY hKeyLocal = nullptr; + HANDLE eventLocal = nullptr; + + { + std::lock_guard lock(_mutex); + if (_stop) + break; + + hKeyLocal = _hKey; + eventLocal = _event; + } + + if (!hKeyLocal || !eventLocal) + break; + + if (_stop) + break; + + if (RegNotifyChangeKeyValue(hKeyLocal, FALSE, REG_NOTIFY_CHANGE_LAST_SET, eventLocal, TRUE) != ERROR_SUCCESS) + break; + + DWORD wait = WaitForSingleObject(eventLocal, INFINITE); + if (_stop || wait == WAIT_FAILED) + break; + + ResetEvent(eventLocal); + + if (!_stop && _callback) + { + try + { + _callback(); + } + catch (...) + { + } + } + } + + { + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + } + + + HKEY _root; + std::wstring _subkey; + std::function _callback; + HANDLE _event = nullptr; + HKEY _hKey = nullptr; + std::thread _thread; + std::atomic _stop; + std::mutex _mutex; +}; \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h index 4872864eff..8015c9b3e6 100644 --- a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h @@ -11,4 +11,7 @@ enum class SettingId Sunset_Offset, ChangeSystem, ChangeApps -}; \ No newline at end of file +}; + +constexpr wchar_t PERSONALIZATION_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr wchar_t NIGHT_LIGHT_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.bluelightreduction.bluelightreductionstate\\windows.data.bluelightreduction.bluelightreductionstate"; diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp index 9633ab2fde..cfa858c636 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp @@ -3,6 +3,7 @@ #include #include #include "ThemeHelper.h" +#include // Controls changing the themes. @@ -10,7 +11,7 @@ static void ResetColorPrevalence() { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -31,7 +32,7 @@ void SetAppsTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -50,7 +51,7 @@ void SetSystemTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -79,7 +80,7 @@ bool GetCurrentSystemTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -98,7 +99,7 @@ bool GetCurrentAppsTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -109,3 +110,30 @@ bool GetCurrentAppsTheme() return value == 1; // true = light, false = dark } + +bool IsNightLightEnabled() +{ + HKEY hKey; + const wchar_t* path = NIGHT_LIGHT_REGISTRY_PATH; + + if (RegOpenKeyExW(HKEY_CURRENT_USER, path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return false; + + // RegGetValueW will set size to the size of the data and we expect that to be at least 25 bytes (we need to access bytes 23 and 24) + DWORD size = 0; + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, nullptr, &size) != ERROR_SUCCESS || size < 25) + { + RegCloseKey(hKey); + return false; + } + + std::vector data(size); + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, data.data(), &size) != ERROR_SUCCESS) + { + RegCloseKey(hKey); + return false; + } + + RegCloseKey(hKey); + return data[23] == 0x10 && data[24] == 0x00; +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h index 5985fd95c8..e8d45e9c2a 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h @@ -3,3 +3,4 @@ void SetSystemTheme(bool dark); void SetAppsTheme(bool dark); bool GetCurrentSystemTheme(); bool GetCurrentAppsTheme(); +bool IsNightLightEnabled(); \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj index c71c81acec..6de7c50b55 100644 --- a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj +++ b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj @@ -1,16 +1,14 @@  - - - PackageReference - - - native,Version=v0.0 - - - Windows - $(WindowsTargetPlatformVersion) - + + + + + + + + + true true @@ -33,11 +31,6 @@ true true - - - - - DynamicLibrary @@ -45,6 +38,7 @@ true + @@ -124,6 +118,9 @@ true + + + {caba8dfb-823b-4bf2-93ac-3f31984150d9} @@ -145,5 +142,42 @@ - + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolCore/packages.config b/src/modules/MeasureTool/MeasureToolCore/packages.config new file mode 100644 index 0000000000..6416ca5b16 --- /dev/null +++ b/src/modules/MeasureTool/MeasureToolCore/packages.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj b/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj index 3e92bd42f3..434ff088b2 100644 --- a/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj +++ b/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj @@ -73,13 +73,6 @@ - - false - true - - - - PreserveNewest - + diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj index bfed4af15d..d127de245e 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj @@ -1,16 +1,13 @@ - - - PackageReference - - - native,Version=v0.0 - - - Windows - $(WindowsTargetPlatformVersion) - + + + + + + + + 15.0 {e94fd11c-0591-456f-899f-efc0ca548336} @@ -23,12 +20,9 @@ false true false + + packages.config - - - - - DynamicLibrary @@ -133,18 +127,18 @@ + + + - - - - - - - - NotUsing - - - + + + + + + NotUsing + + <_ToDelete Include="$(OutDir)Microsoft.Web.WebView2.Core.dll" /> @@ -154,4 +148,38 @@ + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/packages.config b/src/modules/MouseUtils/FindMyMouse/packages.config new file mode 100644 index 0000000000..cff3aa8705 --- /dev/null +++ b/src/modules/MouseUtils/FindMyMouse/packages.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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 preview - ..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\ + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\ false false $(RootNamespace).pri - + SA1313; @@ -42,5 +42,5 @@ Resources.Designer.cs - + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props index 298bcbd787..d99688c081 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props @@ -24,7 +24,7 @@ - + true Assets\%(RecursiveDir)%(FileName)%(Extension) @@ -35,10 +35,14 @@ - - - - + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props index d65b4a2cc2..21c2e7d8d1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props @@ -7,7 +7,7 @@ - ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal $(OutputPath)\AppPackages\ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs new file mode 100644 index 0000000000..2d7567c346 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Controls; + +internal sealed class UVBounds +{ + public double UMin { get; } + + public double UMax { get; } + + public double VMin { get; } + + public double VMax { get; } + + public UVBounds(Orientation orientation, Rect rect) + { + if (orientation == Orientation.Horizontal) + { + UMin = rect.Left; + UMax = rect.Right; + VMin = rect.Top; + VMax = rect.Bottom; + } + else + { + UMin = rect.Top; + UMax = rect.Bottom; + VMin = rect.Left; + VMax = rect.Right; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs new file mode 100644 index 0000000000..1b75c31564 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Controls; + +[DebuggerDisplay("U = {U} V = {V}")] +internal struct UvMeasure +{ + internal double U { get; set; } + + internal double V { get; set; } + + internal static UvMeasure Zero => default(UvMeasure); + + public UvMeasure(Orientation orientation, Size size) + : this(orientation, size.Width, size.Height) + { + } + + public UvMeasure(Orientation orientation, double width, double height) + { + if (orientation == Orientation.Horizontal) + { + U = width; + V = height; + } + else + { + U = height; + V = width; + } + } + + public UvMeasure Add(double u, double v) + { + UvMeasure result = default(UvMeasure); + result.U = U + u; + result.V = V + v; + return result; + } + + public UvMeasure Add(UvMeasure measure) + { + return Add(measure.U, measure.V); + } + + public Size ToSize(Orientation orientation) + { + if (orientation != Orientation.Horizontal) + { + return new Size(V, U); + } + + return new Size(U, V); + } + + public Point GetPoint(Orientation orientation) + { + return orientation is Orientation.Horizontal ? new Point(U, V) : new Point(V, U); + } + + public Size GetSize(Orientation orientation) + { + return orientation is Orientation.Horizontal ? new Size(U, V) : new Size(V, U); + } + + public static bool operator ==(UvMeasure measure1, UvMeasure measure2) + { + return measure1.U == measure2.U && measure1.V == measure2.V; + } + + public static bool operator !=(UvMeasure measure1, UvMeasure measure2) + { + return !(measure1 == measure2); + } + + public override bool Equals(object? obj) + { + return obj is UvMeasure measure && this == measure; + } + + public bool Equals(UvMeasure value) + { + return this == value; + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs new file mode 100644 index 0000000000..ea0101bfa3 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.WinUI.Controls; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Controls; + +/// +/// Arranges elements by wrapping them to fit the available space. +/// When is set to Orientation.Horizontal, element are arranged in rows until the available width is reached and then to a new row. +/// When is set to Orientation.Vertical, element are arranged in columns until the available height is reached. +/// +public sealed partial class WrapPanel : Panel +{ + private struct UvRect + { + public UvMeasure Position { get; set; } + + public UvMeasure Size { get; set; } + + public Rect ToRect(Orientation orientation) + { + return orientation switch + { + Orientation.Vertical => new Rect(Position.V, Position.U, Size.V, Size.U), + Orientation.Horizontal => new Rect(Position.U, Position.V, Size.U, Size.V), + _ => ThrowArgumentException(), + }; + } + + private static Rect ThrowArgumentException() + { + throw new ArgumentException("The input orientation is not valid."); + } + } + + private struct Row + { + public List ChildrenRects { get; } + + public UvMeasure Size { get; set; } + + public UvRect Rect + { + get + { + UvRect result; + if (ChildrenRects.Count <= 0) + { + result = default(UvRect); + result.Position = UvMeasure.Zero; + result.Size = Size; + return result; + } + + result = default(UvRect); + result.Position = ChildrenRects.First().Position; + result.Size = Size; + return result; + } + } + + public Row(List childrenRects, UvMeasure size) + { + ChildrenRects = childrenRects; + Size = size; + } + + public void Add(UvMeasure position, UvMeasure size) + { + ChildrenRects.Add(new UvRect + { + Position = position, + Size = size, + }); + + Size = new UvMeasure + { + U = position.U + size.U, + V = Math.Max(Size.V, size.V), + }; + } + } + + /// + /// Gets or sets a uniform Horizontal distance (in pixels) between items when is set to Horizontal, + /// or between columns of items when is set to Vertical. + /// + public double HorizontalSpacing + { + get { return (double)GetValue(HorizontalSpacingProperty); } + set { SetValue(HorizontalSpacingProperty, value); } + } + + private bool IsSectionItem(UIElement element) => element is FrameworkElement fe && fe.DataContext is ListItemViewModel item && item.IsSectionOrSeparator; + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty HorizontalSpacingProperty = + DependencyProperty.Register( + nameof(HorizontalSpacing), + typeof(double), + typeof(WrapPanel), + new PropertyMetadata(0d, LayoutPropertyChanged)); + + /// + /// Gets or sets a uniform Vertical distance (in pixels) between items when is set to Vertical, + /// or between rows of items when is set to Horizontal. + /// + public double VerticalSpacing + { + get { return (double)GetValue(VerticalSpacingProperty); } + set { SetValue(VerticalSpacingProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty VerticalSpacingProperty = + DependencyProperty.Register( + nameof(VerticalSpacing), + typeof(double), + typeof(WrapPanel), + new PropertyMetadata(0d, LayoutPropertyChanged)); + + /// + /// Gets or sets the orientation of the WrapPanel. + /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls. + /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added. + /// + public Orientation Orientation + { + get { return (Orientation)GetValue(OrientationProperty); } + set { SetValue(OrientationProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty OrientationProperty = + DependencyProperty.Register( + nameof(Orientation), + typeof(Orientation), + typeof(WrapPanel), + new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged)); + + /// + /// Gets or sets the distance between the border and its child object. + /// + /// + /// The dimensions of the space between the border and its child as a Thickness value. + /// Thickness is a structure that stores dimension values using pixel measures. + /// + public Thickness Padding + { + get { return (Thickness)GetValue(PaddingProperty); } + set { SetValue(PaddingProperty, value); } + } + + /// + /// Identifies the Padding dependency property. + /// + /// The identifier for the dependency property. + public static readonly DependencyProperty PaddingProperty = + DependencyProperty.Register( + nameof(Padding), + typeof(Thickness), + typeof(WrapPanel), + new PropertyMetadata(default(Thickness), LayoutPropertyChanged)); + + /// + /// Gets or sets a value indicating how to arrange child items + /// + public StretchChild StretchChild + { + get { return (StretchChild)GetValue(StretchChildProperty); } + set { SetValue(StretchChildProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + /// The identifier for the dependency property. + public static readonly DependencyProperty StretchChildProperty = + DependencyProperty.Register( + nameof(StretchChild), + typeof(StretchChild), + typeof(WrapPanel), + new PropertyMetadata(StretchChild.None, LayoutPropertyChanged)); + + /// + /// Identifies the IsFullLine attached dependency property. + /// If true, the child element will occupy the entire width of the panel and force a line break before and after itself. + /// + public static readonly DependencyProperty IsFullLineProperty = + DependencyProperty.RegisterAttached( + "IsFullLine", + typeof(bool), + typeof(WrapPanel), + new PropertyMetadata(false, OnIsFullLineChanged)); + + public static bool GetIsFullLine(DependencyObject obj) + { + return (bool)obj.GetValue(IsFullLineProperty); + } + + public static void SetIsFullLine(DependencyObject obj, bool value) + { + obj.SetValue(IsFullLineProperty, value); + } + + private static void OnIsFullLineChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (FindVisualParentWrapPanel(d) is WrapPanel wp) + { + wp.InvalidateMeasure(); + } + } + + private static WrapPanel? FindVisualParentWrapPanel(DependencyObject child) + { + var parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(child); + + while (parent != null) + { + if (parent is WrapPanel wrapPanel) + { + return wrapPanel; + } + + parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(parent); + } + + return null; + } + + private static void LayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is WrapPanel wp) + { + wp.InvalidateMeasure(); + wp.InvalidateArrange(); + } + } + + private readonly List _rows = new List(); + + /// + protected override Size MeasureOverride(Size availableSize) + { + var childAvailableSize = new Size( + availableSize.Width - Padding.Left - Padding.Right, + availableSize.Height - Padding.Top - Padding.Bottom); + foreach (var child in Children) + { + child.Measure(childAvailableSize); + } + + var requiredSize = UpdateRows(availableSize); + return requiredSize; + } + + /// + protected override Size ArrangeOverride(Size finalSize) + { + if ((Orientation == Orientation.Horizontal && finalSize.Width < DesiredSize.Width) || + (Orientation == Orientation.Vertical && finalSize.Height < DesiredSize.Height)) + { + // We haven't received our desired size. We need to refresh the rows. + UpdateRows(finalSize); + } + + if (_rows.Count > 0) + { + // Now that we have all the data, we do the actual arrange pass + var childIndex = 0; + foreach (var row in _rows) + { + foreach (var rect in row.ChildrenRects) + { + var child = Children[childIndex++]; + while (child.Visibility == Visibility.Collapsed) + { + // Collapsed children are not added into the rows, + // we skip them. + child = Children[childIndex++]; + } + + var arrangeRect = new UvRect + { + Position = rect.Position, + Size = new UvMeasure { U = rect.Size.U, V = row.Size.V }, + }; + + var finalRect = arrangeRect.ToRect(Orientation); + child.Arrange(finalRect); + } + } + } + + return finalSize; + } + + private Size UpdateRows(Size availableSize) + { + _rows.Clear(); + + var paddingStart = new UvMeasure(Orientation, Padding.Left, Padding.Top); + var paddingEnd = new UvMeasure(Orientation, Padding.Right, Padding.Bottom); + + if (Children.Count == 0) + { + return paddingStart.Add(paddingEnd).ToSize(Orientation); + } + + var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height); + var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing); + var position = new UvMeasure(Orientation, Padding.Left, Padding.Top); + + var currentRow = new Row(new List(), default); + var finalMeasure = new UvMeasure(Orientation, width: 0.0, height: 0.0); + + void CommitRow() + { + // Only adds if the row has a content + if (currentRow.ChildrenRects.Count > 0) + { + _rows.Add(currentRow); + + position.V += currentRow.Size.V + spacingMeasure.V; + } + + position.U = paddingStart.U; + + currentRow = new Row(new List(), default); + } + + void Arrange(UIElement child, bool isLast = false) + { + if (child.Visibility == Visibility.Collapsed) + { + return; + } + + var isFullLine = IsSectionItem(child); + var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize); + + if (isFullLine) + { + if (currentRow.ChildrenRects.Count > 0) + { + CommitRow(); + } + + // Forces the width to fill all the available space + // (Total width - Padding Left - Padding Right) + desiredMeasure.U = parentMeasure.U - paddingStart.U - paddingEnd.U; + + // Adds the Section Header to the row + currentRow.Add(position, desiredMeasure); + + // Updates the global measures + position.U += desiredMeasure.U + spacingMeasure.U; + finalMeasure.U = Math.Max(finalMeasure.U, position.U); + + CommitRow(); + } + else + { + // Checks if the item can fit in the row + if ((desiredMeasure.U + position.U + paddingEnd.U) > parentMeasure.U) + { + CommitRow(); + } + + if (isLast) + { + desiredMeasure.U = parentMeasure.U - position.U; + } + + currentRow.Add(position, desiredMeasure); + + position.U += desiredMeasure.U + spacingMeasure.U; + finalMeasure.U = Math.Max(finalMeasure.U, position.U); + } + } + + var lastIndex = Children.Count - 1; + for (var i = 0; i < lastIndex; i++) + { + Arrange(Children[i]); + } + + Arrange(Children[lastIndex], StretchChild == StretchChild.Last); + + if (currentRow.ChildrenRects.Count > 0) + { + _rows.Add(currentRow); + } + + if (_rows.Count == 0) + { + return paddingStart.Add(paddingEnd).ToSize(Orientation); + } + + var lastRowRect = _rows.Last().Rect; + finalMeasure.V = lastRowRect.Position.V + lastRowRect.Size.V; + return finalMeasure.Add(paddingEnd).ToSize(Orientation); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs new file mode 100644 index 0000000000..033c03a0b9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.CmdPal.UI; + +public partial class DetailsSizeToGridLengthConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is ContentSize size) + { + // This converter calculates the Star width for the LIST. + // + // The input 'size' (ContentSize) represents the TARGET WIDTH desired for the DETAILS PANEL. + // + // To ensure the Details Panel achieves its target size (e.g. ContentSize.Large), + // we must shrink the List and let the Details fill the available space. + // (e.g., A larger target size for Details results in a smaller Star value for the List). + var starValue = size switch + { + ContentSize.Small => 3.0, + ContentSize.Medium => 2.0, + ContentSize.Large => 1.0, + _ => 3.0, + }; + + return new GridLength(starValue, GridUnitType.Star); + } + + return new GridLength(3.0, GridUnitType.Star); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs index c93470e3e3..f638f3f09e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs @@ -18,8 +18,23 @@ internal sealed partial class GridItemTemplateSelector : DataTemplateSelector public DataTemplate? Gallery { get; set; } + public DataTemplate? Section { get; set; } + + public DataTemplate? Separator { get; set; } + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) { + if (item is ListItemViewModel element && element.IsSectionOrSeparator) + { + if (dependencyObject is UIElement li) + { + li.IsTabStop = false; + li.IsHitTestVisible = false; + } + + return string.IsNullOrWhiteSpace(element.Section) ? Separator : Section; + } + return GridProperties switch { SmallGridPropertiesViewModel => Small, diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs new file mode 100644 index 0000000000..7fb810f5dc --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +public sealed partial class ListItemTemplateSelector : DataTemplateSelector +{ + public DataTemplate? ListItem { get; set; } + + public DataTemplate? Separator { get; set; } + + public DataTemplate? Section { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) + { + DataTemplate? dataTemplate = ListItem; + + if (container is ListViewItem listItem) + { + if (item is ListItemViewModel element) + { + if (container is ListViewItem li && element.IsSectionOrSeparator) + { + li.IsEnabled = false; + li.AllowFocusWhenDisabled = false; + li.AllowFocusOnInteraction = false; + li.IsHitTestVisible = false; + dataTemplate = string.IsNullOrWhiteSpace(element.Section) ? Separator : Section; + } + else + { + listItem.IsEnabled = true; + listItem.AllowFocusWhenDisabled = true; + listItem.AllowFocusOnInteraction = true; + listItem.IsHitTestVisible = true; + } + } + } + + return dataTemplate; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index 7cf720198a..859a74eb18 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -28,6 +28,8 @@ 8