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 4a3305217e..f649476f60 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 @@ -1115,6 +1117,7 @@ NEWPLUSSHELLEXTENSIONWIN newrow nicksnettravels NIF +nightlight NLog NLSTEXT NMAKE @@ -1851,6 +1854,8 @@ uitests UITo ULONGLONG ums +UMax +UMin uncompilable UNCPRIORITY UNDNAME diff --git a/.pipelines/versionAndSignCheck.ps1 b/.pipelines/versionAndSignCheck.ps1 index f90e59afd6..cf1f515e78 100644 --- a/.pipelines/versionAndSignCheck.ps1 +++ b/.pipelines/versionAndSignCheck.ps1 @@ -27,7 +27,8 @@ $versionExceptions = @( "WyHash.dll", "Microsoft.Recognizers.Text.DataTypes.TimexExpression.dll", "ObjectModelCsProjection.dll", - "RendererCsProjection.dll") -join '|'; + "RendererCsProjection.dll", + "Microsoft.ML.OnnxRuntime.dll") -join '|'; $nullVersionExceptions = @( "SkiaSharp.Views.WinUI.Native.dll", "libSkiaSharp.dll", 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 eb04903b7e..60567e30b8 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. --> - - - - - + + + @@ -116,7 +112,6 @@ - diff --git a/PowerToys.slnx b/PowerToys.slnx index 1884b2d58b..c985c76bcd 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -927,6 +927,14 @@ + + + + + + + + diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index d7ae386191..9184bb57b1 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -32,6 +32,7 @@ namespace ManagedCommon PowerAccent, RegistryPreview, MeasureTool, + ScreencastMode, ShortcutGuide, PowerOCR, Workspaces, diff --git a/src/common/SettingsAPI/SettingsAPI.vcxproj b/src/common/SettingsAPI/SettingsAPI.vcxproj index d09e33a334..a80ef204cd 100644 --- a/src/common/SettingsAPI/SettingsAPI.vcxproj +++ b/src/common/SettingsAPI/SettingsAPI.vcxproj @@ -1,7 +1,6 @@ - 16.0 {6955446D-23F7-4023-9BB3-8657F904AF99} @@ -40,9 +39,6 @@ Create - - - {cc6e41ac-8174-4e8a-8d22-85dd7f4851df} @@ -51,18 +47,21 @@ {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + - + 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/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/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index 881633e05e..68aefb273e 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -83,6 +83,8 @@ struct LogSettings inline const static std::wstring workspacesSnapshotToolLogPath = L"workspaces-snapshot-tool-log.log"; inline const static std::string zoomItLoggerName = "zoom-it"; inline const static std::string lightSwitchLoggerName = "light-switch"; + inline const static std::string screencastModeLoggerName = "screencast-mode"; + inline const static std::wstring screencastModeLogPath = L"screencast-mode-log.log"; inline const static int retention = 30; std::wstring logLevel; LogSettings(); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs index 80376e5f72..5bef6389f0 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs @@ -198,20 +198,14 @@ namespace AdvancedPaste.Pages } } - private async void ClipboardHistory_ItemInvoked(ItemsView sender, ItemsViewItemInvokedEventArgs args) + private void ClipboardHistory_ItemInvoked(ItemsView sender, ItemsViewItemInvokedEventArgs args) { - if (args.InvokedItem is ClipboardItem item) + if (args.InvokedItem is ClipboardItem item && item.Item is not null) { PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteClipboardItemClicked()); - if (!string.IsNullOrEmpty(item.Content)) - { - ClipboardHelper.SetTextContent(item.Content); - } - else if (item.Image is not null) - { - RandomAccessStreamReference image = await item.Item.Content.GetBitmapAsync(); - ClipboardHelper.SetImageContent(image); - } + + // Use SetHistoryItemAsContent to set the clipboard content without creating a new history entry + Clipboard.SetHistoryItemAsContent(item.Item); } } } diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp index 170dde5b0a..a5973a396f 100644 --- a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -50,6 +50,7 @@ enum class ScheduleMode Off, FixedHours, SunsetToSunrise, + FollowNightLight, // add more later }; @@ -61,6 +62,8 @@ inline std::wstring ToString(ScheduleMode mode) return L"SunsetToSunrise"; case ScheduleMode::FixedHours: return L"FixedHours"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; default: return L"Off"; } @@ -72,6 +75,8 @@ inline ScheduleMode FromString(const std::wstring& str) return ScheduleMode::SunsetToSunrise; if (str == L"FixedHours") return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; return ScheduleMode::Off; } @@ -167,7 +172,9 @@ public: ToString(g_settings.m_scheduleMode), { { L"Off", L"Disable the schedule" }, { L"FixedHours", L"Set hours manually" }, - { L"SunsetToSunrise", L"Use sunrise/sunset times" } }); + { L"SunsetToSunrise", L"Use sunrise/sunset times" }, + { L"FollowNightLight", L"Follow Windows Night Light state" } + }); // Integer spinners settings.add_int_spinner( diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index 845e24fa93..b6684da54e 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -13,10 +13,12 @@ #include #include "LightSwitchStateManager.h" #include +#include SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; HANDLE g_ServiceStopEvent = nullptr; +static LightSwitchStateManager* g_stateManagerPtr = nullptr; VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); @@ -168,7 +170,15 @@ static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateMan } // Use shared helper (handles wraparound logic) - bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + bool shouldBeLight = false; + if (s.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !IsNightLightEnabled(); + } + else + { + shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + } // Compare current system/apps theme bool currentSystemLight = GetCurrentSystemTheme(); @@ -199,15 +209,40 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) // Initialization // ──────────────────────────────────────────────────────────────── static LightSwitchStateManager stateManager; + g_stateManagerPtr = &stateManager; LightSwitchSettings::instance().InitFileWatcher(); HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent(); + static std::unique_ptr g_nightLightWatcher; + LightSwitchSettings::instance().LoadSettings(); const auto& settings = LightSwitchSettings::instance().settings(); + // after loading settings: + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + SYSTEMTIME st; GetLocalTime(&st); int nowMinutes = st.wHour * 60 + st.wMinute; @@ -274,6 +309,31 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) ResetEvent(hSettingsChanged); LightSwitchSettings::instance().LoadSettings(); stateManager.OnSettingsChanged(); + + const auto& settings = LightSwitchSettings::instance().settings(); + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + + stateManager.OnNightLightChange(); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + continue; } } @@ -285,6 +345,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) CloseHandle(hManualOverride); if (hParent) CloseHandle(hParent); + if (g_nightLightWatcher) + { + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } Logger::info(L"[LightSwitchService] Worker thread exiting cleanly."); return 0; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index a3a505f897..e1c8052de6 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -76,6 +76,7 @@ + @@ -88,6 +89,7 @@ + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters index 795df99aba..55c7bde39b 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -36,6 +36,9 @@ Source Files + + Source Files + @@ -62,6 +65,9 @@ Header Files + + Header Files + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h index d4029d072d..1d1c7953fe 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -19,7 +19,8 @@ enum class ScheduleMode { Off, FixedHours, - SunsetToSunrise + SunsetToSunrise, + FollowNightLight, // Add more in the future }; @@ -31,6 +32,8 @@ inline std::wstring ToString(ScheduleMode mode) return L"FixedHours"; case ScheduleMode::SunsetToSunrise: return L"SunsetToSunrise"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; default: return L"Off"; } @@ -42,6 +45,8 @@ inline ScheduleMode FromString(const std::wstring& str) return ScheduleMode::SunsetToSunrise; if (str == L"FixedHours") return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; else return ScheduleMode::Off; } diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp index 4fba4ae9a6..f562d38c41 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -31,7 +31,10 @@ void LightSwitchStateManager::OnSettingsChanged() void LightSwitchStateManager::OnTick(int currentMinutes) { std::lock_guard lock(_stateMutex); - EvaluateAndApplyIfNeeded(); + if (_state.lastAppliedMode != ScheduleMode::FollowNightLight) + { + EvaluateAndApplyIfNeeded(); + } } // Called when manual override is triggered @@ -49,8 +52,38 @@ void LightSwitchStateManager::OnManualOverride() _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).", - (_state.isSystemLightActive ? L"light" : L"dark"), - (_state.isAppsLightActive ? L"light" : L"dark")); + (_state.isSystemLightActive ? L"light" : L"dark"), + (_state.isAppsLightActive ? L"light" : L"dark")); + } + + EvaluateAndApplyIfNeeded(); +} + +// Runs with the registry observer detects a change in Night Light settings. +void LightSwitchStateManager::OnNightLightChange() +{ + std::lock_guard lock(_stateMutex); + + bool newNightLightState = IsNightLightEnabled(); + + // In Follow Night Light mode, treat a Night Light toggle as a boundary + if (_state.lastAppliedMode == ScheduleMode::FollowNightLight && _state.isManualOverride) + { + Logger::info(L"[LightSwitchStateManager] Night Light changed while manual override active; " + L"treating as a boundary and clearing manual override."); + _state.isManualOverride = false; + } + + if (newNightLightState != _state.isNightLightActive) + { + Logger::info(L"[LightSwitchStateManager] Night Light toggled to {}", + newNightLightState ? L"ON" : L"OFF"); + + _state.isNightLightActive = newNightLightState; + } + else + { + Logger::debug(L"[LightSwitchStateManager] Night Light change event fired, but no actual change."); } EvaluateAndApplyIfNeeded(); @@ -77,9 +110,9 @@ void LightSwitchStateManager::SyncInitialThemeState() _state.isSystemLightActive = GetCurrentSystemTheme(); _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})", - _state.isSystemLightActive ? L"light" : L"dark"); + _state.isSystemLightActive ? L"light" : L"dark"); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})", - _state.isAppsLightActive ? L"light" : L"dark"); + _state.isAppsLightActive ? L"light" : L"dark"); } static std::pair update_sun_times(auto& settings) @@ -194,7 +227,15 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastAppliedMode = _currentSettings.scheduleMode; - bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + bool shouldBeLight = false; + if (_currentSettings.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !_state.isNightLightActive; + } + else + { + shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + } bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight); bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight); @@ -227,6 +268,3 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastTickMinutes = now; } - - - diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h index 5c9bcc6e25..c4f39a2e9a 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -9,6 +9,7 @@ struct LightSwitchState bool isManualOverride = false; bool isSystemLightActive = false; bool isAppsLightActive = false; + bool isNightLightActive = false; int lastEvaluatedDay = -1; int lastTickMinutes = -1; @@ -32,6 +33,9 @@ public: // Called when manual override is toggled (via shortcut or system change). void OnManualOverride(); + // Called when night light changes in windows settings + void OnNightLightChange(); + // Initial sync at startup to align internal state with system theme void SyncInitialThemeState(); diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp new file mode 100644 index 0000000000..8da19c6595 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp @@ -0,0 +1 @@ +#include "NightLightRegistryObserver.h" diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h new file mode 100644 index 0000000000..2806c28316 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h @@ -0,0 +1,134 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class NightLightRegistryObserver +{ +public: + NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function callback) : + _root(root), _subkey(subkey), _callback(std::move(callback)), _stop(false) + { + _thread = std::thread([this]() { this->Run(); }); + } + + ~NightLightRegistryObserver() + { + Stop(); + } + + void Stop() + { + _stop = true; + + { + std::lock_guard lock(_mutex); + if (_event) + SetEvent(_event); + } + + if (_thread.joinable()) + _thread.join(); + + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + + +private: + void Run() + { + { + std::lock_guard lock(_mutex); + if (RegOpenKeyExW(_root, _subkey.c_str(), 0, KEY_NOTIFY, &_hKey) != ERROR_SUCCESS) + return; + + _event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!_event) + { + RegCloseKey(_hKey); + _hKey = nullptr; + return; + } + } + + while (!_stop) + { + HKEY hKeyLocal = nullptr; + HANDLE eventLocal = nullptr; + + { + std::lock_guard lock(_mutex); + if (_stop) + break; + + hKeyLocal = _hKey; + eventLocal = _event; + } + + if (!hKeyLocal || !eventLocal) + break; + + if (_stop) + break; + + if (RegNotifyChangeKeyValue(hKeyLocal, FALSE, REG_NOTIFY_CHANGE_LAST_SET, eventLocal, TRUE) != ERROR_SUCCESS) + break; + + DWORD wait = WaitForSingleObject(eventLocal, INFINITE); + if (_stop || wait == WAIT_FAILED) + break; + + ResetEvent(eventLocal); + + if (!_stop && _callback) + { + try + { + _callback(); + } + catch (...) + { + } + } + } + + { + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + } + + + HKEY _root; + std::wstring _subkey; + std::function _callback; + HANDLE _event = nullptr; + HKEY _hKey = nullptr; + std::thread _thread; + std::atomic _stop; + std::mutex _mutex; +}; \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h index 4872864eff..8015c9b3e6 100644 --- a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h @@ -11,4 +11,7 @@ enum class SettingId Sunset_Offset, ChangeSystem, ChangeApps -}; \ No newline at end of file +}; + +constexpr wchar_t PERSONALIZATION_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr wchar_t NIGHT_LIGHT_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.bluelightreduction.bluelightreductionstate\\windows.data.bluelightreduction.bluelightreductionstate"; diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp index 9633ab2fde..cfa858c636 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp @@ -3,6 +3,7 @@ #include #include #include "ThemeHelper.h" +#include // Controls changing the themes. @@ -10,7 +11,7 @@ static void ResetColorPrevalence() { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -31,7 +32,7 @@ void SetAppsTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -50,7 +51,7 @@ void SetSystemTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -79,7 +80,7 @@ bool GetCurrentSystemTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -98,7 +99,7 @@ bool GetCurrentAppsTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -109,3 +110,30 @@ bool GetCurrentAppsTheme() return value == 1; // true = light, false = dark } + +bool IsNightLightEnabled() +{ + HKEY hKey; + const wchar_t* path = NIGHT_LIGHT_REGISTRY_PATH; + + if (RegOpenKeyExW(HKEY_CURRENT_USER, path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return false; + + // RegGetValueW will set size to the size of the data and we expect that to be at least 25 bytes (we need to access bytes 23 and 24) + DWORD size = 0; + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, nullptr, &size) != ERROR_SUCCESS || size < 25) + { + RegCloseKey(hKey); + return false; + } + + std::vector data(size); + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, data.data(), &size) != ERROR_SUCCESS) + { + RegCloseKey(hKey); + return false; + } + + RegCloseKey(hKey); + return data[23] == 0x10 && data[24] == 0x00; +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h index 5985fd95c8..e8d45e9c2a 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h @@ -3,3 +3,4 @@ void SetSystemTheme(bool dark); void SetAppsTheme(bool dark); bool GetCurrentSystemTheme(); bool GetCurrentAppsTheme(); +bool IsNightLightEnabled(); \ No newline at end of file diff --git a/src/modules/MeasureTool/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/ScreencastMode/README.md b/src/modules/ScreencastMode/README.md new file mode 100644 index 0000000000..3ce72c6345 --- /dev/null +++ b/src/modules/ScreencastMode/README.md @@ -0,0 +1,210 @@ + +# Microsoft PowerToys - Screencast Mode + +## What is Screencast Mode + +Screencast Mode is a new utility within PowerToys that allows users to Visualize their Keystrokes. The module and its design was influenced by [Issue 981 from the PowerToys Repository](https://github.com/microsoft/PowerToys/issues/981). + +## Features +- Visualize Keystrokes in an On-Screen Overlay +- Customizable Overlay Position +- Customizable Overlay Text Size +- Customizable Overlay Background and Text Color +- Preview Window to see changes in real-time +- Enable/Disable Overlay with a Keyboard Shortcut + + +## Known Bugs/Notes: +- The size of the text in the preview window is not entirely accurate when compared to the overlay text size +- When an application is maximized from the taskbar, the overlay window might go behind it. This can be fixed by restarting the overlay +- Currently the "Learn More..." on the Screencast Mode settings page links to the general PowerToys page +- The "Welcome to PowerToys" page does not include ScreencastMode +- There are currently no Unit Tests set up for Screencast Mode + +## Implementation Details + +### The Screencast Mode Settings +Much like the other PowerToys modules, Screencast Mode's Settings are implemented in the `Settings.UI` and `Settings.UI.Library` folders. In the Settings.UI.Library, there are the `ScreencastModeSettings` and `ScreencastModeProperties` classes. The `ScreencastModeSettings` implements `ISettingsConfig` Interface and inherits from `BasePTModuleSettings`, primarily to set up the JSON serialization. The `ScreencastModeProperties` class holds the actual settings that are bound to the UI elements in the Settings.UI project, and also sets up their default values. + +In the `Views` folder of the `Settings.UI` project, there is the `ScreencastModePage.xaml` file which contains the XAML code for the settings page, and the `ScreencastModePage.xaml.cs` file which contains the code-behind for the settings page. The code-behind file was kept minimal. We also modifed the `ShellPage.xaml` file to add a navigation item for the Screencast Mode settings page. To add all the buttons and descriptions, we had to add elements to `Resources.resw` file. We tried to follow convention as much as possible when naming the resources. + +Last but not at least, we designed assets for Screencast Mode, which can be found in the `Settings.UI/Icons` folder and the preview image is in the `Settings.UI/Modules` folder. + +### The Screencast Mode Overlay + +The overlay is implemented as a WinUI 3 application in the `ScreencastModeUI` project. + +#### Project Structure + +| File/Folder | Purpose | +|-------------|---------| +| `App.xaml` / `App.xaml.cs` |Entry point and sets up the Logger | +| `MainWindow.xaml` / `MainWindow.xaml.cs` | Overlay window UI and display logic | +| `Keyboard/KeyboardListener.cs` | Low-level keyboard hook to capture system-wide keystrokes | +| `Keyboard/KeyboardEventArgs.cs` | Event arguments for keyboard events | +| `Keyboard/KeyDisplayer.cs` | Formats and manages the display of captured keystrokes | +| `Assets/ScreencastMode/` | Module icons and visual assets | + +#### Architecture + +1. **Keyboard Capture**: `KeyboardListener` uses Windows low-level keyboard hooks to intercept keystrokes globally, even when other applications have focus. We don't believe Keystroke capture is possible without importing the DLLs from Win32. We took inspiration from [an old blog post on making a low level keyboard hook in C#](https://learn.microsoft.com/en-us/archive/blogs/toub/low-level-keyboard-hook-in-c). + +2. **Key Processing**: `KeyDisplayer` receives raw key events and converts them into human-readable text (handling modifiers like Ctrl, Alt, Shift, and special keys). The events are recieved via the Virtual Key Codes, which are then translated to their string representations. + +3. **Overlay Window**: `MainWindow` renders an always-on-top, click-through window that displays the formatted keystrokes. It gets the settings from the Settings.JSON using methods that are similar to those used in other PowerToys modules. We had to add Win32 APIs here so that the window would not show up as an application on Task Manager, would be click through, and would stay on top of other windows. + + +#### KeyDisplayer Implementation Details + +The `KeyDisplayer` class (`Keyboard/KeyDisplayer.cs`) manages keystroke state and +builds display strings optimized for on-screen presentation. + +**State Tracking:** +| Field | Type | Purpose | +|-------|------|---------| +| `_displayedKeys` | `List` | Ordered list of keys/separators to display | +| `_activeModifiers` | `HashSet` | Currently held modifier keys | +| `_needsPlusSeparator` | `bool` | Whether next key needs a `+` prefix | + +**Key Processing Flow:** + +``` +KeyDown Event + │ + ├─► Modifier Key? ──► Normalize (LeftShift → Shift) + │ └─► Add to _activeModifiers if new + │ └─► Append to display with "+" + │ + ├─► Clear Key (Backspace/Esc)? ──► Clear display, show key alone + │ + └─► Regular Key ──► Check overflow (>40 chars) + └─► If overflow: clear but re-add held modifiers + └─► Append key with "+" if modifiers active +``` + +**Modifier Normalization:** +Left/right modifier variants are normalized to generic forms for cleaner display: +- `LeftShift` / `RightShift` → `Shift` +- `LeftControl` / `RightControl` → `Control` +- `LeftMenu` / `RightMenu` → `Menu` (Alt) +- `LeftWindows` / `RightWindows` → `LeftWindows` + +**Display Name Mapping:** +Keys are mapped to short, readable names optimized for readability: +- Unknown keys fall back to `PowerToys.Interop.LayoutMapManaged`; This is great for graceful handling in case some keys were missed during implementation. We decied not to use this for every key because some of the names are too long for screencast display. + +**Overflow Handling:** +When display text exceeds ~40 characters, the display clears but preserves +currently held modifiers. This ensures continuous typing doesn't create an +unreadable string while maintaining modifier context. In the future, it is would be great to make the max character/word count configurable. + +**Event Model:** +The `DisplayUpdated` event fires after each key-down, allowing the UI to refresh +via data binding or direct subscription. + + +#### Dependencies + +- WinUI 3 / Windows App SDK +- Shared PowerToys libraries (`ManagedCommon`, `Settings.UI.Library`) +- Windows low-level keyboard hooks (Win32 interop) + + +### Screencast Mode Module Interface + +The module interface (`ScreencastModeModuleInterface`) is a C++ DLL that integrates +Screencast Mode with the PowerToys Runner. It follows the standard PowerToys module +pattern by implementing `PowertoyModuleIface`. + +#### Project Structure + +| File | Purpose | +|------|---------| +| `dllmain.cpp` | Module implementation and `PowertoyModuleIface` | + +There trace provide and precompiled header files folow standard PowerToys conventions, and we did not change them much. + +#### Module Lifecycle + +``` +PowerToys Runner + │ + ├─► LoadLibrary() ──► DllMain(DLL_PROCESS_ATTACH) + │ └─► Trace::RegisterProvider() + │ + ├─► powertoy_create() ──► new ScreencastMode() + │ └─► init_settings() + │ └─► parse_hotkey() + │ + ├─► enable() ──► Launch UI process (skipped on first enable/startup) + │ + ├─► on_hotkey() ──► Toggle overlay visibility + │ + ├─► disable() ──► Terminate UI process + │ + └─► destroy() ──► Cleanup and delete +``` + +#### Key Implementation Details + +**Module Registration:** +The DLL exports `powertoy_create()` which the Runner calls to instantiate the module: + +```cpp +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new ScreencastMode(); +} +``` + +**Settings & Hotkey Parsing:** +Settings are loaded from the standard PowerToys settings JSON file using +`PowerToysSettings::PowerToyValues`. The hotkey configuration is parsed from: + +```json +{ + "properties": { + "ScreencastModeShortcut": { + "win": true, + "alt": true, + "ctrl": false, + "shift": false, + "code": 83 + } + } +} +``` + +Default hotkey: `Win + Alt + S` (0x53 = 'S') + +**Process Management:** +The module spawns the WinUI 3 overlay as a separate process: + +| Method | Behavior | +|--------|----------| +| `launch_process()` | Starts `WinUI3Apps\PowerToys.ScreencastModeUI.exe` via `ShellExecuteExW` | +| `terminate_process()` | Graceful shutdown with 1s timeout, then `TerminateProcess` | +| `is_viewer_running()` | Checks process handle with `WaitForSingleObject` | + +The Runner's PID is passed as a command-line argument to the UI process. + +**First-Enable Behavior:** +The overlay does NOT launch automatically when PowerToys starts. The `m_firstEnable` +flag ensures `enable()` only shows the overlay on subsequent toggles from Settings, +not on initial startup. Users must press the hotkey to show the overlay. We decided that it would be a better user experience this way, as some users may not want the overlay to show up immediately on startup. + +**Hotkey Toggle:** +`on_hotkey()` toggles visibility — if the overlay is running, it terminates the +process; otherwise, it launches it. The hotkey only works when the module is +enabled via Settings. + +#### Dependencies + +- `interface/powertoy_module_interface.h` — Base interface +- `common/SettingsAPI/settings_objects.h` — Settings parsing +- `common/logger/logger.h` — Logging infrastructure +- `common/utils/process_path.h`, `winapi_error.h` — Win32 utilities + +#### Future Work + +- We did not get a chance to implement the GPO support for Screencast Mode. This work would mostly involve adding the appropriate checks in the `enable()` method of the `ScreencastMode` class in `dllmain.cpp`. There is a commented out piece of code in `dllmain.cpp` regarding the GPO. We see no reason to implement GPO for each individual setting, as Screencast Mode is a fairly simple module. diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.rc b/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.rc new file mode 100644 index 0000000000..af288a92d3 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.rc @@ -0,0 +1,32 @@ +1 VERSIONINFO + FILEVERSION 0,1,0,0 + PRODUCTVERSION 0,1,0,0 + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Company Name" + VALUE "FileDescription", "$projectname$ Module" + VALUE "FileVersion", "0.1.0.0" + VALUE "InternalName", "$projectname$" + VALUE "LegalCopyright", "Copyright (C) 2019 Company Name" + VALUE "OriginalFilename", "$projectname$.dll" + VALUE "ProductName", "$projectname$" + VALUE "ProductVersion", "0.1.0.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END \ No newline at end of file diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.vcxproj b/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.vcxproj new file mode 100644 index 0000000000..5861a40c21 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.vcxproj @@ -0,0 +1,145 @@ + + + + + + Debug + x64 + + + Release + x64 + + + + 15.0 + {ff038541-2c59-4ee2-a52f-a2e94863d517} + Win32Proj + ScreencastModeModuleInterface + 10.0 + ScreencastModeModuleInterface + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\ + PowerToys.ScreencastModeModuleInterface + + + true + + + false + + + + Use + Level3 + Disabled + true + _DEBUG;SCREENCASTMODE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreadedDebug + stdcpplatest + $(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories) + /wd26493 %(AdditionalOptions) + + + Windows + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + Use + Level3 + MaxSpeed + true + true + true + NDEBUG;SCREENCASTMODE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + MultiThreaded + stdcpplatest + $(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories) + /wd26493 %(AdditionalOptions) + + + Windows + true + true + true + $(OutDir)$(TargetName)$(TargetExt) + + + + + + + + + + + Create + Create + pch.h + pch.h + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + + + + + 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/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.vcxproj.filters b/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.vcxproj.filters new file mode 100644 index 0000000000..e3107f403e --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/ScreencastModeModuleInterface.vcxproj.filters @@ -0,0 +1,50 @@ + + + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Generated Files + + + + + + + + {26a9062e-ee17-4d08-8bdb-080a98a6be4b} + + + {3933f1db-ceff-4665-aee8-a0023705a7fc} + + + {5bfb672c-91b7-4ade-8d85-df39ceab1702} + + + {24f00dd5-0826-4972-b5ce-1098bf5d2688} + + + + + Resource Files + + + + + + \ No newline at end of file diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/dllmain.cpp b/src/modules/ScreencastMode/ScreencastModeModuleInterface/dllmain.cpp new file mode 100644 index 0000000000..e70771f0d2 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/dllmain.cpp @@ -0,0 +1,294 @@ +#include "pch.h" +#include +#include +#include "trace.h" +#include +#include +#include +#include +#include +#include + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_WIN[] = L"win"; + const wchar_t JSON_KEY_ALT[] = L"alt"; + const wchar_t JSON_KEY_CTRL[] = L"ctrl"; + const wchar_t JSON_KEY_SHIFT[] = L"shift"; + const wchar_t JSON_KEY_CODE[] = L"code"; + const wchar_t JSON_KEY_HOTKEY[] = L"ScreencastModeShortcut"; + const wchar_t JSON_KEY_VALUE[] = L"value"; +} + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +const static wchar_t* MODULE_NAME = L"Screencast Mode"; +const static wchar_t* MODULE_KEY = L"ScreencastMode"; +const static wchar_t* MODULE_DESC = L"Visualize keystrokes for recordings and presentations."; + +// Implement the PowerToy Module Interface and all the required methods. +class ScreencastMode : public PowertoyModuleIface +{ +private: + bool m_enabled = false; + bool m_overlayVisible = false; + bool m_firstEnable = true; + HANDLE m_hProcess = nullptr; + DWORD m_processPid = 0; + Hotkey m_hotkey; + + void parse_hotkey(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + auto jsonPropsObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (jsonPropsObject.HasKey(JSON_KEY_HOTKEY)) + { + auto jsonHotkeyObject = jsonPropsObject.GetNamedObject(JSON_KEY_HOTKEY); + m_hotkey.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN); + m_hotkey.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT); + m_hotkey.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); + m_hotkey.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); + m_hotkey.key = static_cast(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + + Logger::info("ScreencastMode hotkey loaded: win={}, alt={}, ctrl={}, shift={}, key={}", + m_hotkey.win, m_hotkey.alt, m_hotkey.ctrl, m_hotkey.shift, m_hotkey.key); + } + else + { + Logger::info("ScreencastMode hotkey not found in settings, using defaults"); + set_default_hotkey(); + } + } + catch (const winrt::hresult_error& e) + { + Logger::error(L"Failed to parse ScreencastMode hotkey: {}", e.message().c_str()); + set_default_hotkey(); + } + catch (...) + { + Logger::error("Failed to initialize ScreencastMode hotkey (unknown error)"); + set_default_hotkey(); + } + } + else + { + Logger::info("ScreencastMode settings are empty, using default hotkey"); + set_default_hotkey(); + } + } + + void set_default_hotkey() + { + // Default: Win + Alt + S + m_hotkey.win = true; + m_hotkey.alt = true; + m_hotkey.shift = false; + m_hotkey.ctrl = false; + m_hotkey.key = 0x53; + } + + void init_settings() + { + try + { + auto settings = PowerToysSettings::PowerToyValues::load_from_settings_file(get_key()); + parse_hotkey(settings); + settings.save_to_settings_file(); + } + catch (std::exception& e) + { + Logger::error("Failed to load settings file: {}", e.what()); + set_default_hotkey(); + } + } + + bool is_viewer_running() + { + return m_hProcess && WaitForSingleObject(m_hProcess, 0) == WAIT_TIMEOUT; + } + + void launch_process() + { + if (is_viewer_running()) + { + return; + } + + Logger::trace(L"Starting ScreencastMode UI process"); + unsigned long powertoys_pid = GetCurrentProcessId(); + std::wstring executable_args = std::to_wstring(powertoys_pid); + + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI; + sei.lpFile = L"WinUI3Apps\\PowerToys.ScreencastModeUI.exe"; + sei.nShow = SW_SHOWNORMAL; + sei.lpParameters = executable_args.data(); + if (ShellExecuteExW(&sei)) + { + m_hProcess = sei.hProcess; + m_processPid = GetProcessId(m_hProcess); + m_overlayVisible = true; + Logger::trace("Successfully started ScreencastMode UI process"); + } + else + { + Logger::error(L"ScreencastMode UI failed to start. {}", get_last_error_or_default(GetLastError())); + } + } + + void terminate_process() + { + if (m_hProcess) + { + HANDLE hProcess = OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, m_processPid); + if (hProcess) + { + if (WaitForSingleObject(hProcess, 1000) == WAIT_TIMEOUT) + { + TerminateProcess(hProcess, 1); + } + CloseHandle(hProcess); + } + CloseHandle(m_hProcess); + m_hProcess = nullptr; + m_processPid = 0; + m_overlayVisible = false; + } + } + +public: + ScreencastMode() + { + LoggerHelpers::init_logger(MODULE_KEY, L"ModuleInterface", LogSettings::screencastModeLoggerName); + init_settings(); + }; + + virtual void destroy() override + { + if (m_enabled) + { + terminate_process(); + } + delete this; + } + + virtual const wchar_t* get_name() override { return MODULE_NAME; } + virtual const wchar_t* get_key() override { return MODULE_KEY; } + + /* virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredScreencastModeEnabledValue(); + }*/ + + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + PowerToysSettings::Settings settings(hinstance, get_name()); + settings.set_description(MODULE_DESC); + return settings.serialize_to_buffer(buffer, buffer_size); + } + + virtual void set_config(const wchar_t* config) override + { + try + { + auto values = PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + parse_hotkey(values); + values.save_to_settings_file(); + } + catch (std::exception&) + { + } + } + + virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override + { + if (m_hotkey.key) + { + if (hotkeys && buffer_size >= 1) + { + hotkeys[0] = m_hotkey; + } + return 1; + } + return 0; + } + + virtual bool on_hotkey(size_t /*hotkeyId*/) override + { + // Hotkey only works when the module is enabled via settings + if (!m_enabled) + { + return false; + } + + // Toggle overlay visibility + Logger::trace(L"ScreencastMode hotkey pressed, toggling overlay visibility"); + if (m_overlayVisible && is_viewer_running()) + { + Logger::trace(L"Hiding ScreencastMode overlay"); + terminate_process(); + } + else + { + Logger::trace(L"Showing ScreencastMode overlay"); + launch_process(); + } + return true; + } + + virtual void enable() override + { + Logger::trace(L"ScreencastMode enabled"); + m_enabled = true; + + // Don't show the overlay on powertoys startup + if (!m_firstEnable) + { + launch_process(); + } + else + { + m_firstEnable = false; + } + + Trace::ScreencastModeEnabled(true); + } + + virtual void disable() override + { + Logger::trace(L"ScreencastMode disabled"); + m_enabled = false; + terminate_process(); + Trace::ScreencastModeEnabled(false); + } + + virtual bool is_enabled() override { return m_enabled; } +}; + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new ScreencastMode(); +} \ No newline at end of file diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/packages.config b/src/modules/ScreencastMode/ScreencastModeModuleInterface/packages.config new file mode 100644 index 0000000000..ce3449eca9 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/pch.cpp b/src/modules/ScreencastMode/ScreencastModeModuleInterface/pch.cpp new file mode 100644 index 0000000000..a83d3bb2cc --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/pch.cpp @@ -0,0 +1,2 @@ +#include "pch.h" +#pragma comment(lib, "windowsapp") \ No newline at end of file diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/pch.h b/src/modules/ScreencastMode/ScreencastModeModuleInterface/pch.h new file mode 100644 index 0000000000..39f8f4ac84 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/pch.h @@ -0,0 +1,14 @@ +#pragma once +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/resource.h b/src/modules/ScreencastMode/ScreencastModeModuleInterface/resource.h new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/trace.cpp b/src/modules/ScreencastMode/ScreencastModeModuleInterface/trace.cpp new file mode 100644 index 0000000000..3dac8c1883 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/trace.cpp @@ -0,0 +1,28 @@ +#include "pch.h" +#include "trace.h" +#include + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::ScreencastModeEnabled(bool enabled) +{ + TraceLoggingWrite( + g_hProvider, + "ScreencastMode_Enabled", + TraceLoggingValue(enabled, "Enabled")); +} diff --git a/src/modules/ScreencastMode/ScreencastModeModuleInterface/trace.h b/src/modules/ScreencastMode/ScreencastModeModuleInterface/trace.h new file mode 100644 index 0000000000..477d2b9fbb --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeModuleInterface/trace.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +TRACELOGGING_DECLARE_PROVIDER(g_hProvider); + +class Trace +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + + // Screencast Mode enabled/disabled state change + static void ScreencastModeEnabled(bool enabled); +}; diff --git a/src/modules/ScreencastMode/ScreencastModeUI/App.xaml b/src/modules/ScreencastMode/ScreencastModeUI/App.xaml new file mode 100644 index 0000000000..f5820c40b9 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/App.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/src/modules/ScreencastMode/ScreencastModeUI/App.xaml.cs b/src/modules/ScreencastMode/ScreencastModeUI/App.xaml.cs new file mode 100644 index 0000000000..8f2bdeee6c --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/App.xaml.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 System; +using ManagedCommon; +using Microsoft.UI.Xaml; +using PowerToys.Interop; + +namespace ScreencastModeUI +{ + /// + /// Provides application-specific behavior to supplement the default Application class. + /// + public partial class App : Application + { + private Window? _window; + + public App() + { + InitializeComponent(); + Logger.InitializeLogger("\\ScreencastMode\\ScreencastModeUI\\Logs"); + } + + protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + _window = new MainWindow(); + _window.Activate(); + + // Exit when PowerToys Runner exits (pattern from Peek) + var cmdArgs = Environment.GetCommandLineArgs(); + if (cmdArgs?.Length > 1 && int.TryParse(cmdArgs[^1], out int runnerPid)) + { + RunnerHelper.WaitForPowerToysRunner(runnerPid, () => Environment.Exit(0)); + } + } + } +} diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Icon.ico b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Icon.ico new file mode 100644 index 0000000000..d747207682 Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Icon.ico differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/LockScreenLogo.scale-200.png b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/LockScreenLogo.scale-200.png new file mode 100644 index 0000000000..7440f0d4bf Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/LockScreenLogo.scale-200.png differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/SplashScreen.scale-200.png b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/SplashScreen.scale-200.png new file mode 100644 index 0000000000..32f486a867 Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/SplashScreen.scale-200.png differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square150x150Logo.scale-200.png b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square150x150Logo.scale-200.png new file mode 100644 index 0000000000..53ee3777ea Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square150x150Logo.scale-200.png differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square44x44Logo.scale-200.png b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square44x44Logo.scale-200.png new file mode 100644 index 0000000000..f713bba67f Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square44x44Logo.scale-200.png differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square44x44Logo.targetsize-24_altform-unplated.png b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000000..dc9f5bea0c Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/StoreLogo.png b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/StoreLogo.png new file mode 100644 index 0000000000..a4586f26bd Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/StoreLogo.png differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Wide310x150Logo.scale-200.png b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000000..8b4a5d0dd5 Binary files /dev/null and b/src/modules/ScreencastMode/ScreencastModeUI/Assets/ScreencastMode/Wide310x150Logo.scale-200.png differ diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyDisplayer.cs b/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyDisplayer.cs new file mode 100644 index 0000000000..47bfe88d8f --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyDisplayer.cs @@ -0,0 +1,445 @@ +// 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.Text; +using ManagedCommon; +using Windows.System; + +namespace ScreencastModeUI.Keyboard +{ + /// + /// Manages keystroke display for screencast overlays. + /// Tracks key state and builds display strings optimized for on-screen presentation. + /// + internal sealed class KeyDisplayer + { + private static readonly Lazy _layoutMap = + new Lazy(() => new PowerToys.Interop.LayoutMapManaged()); + + // Track displayed keys in order where each entry is a display string + private readonly List _displayedKeys = new(); + + // Track currently held modifiers + private readonly HashSet _activeModifiers = new(); + + // Flag to track if we need to add "+" before the next key + private bool _needsPlusSeparator; + + /// + /// Event raised when the display text has been updated and the UI should refresh. + /// + public event EventHandler? DisplayUpdated; + + /// + /// Gets the current display text for the keystroke overlay. + /// + public string DisplayText => BuildDisplayText(); + + /// + /// Gets a value indicating whether there is content to display. + /// + public bool HasContent => _displayedKeys.Count > 0; + + /// + /// Processes a key event (key down or key up). + /// + /// The virtual key. + /// True if key is pressed; false if released. + public void ProcessKeyEvent(VirtualKey key, bool isKeyDown) + { + if (isKeyDown) + { + HandleKeyDown(key); + } + else + { + HandleKeyUp(key); + } + } + + /// + /// Clears all tracked keys and modifiers. + /// + public void Clear() + { + _displayedKeys.Clear(); + _activeModifiers.Clear(); + _needsPlusSeparator = false; + } + + /// + /// Handle when a key is being pressed. + /// + /// The key that is currently being held down. + private void HandleKeyDown(VirtualKey key) + { + // Normalize modifier keys (e.g., LeftShift -> Shift) + var normalizedKey = IsModifierKey(key) + ? NormalizeModifierKey(key) + : key; + + // Handle modifier keys + if (IsModifierKey(key)) + { + // Only add modifier if not already held (Add returns false if already present) + if (_activeModifiers.Add(normalizedKey)) + { + var keyName = GetKeyDisplayName(normalizedKey); + + // Check if adding would overflow + string previewText = BuildPreviewText(keyName); + if (WillOverflow(previewText)) + { + // Clear and start fresh with just this modifier + _displayedKeys.Clear(); + _needsPlusSeparator = false; + } + + // Add "+" if we already have content and need separator + if (_needsPlusSeparator && _displayedKeys.Count > 0) + { + _displayedKeys.Add("+"); + } + + _displayedKeys.Add(keyName); + + // Next key should have a "+" before it + _needsPlusSeparator = true; + } + } + + // Backspace and Escape keys clear the current display + else if (IsClearKey(key)) + { + // Clear keys (Backspace, Esc) - clear and show just this key + _displayedKeys.Clear(); + _activeModifiers.Clear(); + _needsPlusSeparator = false; + + _displayedKeys.Add(GetKeyDisplayName(normalizedKey)); + _needsPlusSeparator = false; // Clear keys don't expect continuation + } + else + { + // Regular key + var keyName = GetKeyDisplayName(normalizedKey); + + // Check if adding would overflow + string previewText = BuildPreviewText(keyName); + if (WillOverflow(previewText)) + { + // Clear and start fresh - but keep active modifiers shown + _displayedKeys.Clear(); + _needsPlusSeparator = false; + + // Re-add currently held modifiers + foreach (var mod in _activeModifiers) + { + if (_displayedKeys.Count > 0) + { + _displayedKeys.Add("+"); + } + + _displayedKeys.Add(GetKeyDisplayName(mod)); + } + + if (_displayedKeys.Count > 0) + { + _needsPlusSeparator = true; + } + } + + // Add "+" if we have modifiers held or previous content + if (_needsPlusSeparator && _displayedKeys.Count > 0) + { + _displayedKeys.Add("+"); + } + + _displayedKeys.Add(keyName); + + // If modifiers are still held, next key should have "+" + // If no modifiers, this is a standalone key, so start fresh next time + _needsPlusSeparator = _activeModifiers.Count > 0; + } + + DisplayUpdated?.Invoke(this, EventArgs.Empty); + } + + /// + /// Handle key release events. + /// + /// The key that is released. + private void HandleKeyUp(VirtualKey key) + { + if (IsModifierKey(key)) + { + var normalizedKey = NormalizeModifierKey(key); + _activeModifiers.Remove(normalizedKey); + + // When all modifiers are released, reset the separator flag + // This allows the next keystroke to start a new sequence + if (_activeModifiers.Count == 0) + { + _needsPlusSeparator = false; + } + } + } + + /// + /// Builds the display text from the displayed keys list. + /// Keys are shown in the exact order they were added. + /// + private string BuildDisplayText() + { + if (_displayedKeys.Count == 0) + { + return string.Empty; + } + + // Join with spaces for visual separation, but "+" entries are already in the list + var result = new StringBuilder(); + foreach (var part in _displayedKeys) + { + if (part == "+") + { + // Add space before and after the plus for readability + result.Append(" + "); + } + else + { + if (result.Length > 0 && !result.ToString().EndsWith(' ')) + { + // Only add space if not coming right after a "+" + // Check if last thing added was " + " + if (!result.ToString().EndsWith("+ ", StringComparison.Ordinal)) + { + result.Append(' '); + } + } + + result.Append(part); + } + } + + return result.ToString().Trim(); + } + + /// + /// Builds a preview of what the display text would look like if we add a new key. + /// + private string BuildPreviewText(string newKey) + { + var tempList = new List(_displayedKeys); + if (_needsPlusSeparator && tempList.Count > 0) + { + tempList.Add("+"); + } + + tempList.Add(newKey); + + var result = new StringBuilder(); + foreach (var part in tempList) + { + if (part == "+") + { + result.Append(" + "); + } + else + { + if (result.Length > 0 && !result.ToString().EndsWith(' ')) + { + if (!result.ToString().EndsWith("+ ", StringComparison.Ordinal)) + { + result.Append(' '); + } + } + + result.Append(part); + } + } + + return result.ToString().Trim(); + } + + private static bool WillOverflow(string nextText) + { + // Rough width check using character count vs. a max visible chars estimate + const int maxVisibleChars = 40; + return nextText.Length > maxVisibleChars; + } + + /// + /// Gets a user-friendly display name for the specified virtual key. + /// + /// The virtual key to get the display name for. + /// A short, readable display name optimized for on-screen display. + public static string GetKeyDisplayName(VirtualKey key) + { + // For screencast mode, we use custom short names optimized for on-screen display + // These override the LayoutMap names for better readability during presentations + return key switch + { + // Modifier keys - keep short for screen display + VirtualKey.LeftWindows or VirtualKey.RightWindows => "Win", + VirtualKey.Control => "Ctrl", + VirtualKey.Menu => "Alt", + VirtualKey.Shift => "Shift", + + // Special keys with symbols for compact display + VirtualKey.Up => "↑", + VirtualKey.Down => "↓", + VirtualKey.Left => "←", + VirtualKey.Right => "→", + + // Common keys with shortened names + VirtualKey.Space => "Space", + VirtualKey.Enter => "Enter", + VirtualKey.Tab => "Tab", + VirtualKey.Back => "Backspace", + VirtualKey.Escape => "Esc", + VirtualKey.Delete => "Del", + VirtualKey.PageUp => "PgUp", + VirtualKey.PageDown => "PgDn", + VirtualKey.Home => "Home", + VirtualKey.End => "End", + VirtualKey.Insert => "Ins", + + // Numpad + VirtualKey.NumberPad0 => "Num 0", + VirtualKey.NumberPad1 => "Num 1", + VirtualKey.NumberPad2 => "Num 2", + VirtualKey.NumberPad3 => "Num 3", + VirtualKey.NumberPad4 => "Num 4", + VirtualKey.NumberPad5 => "Num 5", + VirtualKey.NumberPad6 => "Num 6", + VirtualKey.NumberPad7 => "Num 7", + VirtualKey.NumberPad8 => "Num 8", + VirtualKey.NumberPad9 => "Num 9", + + // F-keys + VirtualKey.F1 => "F1", + VirtualKey.F2 => "F2", + VirtualKey.F3 => "F3", + VirtualKey.F4 => "F4", + VirtualKey.F5 => "F5", + VirtualKey.F6 => "F6", + VirtualKey.F7 => "F7", + VirtualKey.F8 => "F8", + VirtualKey.F9 => "F9", + VirtualKey.F10 => "F10", + VirtualKey.F11 => "F11", + VirtualKey.F12 => "F12", + + // Letters A-Z - these will be uppercase regardless of keyboard layout + >= VirtualKey.A and <= VirtualKey.Z => ((char)('A' + ((int)key - (int)VirtualKey.A))).ToString(), + + // Numbers 0-9 - semantic meaning, not keyboard layout dependent + >= VirtualKey.Number0 and <= VirtualKey.Number9 => ((char)('0' + ((int)key - (int)VirtualKey.Number0))).ToString(), + + // OEM keys - use hardcoded US layout for consistency in screencasts + // This ensures viewers see the semantic key regardless of presenter's keyboard + (VirtualKey)0xBD => "-", // VK_OEM_MINUS + (VirtualKey)0xBB => "=", // VK_OEM_PLUS + (VirtualKey)0xDB => "[", // VK_OEM_4 + (VirtualKey)0xDD => "]", // VK_OEM_6 + (VirtualKey)0xDC => "\\", // VK_OEM_5 + (VirtualKey)0xBA => ";", // VK_OEM_1 + (VirtualKey)0xDE => "'", // VK_OEM_7 + (VirtualKey)0xBC => ",", // VK_OEM_COMMA + (VirtualKey)0xBE => ".", // VK_OEM_PERIOD + (VirtualKey)0xBF => "/", // VK_OEM_2 + (VirtualKey)0xC0 => "`", // VK_OEM_3 + + // For any other key, use the LayoutMap as fallback + // This handles media keys, browser keys, and other special keys + _ => GetLayoutMapKeyName(key), + }; + } + + /// + /// Checks if the specified key is a modifier key. + /// + /// The virtual key to check. + /// True if the key is a modifier (Shift, Ctrl, Alt, Win); otherwise, false. + public static bool IsModifierKey(VirtualKey key) + { + return key is VirtualKey.Shift or + VirtualKey.LeftShift or + VirtualKey.RightShift or + VirtualKey.Control or + VirtualKey.LeftControl or + VirtualKey.RightControl or + VirtualKey.Menu or // Alt + VirtualKey.LeftMenu or + VirtualKey.RightMenu or + VirtualKey.LeftWindows or + VirtualKey.RightWindows; + } + + /// + /// Normalizes modifier keys to their generic form (e.g., LeftShift -> Shift). + /// + /// The modifier key to normalize. + /// The normalized modifier key. + public static VirtualKey NormalizeModifierKey(VirtualKey key) + { + return key switch + { + VirtualKey.LeftShift or VirtualKey.RightShift => VirtualKey.Shift, + VirtualKey.LeftControl or VirtualKey.RightControl => VirtualKey.Control, + VirtualKey.LeftMenu or VirtualKey.RightMenu => VirtualKey.Menu, + VirtualKey.LeftWindows or VirtualKey.RightWindows => VirtualKey.LeftWindows, + _ => key, + }; + } + + /// + /// Checks if the specified key should trigger clearing the keystroke display. + /// + /// The virtual key to check. + /// True if the key should clear the display; otherwise, false. + public static bool IsClearKey(VirtualKey key) + { + return key is VirtualKey.Back or + VirtualKey.Escape; + } + + /// + /// Gets the key name from LayoutMap with shortened verbose names for better screen display. + /// + /// The virtual key to look up. + /// A display name from LayoutMap, shortened for readability. + private static string GetLayoutMapKeyName(VirtualKey key) + { + try + { + var keyName = _layoutMap.Value.GetKeyName((uint)key); + + // Shorten some verbose names from LayoutMap for better screen display + return keyName switch + { + "Win (Left)" or "Win (Right)" => "Win", + "Ctrl (Left)" or "Ctrl (Right)" => "Ctrl", + "Alt (Left)" or "Alt (Right)" => "Alt", + "Shift (Left)" or "Shift (Right)" => "Shift", + "Print Screen" => "PrtScn", + "Caps Lock" => "CapsLk", + "Num Lock" => "NumLk", + "Scroll Lock" => "ScrLk", + "Apps/Menu" => "Menu", + _ => keyName, + }; + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to get key name from LayoutMap for key {key}: {ex.Message}"); + + // Fallback to virtual key name + return key.ToString(); + } + } + } +} diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyboardEventArgs.cs b/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyboardEventArgs.cs new file mode 100644 index 0000000000..fba746b6d6 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyboardEventArgs.cs @@ -0,0 +1,28 @@ +// 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.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Windows.System; + +namespace ScreencastModeUI.Keyboard +{ + /// + /// Event arguments for keyboard events. + /// + internal sealed class KeyboardEventArgs : EventArgs + { + public VirtualKey Key { get; } + + public bool IsKeyDown { get; } + + public KeyboardEventArgs(VirtualKey key, bool isKeyDown) + { + Key = key; + IsKeyDown = isKeyDown; + } + } +} diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyboardListener.cs b/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyboardListener.cs new file mode 100644 index 0000000000..ff49b27386 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/Keyboard/KeyboardListener.cs @@ -0,0 +1,94 @@ +// 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.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Windows.System; + +namespace ScreencastModeUI.Keyboard +{ + internal sealed class KeyboardListener : IDisposable + { + private readonly HookProc _hookProc; + private const int WHKEYBOARDLL = 13; + private nint _windowsHookHandle; + private bool _disposed; + + private delegate nint HookProc(int nCode, nint wParam, nint lParam); + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern nint SetWindowsHookEx(int idHook, HookProc lpfn, nint hMod, uint dwThreadId); + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnhookWindowsHookEx(nint hhk); + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern nint CallNextHookEx(nint hhk, int nCode, nint wParam, nint lParam); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern nint GetModuleHandle(string? lpModuleName); + + [StructLayout(LayoutKind.Sequential)] + private struct KBDLLHOOKSTRUCT + { + public uint VkCode; + public uint ScanCode; + public uint Flags; + public uint Time; + public nuint DwExtraInfo; + } + + public event EventHandler? KeyboardEvent; + + public KeyboardListener() + { + _hookProc = LowLevelKeyboardProc; + + nint currentModuleHandle = GetModuleHandle(null); + _windowsHookHandle = SetWindowsHookEx(WHKEYBOARDLL, _hookProc, currentModuleHandle, 0); + + if (_windowsHookHandle == nint.Zero) + { + int errorCode = Marshal.GetLastWin32Error(); + throw new Win32Exception(errorCode, $"Failed to set keyboard hook. Error {errorCode}."); + } + } + + private nint LowLevelKeyboardProc(int nCode, nint wParam, nint lParam) + { + if (nCode >= 0) + { + var hookStruct = Marshal.PtrToStructure(lParam); + var key = (VirtualKey)hookStruct.VkCode; + var message = wParam.ToInt32(); + + // WM_KEYDOWN (0x0100) or WM_SYSKEYDOWN (0x0104) + bool isKeyDown = message == 0x0100 || message == 0x0104; + + KeyboardEvent?.Invoke(this, new KeyboardEventArgs(key, isKeyDown)); + } + + return CallNextHookEx(_windowsHookHandle, nCode, wParam, lParam); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_windowsHookHandle != nint.Zero) + { + UnhookWindowsHookEx(_windowsHookHandle); + _windowsHookHandle = nint.Zero; + } + } + } +} diff --git a/src/modules/ScreencastMode/ScreencastModeUI/MainWindow.xaml b/src/modules/ScreencastMode/ScreencastModeUI/MainWindow.xaml new file mode 100644 index 0000000000..cb1aeb4659 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/MainWindow.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/modules/ScreencastMode/ScreencastModeUI/MainWindow.xaml.cs b/src/modules/ScreencastMode/ScreencastModeUI/MainWindow.xaml.cs new file mode 100644 index 0000000000..bc8fc2d66a --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/MainWindow.xaml.cs @@ -0,0 +1,555 @@ +// 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.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using ScreencastModeUI.Keyboard; +using WinUIEx; + +namespace ScreencastModeUI +{ + /// + /// Main window that displays keystrokes for screencast mode + /// + public sealed partial class MainWindow : WindowEx, IDisposable + { + [DllImport("user32.dll", SetLastError = true)] + private static extern int GetWindowLong(nint hWnd, int nIndex); + + [DllImport("user32.dll", SetLastError = true)] + private static extern int SetWindowLong(nint hWnd, int nIndex, int dwNewLong); + + // private const int MaxKeysToDisplay = 22; + private const int DefaultHideDelayMs = 2000; + private const int MinWindowWidth = 200; + private const int MinWindowHeight = 40; + private const int EdgeMargin = 20; + + // Extra buffer to prevent text clipping + private const double ExtraWidthBuffer = 20; + private const double ExtraHeightBuffer = 20; + + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private readonly DispatcherTimer _hideTimer; + private readonly DispatcherTimer _settingsDebounceTimer; + private readonly KeyDisplayer _keyDisplayer = new(); + + private KeyboardListener? _keyboardListener; + private System.IO.FileSystemWatcher? _settingsWatcher; + private bool _disposed; + + private string _textColor = "#FFFFFF"; + private string _backgroundColor = "#000000"; + private string _displayPosition = "TopRight"; + private int _textSize = 18; + + public MainWindow() + { + InitializeComponent(); + + // Timer to hide the keystroke display after nothing is typed + _hideTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(DefaultHideDelayMs), + }; + _hideTimer.Tick += HideTimer_Tick; + + // Debounce timer for settings changes + _settingsDebounceTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(300), + }; + _settingsDebounceTimer.Tick += SettingsDebounceTimer_Tick; + + // Subscribe to display updates from KeyDisplayer + _keyDisplayer.DisplayUpdated += OnDisplayUpdated; + + LoadSettings(); + SetupKeyboardHook(); + ApplyColorSettings(); + + KeystrokePanel.Visibility = Visibility.Collapsed; + + SubscribeToSettingsChanges(); + + ConfigureOverlayWindow(); + + UpdateWindowPosition(); + + this.Hide(); + } + + private void ConfigureOverlayWindow() + { + try + { + // Use WinUIEx properties to configure the overlay window + // Remove title bar and window chrome + this.ExtendsContentIntoTitleBar = true; + this.IsTitleBarVisible = false; + + // Disable resizing and min/max buttons + this.IsResizable = false; + this.IsMaximizable = false; + this.IsMinimizable = false; + + // Keep window always on top + this.IsAlwaysOnTop = true; + + // Hide from Alt+Tab and taskbar + this.IsShownInSwitchers = false; + + // Set initial window size + this.SetWindowSize(MinWindowWidth, MinWindowHeight); + + ApplyClickThroughStyle(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to configure overlay window: {ex.Message}"); + } + } + + /// + /// Applies extended window styles to make the window click-through (transparent to mouse input) + /// and hidden from Task Manager's window list + /// + private void ApplyClickThroughStyle() + { + try + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + int extendedStyle = GetWindowLong(hwnd, -20); + + // Add extended window styles: + // 0x00000020 = WS_EX_TRANSPARENT - Makes window transparent to mouse input (click-through) + // 0x00000080 = WS_EX_TOOLWINDOW - Hides from Task Manager window list and taskbar + // 0x08000000 = WS_EX_NOACTIVATE - Prevents window from being activated/focused + // 0x00080000 = WS_EX_LAYERED - Still necesseary even though its enabled in WinUI + extendedStyle |= 0x00000020 | 0x00000080 | 0x08000000 | 0x00080000; + + _ = SetWindowLong(hwnd, -20, extendedStyle); + } + catch (Exception ex) + { + Logger.LogError($"Failed to apply click-through style: {ex.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Stop timers + _hideTimer.Stop(); + _settingsDebounceTimer.Stop(); + + // Unsubscribe from KeyDisplayer events + _keyDisplayer.DisplayUpdated -= OnDisplayUpdated; + + // Unsubscribe from keyboard listener events and dispose + if (_keyboardListener != null) + { + _keyboardListener.KeyboardEvent -= OnKeyboardEvent; + _keyboardListener.Dispose(); + _keyboardListener = null; + } + + // Unsubscribe from settings watcher events and dispose + if (_settingsWatcher != null) + { + _settingsWatcher.EnableRaisingEvents = false; + _settingsWatcher.Changed -= OnSettingsFileChanged; + _settingsWatcher.Created -= OnSettingsFileChanged; + _settingsWatcher.Dispose(); + _settingsWatcher = null; + } + } + + private void SubscribeToSettingsChanges() + { + try + { + // Watch the ScreencastMode settings file for changes + var settingsPath = _settingsUtils.GetSettingsFilePath(ScreencastModeSettings.ModuleName); + var dir = System.IO.Path.GetDirectoryName(settingsPath); + var file = System.IO.Path.GetFileName(settingsPath); + + Logger.LogInfo($"Watching settings file: {settingsPath}"); + + if (!string.IsNullOrEmpty(dir) && !string.IsNullOrEmpty(file)) + { + // Ensure directory exists + if (!System.IO.Directory.Exists(dir)) + { + System.IO.Directory.CreateDirectory(dir); + } + + // Store watcher as field to prevent garbage collection + _settingsWatcher = new System.IO.FileSystemWatcher(dir, file) + { + NotifyFilter = System.IO.NotifyFilters.LastWrite | System.IO.NotifyFilters.Size | System.IO.NotifyFilters.CreationTime, + EnableRaisingEvents = true, + }; + + _settingsWatcher.Changed += OnSettingsFileChanged; + _settingsWatcher.Created += OnSettingsFileChanged; + + Logger.LogInfo("FileSystemWatcher configured for settings changes"); + } + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to subscribe to settings changes: {ex.Message}"); + } + } + + private void OnSettingsFileChanged(object sender, System.IO.FileSystemEventArgs e) + { + // Use debounce to avoid multiple rapid reloads + // Without this, the color would not change, possibly due to file locking + DispatcherQueue.TryEnqueue(() => + { + _settingsDebounceTimer.Stop(); + _settingsDebounceTimer.Start(); + }); + } + + /// + /// Add a delay before reloading settings to debounce multiple file change events + /// + private void SettingsDebounceTimer_Tick(object? sender, object e) + { + _settingsDebounceTimer.Stop(); + + try + { + Logger.LogInfo("Reloading settings after file change..."); + LoadSettings(); + ApplyColorSettings(); + UpdateWindowPosition(); + UpdateDisplay(); + Logger.LogInfo($"Settings reloaded - TextColor: {_textColor}, BackgroundColor: {_backgroundColor}, Position: {_displayPosition}"); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed applying live settings: {ex.Message}"); + } + } + + /// + /// Loads settings from the ScreencastMode settings file + /// + private void LoadSettings() + { + try + { + var settings = _settingsUtils.GetSettingsOrDefault( + ScreencastModeSettings.ModuleName); + + _textColor = settings.Properties.TextColor.Value; + _backgroundColor = settings.Properties.BackgroundColor.Value; + _displayPosition = settings.Properties.DisplayPosition.Value; + _textSize = settings.Properties.TextSize.Value; + } + catch (Exception ex) + { + Logger.LogError($"Failed to load settings: {ex.Message}"); + } + } + + /// + /// Parse the color settings and apply them to the keystroke panel + /// + private void ApplyColorSettings() + { + try + { + // Parse background color + byte bgAlpha = 230; + int bgROffset = 1; + int bgGOffset = 3; + int bgBOffset = 5; + + if (_backgroundColor.Length == 9) + { + // Format: #AARRGGBB + bgAlpha = byte.Parse(_backgroundColor.AsSpan(1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + bgROffset = 3; + bgGOffset = 5; + bgBOffset = 7; + } + + KeystrokePanel.Background = new SolidColorBrush( + Windows.UI.Color.FromArgb( + bgAlpha, + byte.Parse(_backgroundColor.AsSpan(bgROffset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + byte.Parse(_backgroundColor.AsSpan(bgGOffset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + byte.Parse(_backgroundColor.AsSpan(bgBOffset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture))); + + // Parse text color + byte txtAlpha = 255; + int txtROffset = 1; + int txtGOffset = 3; + int txtBOffset = 5; + + // Even though the color picker in the settings UI only allows #RRGGBB, + // the settings.json file saves it as #AARRGGBB, where #AA is always FF + if (_textColor.Length == 9) + { + // Format: #AARRGGBB + txtAlpha = byte.Parse(_textColor.AsSpan(1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + txtROffset = 3; + txtGOffset = 5; + txtBOffset = 7; + } + + KeystrokeText.Foreground = new SolidColorBrush( + Windows.UI.Color.FromArgb( + txtAlpha, + byte.Parse(_textColor.AsSpan(txtROffset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + byte.Parse(_textColor.AsSpan(txtGOffset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + byte.Parse(_textColor.AsSpan(txtBOffset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture))); + + Logger.LogInfo("Applied color settings to keystroke panel"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to apply color settings: {ex.Message}"); + } + } + + /// + /// Move the overlaw to the updated position based on settings + /// + private void UpdateWindowPosition() + { + try + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary); + var workArea = displayArea.WorkArea; + + double dpiScale = (float)this.GetDpiForWindow() / 96; + + // Get the current actual window size (not the minimum) + var appWindow = AppWindow.GetFromWindowId(windowId); + int currentWidth = appWindow.Size.Width; + int currentHeight = appWindow.Size.Height; + + int scaledMargin = (int)(EdgeMargin * dpiScale); + + int x, y; + + switch (_displayPosition) + { + case "Top Left": + x = workArea.X + scaledMargin; + y = workArea.Y + scaledMargin; + break; + + case "Top Right": + x = workArea.X + workArea.Width - currentWidth - scaledMargin; + y = workArea.Y + scaledMargin; + break; + + case "Top Center": + x = workArea.X + ((workArea.Width - currentWidth) / 2); + y = workArea.Y + scaledMargin; + break; + + case "Center": + x = workArea.X + ((workArea.Width - currentWidth) / 2); + y = workArea.Y + ((workArea.Height - currentHeight) / 2); + break; + + case "Bottom Left": + x = workArea.X + scaledMargin; + y = workArea.Y + workArea.Height - currentHeight - scaledMargin; + break; + + case "Bottom Center": + x = workArea.X + ((workArea.Width - currentWidth) / 2); + y = workArea.Y + workArea.Height - currentHeight - scaledMargin; + break; + + case "Bottom Right": + default: + x = workArea.X + workArea.Width - currentWidth - scaledMargin; + y = workArea.Y + workArea.Height - currentHeight - scaledMargin; + break; + } + + this.Move(x, y); + + KeystrokePanel.HorizontalAlignment = HorizontalAlignment.Center; + KeystrokePanel.VerticalAlignment = VerticalAlignment.Center; + KeystrokePanel.Margin = new Thickness(0); + } + catch (Exception ex) + { + Logger.LogError($"Failed to update window position: {ex.Message}"); + } + } + + /// + /// Intializes the global keyboard hook to listen for keystrokes + /// + private void SetupKeyboardHook() + { + try + { + // Use our custom KeyboardListener that observes but doesn't consume keystrokes + _keyboardListener = new KeyboardListener(); + _keyboardListener.KeyboardEvent += OnKeyboardEvent; + } + catch (Exception ex) + { + Logger.LogError($"Failed to setup keyboard hook: {ex.Message}"); + } + } + + /// + /// Enqueues processing of a keyboard event on the UI thread + /// + private void OnKeyboardEvent(object? sender, KeyboardEventArgs e) + { + DispatcherQueue.TryEnqueue(() => + { + _keyDisplayer.ProcessKeyEvent(e.Key, e.IsKeyDown); + }); + } + + /// + /// Handles display updates from the KeyDisplayer + /// + private void OnDisplayUpdated(object? sender, EventArgs e) + { + UpdateDisplay(); + } + + /// + /// Updates the displayed text and resizes the window accordingly + /// + private void UpdateDisplay() + { + var displayText = _keyDisplayer.DisplayText; + + if (string.IsNullOrEmpty(displayText)) + { + KeystrokePanel.Visibility = Visibility.Collapsed; + _hideTimer.Stop(); + this.Hide(); + } + else + { + KeystrokeText.Text = displayText; + KeystrokeText.FontSize = _textSize; + + // Update padding proportionally to text size + // Horizontal: ~0.9x font size per side, Vertical: ~0.45x font size per side + double paddingH = _textSize * 0.9; + double paddingV = _textSize * 0.45; + KeystrokePanel.Padding = new Thickness(paddingH, paddingV, paddingH, paddingV); + + KeystrokePanel.Visibility = Visibility.Visible; + + // Show the window when there's content to display + this.Show(); + + // Force layout update before measuring + KeystrokeText.UpdateLayout(); + + // Measure and resize window based on text content + ResizeWindowToFitContent(); + + _hideTimer.Stop(); + _hideTimer.Start(); + } + } + + /// + /// Dynamically resizes the overlay window based on size and number of text characters + /// + private void ResizeWindowToFitContent() + { + try + { + // Get window and DPI info + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + var appWindow = AppWindow.GetFromWindowId(windowId); + double dpiScale = (float)this.GetDpiForWindow() / 96; + + // Measure the actual TextBlock after it has been updated + KeystrokeText.Measure(new Windows.Foundation.Size(double.PositiveInfinity, double.PositiveInfinity)); + double textWidth = KeystrokeText.DesiredSize.Width; + double textHeight = KeystrokeText.DesiredSize.Height; + + // Get the current padding from the Border + var padding = KeystrokePanel.Padding; + double totalPaddingH = padding.Left + padding.Right; + double totalPaddingV = padding.Top + padding.Bottom; + + // Calculate logical size (text + padding + extra buffer for safety) + double logicalWidth = textWidth + totalPaddingH + ExtraWidthBuffer; + double logicalHeight = textHeight + totalPaddingV + ExtraHeightBuffer; + + // Convert to physical pixels for window sizing + int windowWidth = (int)Math.Ceiling(logicalWidth * dpiScale); + int windowHeight = (int)Math.Ceiling(logicalHeight * dpiScale); + + // Calculate minimum sizes based on font size + // After testing, min height 2.5x the font is a good fit + int minWidth = (int)(MinWindowWidth * dpiScale); + int minHeight = (int)(_textSize * 2.5 * dpiScale); + + // Ensure minimum size + windowWidth = Math.Max(windowWidth, minWidth); + windowHeight = Math.Max(windowHeight, minHeight); + + // Resize using AppWindow API + appWindow.Resize(new Windows.Graphics.SizeInt32(windowWidth, windowHeight)); + + // Update position after resize + UpdateWindowPosition(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to resize window: {ex.Message}"); + } + } + + /// + /// Hides the overlay, clears tracked keys, and stops the hide timer + /// + private void HideTimer_Tick(object? sender, object e) + { + _hideTimer.Stop(); + + // Clear all tracked keys and modifiers when the overlay times out + _keyDisplayer.Clear(); + + KeystrokeText.Text = string.Empty; + KeystrokePanel.Visibility = Visibility.Collapsed; + + // Hide the window completely when no text is displayed + this.Hide(); + } + } +} diff --git a/src/modules/ScreencastMode/ScreencastModeUI/Package.appxmanifest b/src/modules/ScreencastMode/ScreencastModeUI/Package.appxmanifest new file mode 100644 index 0000000000..a26bba148b --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/Package.appxmanifest @@ -0,0 +1,51 @@ + + + + + + + + + + ScreencastModeUI + ahmad + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/ScreencastMode/ScreencastModeUI/ScreencastModeUI.csproj b/src/modules/ScreencastMode/ScreencastModeUI/ScreencastModeUI.csproj new file mode 100644 index 0000000000..49261d137e --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/ScreencastModeUI.csproj @@ -0,0 +1,60 @@ + + + + + + + PowerToys.ScreencastMode + PowerToys ScreencastMode + WinExe + ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps + ScreencastModeUI + PowerToys.ScreencastModeUI + app.manifest + true + x64;ARM64 + true + false + false + true + None + true + enable + + PowerToys.ScreencastModeUI.pri + false + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + \ No newline at end of file diff --git a/src/modules/ScreencastMode/ScreencastModeUI/TargetFramework.cs b/src/modules/ScreencastMode/ScreencastModeUI/TargetFramework.cs new file mode 100644 index 0000000000..e603983cf7 --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/TargetFramework.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 System.Runtime.Versioning; + +[assembly: TargetFramework( + ".NETCoreApp,Version=v9.0", + FrameworkDisplayName = ".NET 9.0")] diff --git a/src/modules/ScreencastMode/ScreencastModeUI/app.manifest b/src/modules/ScreencastMode/ScreencastModeUI/app.manifest new file mode 100644 index 0000000000..48138237ef --- /dev/null +++ b/src/modules/ScreencastMode/ScreencastModeUI/app.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + PerMonitorV2 + + + \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs index e0d2c38262..f8e9478023 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs @@ -8,6 +8,7 @@ using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel.DataTransfer; namespace Microsoft.CmdPal.Core.ViewModels; @@ -16,6 +17,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa { public ExtensionObject Model => _commandItemModel; + private ExtensionObject? ExtendedAttributesProvider { get; set; } + private readonly ExtensionObject _commandItemModel = new(null); private CommandContextItemViewModel? _defaultCommandContextItemViewModel; @@ -65,6 +68,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public bool ShouldBeVisible => !string.IsNullOrEmpty(Name); + public DataPackageView? DataPackage { get; private set; } + public List AllCommands { get @@ -157,6 +162,13 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa // will never be able to load Hotkeys & aliases UpdateProperty(nameof(IsInitialized)); + if (model is IExtendedAttributesProvider extendedAttributesProvider) + { + ExtendedAttributesProvider = new ExtensionObject(extendedAttributesProvider); + var properties = extendedAttributesProvider.GetProperties(); + UpdateDataPackage(properties); + } + Initialized |= InitializedState.Initialized; } @@ -379,6 +391,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa UpdateProperty(nameof(SecondaryCommandName)); UpdateProperty(nameof(HasMoreCommands)); + break; + case nameof(DataPackage): + UpdateDataPackage(ExtendedAttributesProvider?.Unsafe?.GetProperties()); break; } @@ -431,6 +446,16 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa UpdateProperty(nameof(Icon)); } + private void UpdateDataPackage(IDictionary? properties) + { + DataPackage = + properties?.TryGetValue(WellKnownExtensionAttributes.DataPackage, out var dataPackageView) == true && + dataPackageView is DataPackageView view + ? view + : null; + UpdateProperty(nameof(DataPackage)); + } + protected override void UnsafeCleanup() { base.UnsafeCleanup(); 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.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index fc5e36d1e2..fcba26ade8 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -18,7 +18,7 @@ using WyHash; namespace Microsoft.CmdPal.UI.ViewModels; -public sealed partial class TopLevelViewModel : ObservableObject, IListItem +public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider { private readonly SettingsModel _settings; private readonly ProviderSettings _providerSettings; @@ -232,6 +232,13 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem { UpdateInitialIcon(); } + else if (e.PropertyName == nameof(CommandItem.DataPackage)) + { + DoOnUiThread(() => + { + OnPropertyChanged(nameof(CommandItem.DataPackage)); + }); + } } } @@ -394,4 +401,12 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem { return $"{nameof(TopLevelViewModel)}: {Id} ({Title}) - display: {DisplayTitle} - fallback: {IsFallback} - enabled: {IsEnabled}"; } + + public IDictionary GetProperties() + { + return new Dictionary + { + [WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage, + }; + } } 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/BlurImageControl.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs index 743e68d690..d77cab0645 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs @@ -368,32 +368,69 @@ internal sealed partial class BlurImageControl : Control { try { - if (imageSource is Microsoft.UI.Xaml.Media.Imaging.BitmapImage bitmapImage) + if (imageSource is not 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); + return; } + + _imageBrush ??= _compositor?.CreateSurfaceBrush(); + if (_imageBrush is null) + { + return; + } + + Logger.LogDebug($"Starting load of BlurImageControl from '{bitmapImage.UriSource}'"); + var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource); + loadedSurface.LoadCompleted += OnLoadedSurfaceOnLoadCompleted; + SetLoadedSurfaceToBrush(loadedSurface); + _effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush); } catch (Exception ex) { Logger.LogError("Failed to load image for BlurImageControl: {0}", ex); } + + return; + + void OnLoadedSurfaceOnLoadCompleted(LoadedImageSurface loadedSurface, LoadedImageSourceLoadCompletedEventArgs e) + { + switch (e.Status) + { + case LoadedImageSourceLoadStatus.Success: + Logger.LogDebug($"BlurImageControl loaded successfully: has _imageBrush? {_imageBrush != null}"); + + try + { + SetLoadedSurfaceToBrush(loadedSurface); + } + catch (Exception ex) + { + Logger.LogError("Failed to set surface in BlurImageControl", ex); + throw; + } + + break; + case LoadedImageSourceLoadStatus.NetworkError: + case LoadedImageSourceLoadStatus.InvalidFormat: + case LoadedImageSourceLoadStatus.Other: + default: + Logger.LogError($"Failed to load image for BlurImageControl: Load status {e.Status}"); + break; + } + } + } + + private void SetLoadedSurfaceToBrush(LoadedImageSurface loadedSurface) + { + var surfaceBrush = _imageBrush; + if (surfaceBrush is null) + { + return; + } + + surfaceBrush.Surface = loadedSurface; + surfaceBrush.Stretch = ConvertStretch(ImageStretch); + surfaceBrush.BitmapInterpolationMode = CompositionBitmapInterpolationMode.Linear; } private static CompositionStretch ConvertStretch(Stretch stretch) 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..3508798078 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