Merge branch 'dev/mjolley/deux' of https://github.com/microsoft/PowerToys into dev/mjolley/deux

This commit is contained in:
Michael Jolley
2025-12-12 20:04:00 -06:00
258 changed files with 10836 additions and 952 deletions

View File

@@ -335,3 +335,7 @@ azp
feedbackhub
needinfo
reportbug
#ffmpeg
crf
nostdin

View File

@@ -141,8 +141,11 @@ BITSPIXEL
bla
BLACKFRAME
BLENDFUNCTION
blittable
Blockquotes
blt
bluelightreduction
bluelightreductionstate
BLURBEHIND
BLURREGION
bmi
@@ -250,6 +253,7 @@ colorformat
colorhistory
colorhistorylimit
COLORKEY
colorref
comctl
comdlg
comexp
@@ -1113,6 +1117,7 @@ NEWPLUSSHELLEXTENSIONWIN
newrow
nicksnettravels
NIF
nightlight
NLog
NLSTEXT
NMAKE
@@ -1483,6 +1488,7 @@ rgh
rgn
rgs
rguid
rhk
RIDEV
RIGHTSCROLLBAR
riid
@@ -1588,6 +1594,7 @@ SHGDNF
SHGFI
SHIL
shinfo
shk
shlwapi
shobjidl
SHORTCUTATLEAST
@@ -1798,6 +1805,7 @@ tlbimp
tlc
tmain
TNP
toolgood
Toolhelp
toolwindow
TOPDOWNDIB
@@ -1846,6 +1854,8 @@ uitests
UITo
ULONGLONG
ums
UMax
UMin
uncompilable
UNCPRIORITY
UNDNAME
@@ -1857,8 +1867,10 @@ Uniquifies
unitconverter
unittests
UNLEN
Uninitializes
UNORM
unremapped
Unsubscribes
unvirtualized
unwide
unzoom

View File

@@ -60,6 +60,8 @@
"PowerToys.FancyZonesEditorCommon.dll",
"PowerToys.FancyZonesModuleInterface.dll",
"PowerToys.FancyZones.exe",
"FancyZonesCLI.exe",
"FancyZonesCLI.dll",
"PowerToys.GcodePreviewHandler.dll",
"PowerToys.GcodePreviewHandler.exe",
@@ -351,6 +353,11 @@
"Microsoft.SemanticKernel.Connectors.Ollama.dll",
"OllamaSharp.dll",
"boost_regex-vc143-mt-gd-x32-1_87.dll",
"boost_regex-vc143-mt-gd-x64-1_87.dll",
"boost_regex-vc143-mt-x32-1_87.dll",
"boost_regex-vc143-mt-x64-1_87.dll",
"UnitsNet.dll",
"UtfUnknown.dll",
"Wpf.Ui.dll"

View File

@@ -27,7 +27,8 @@ $versionExceptions = @(
"WyHash.dll",
"Microsoft.Recognizers.Text.DataTypes.TimexExpression.dll",
"ObjectModelCsProjection.dll",
"RendererCsProjection.dll") -join '|';
"RendererCsProjection.dll",
"Microsoft.ML.OnnxRuntime.dll") -join '|';
$nullVersionExceptions = @(
"SkiaSharp.Views.WinUI.Native.dll",
"libSkiaSharp.dll",
@@ -52,7 +53,12 @@ $nullVersionExceptions = @(
"System.Diagnostics.EventLog.Messages.dll",
"Microsoft.Windows.Widgets.dll",
"AdaptiveCards.ObjectModel.WinUI3.dll",
"AdaptiveCards.Rendering.WinUI3.dll") -join '|';
"AdaptiveCards.Rendering.WinUI3.dll",
"boost_regex_vc143_mt_gd_x32_1_87.dll",
"boost_regex_vc143_mt_gd_x64_1_87.dll",
"boost_regex_vc143_mt_x32_1_87.dll",
"boost_regex_vc143_mt_x64_1_87.dll"
) -join '|';
$totalFailure = 0;
Write-Host $DirPath;

View File

@@ -121,6 +121,9 @@ PowerToys Awake is a tool to keep your computer awake.
Randy contributed Registry Preview and some very early conversations about keyboard remapping.
### [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon
Kayla was a former lead for PowerToys and helped create multiple utilities, maintained the GitHub repo, and collaborated with the community to improve the overall product
### [@oldnewthing](https://github.com/oldnewthing) - Raymond Chen
Find My Mouse is based on Raymond Chen's SuperSonar.
@@ -180,7 +183,6 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter
## PowerToys core team
- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Lead
- [@craigloewen-msft](https://github.com/craigloewen-msft) - Craig Loewen - Product Manager
- [@niels9001](https://github.com/niels9001/) - Niels Laute - Product Manager
- [@dhowett](https://github.com/dhowett) - Dustin Howett - Dev Lead
@@ -209,6 +211,7 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter
## Former PowerToys core team members
- [@indierawk2k2](https://github.com/indierawk2k2) - Mike Harsh - Product Manager
- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Product Manager
- [@ethanfangg](https://github.com/ethanfangg) - Ethan Fang - Product Manager
- [@plante-msft](https://github.com/plante-msft) - Connor Plante - Product Manager
- [@joadoumie](https://github.com/joadoumie) - Jordi Adoumie - Product Manager

View File

@@ -38,6 +38,7 @@
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
<!-- Including Microsoft.Bcl.AsyncInterfaces to force version, since it's used by Microsoft.SemanticKernel. -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" />
<PackageVersion Include="Microsoft.Windows.CppWinRT" Version="2.0.240111.5" />
<PackageVersion Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.16" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.9.1" />
@@ -118,6 +119,7 @@
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageVersion Include="ToolGood.Words.Pinyin" Version="3.1.0.3" />
<PackageVersion Include="UnicodeInformation" Version="2.6.0" />
<PackageVersion Include="UnitsNet" Version="5.56.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />

View File

@@ -75,6 +75,37 @@ OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>
```
### ToolGood.Words.Pinyin
We use the ToolGood.Words.Pinyin NuGet package for converting Chinese characters to pinyin.
**Source**: [https://github.com/toolgood/ToolGood.Words.Pinyin](https://github.com/toolgood/ToolGood.Words.Pinyin)
```
MIT License
Copyright (c) 2020 ToolGood
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Utility: Command Palette Built-in Extensions
### Calculator
@@ -1532,6 +1563,7 @@ SOFTWARE.
- SkiaSharp.Views.WinUI
- StreamJsonRpc
- StyleCop.Analyzers
- ToolGood.Words.Pinyin
- UnicodeInformation
- UnitsNet
- UTF.Unknown

View File

@@ -398,6 +398,10 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/fancyzones/FancyZones/FancyZones.vcxproj" Id="ff1d7936-842a-4bbb-8bea-e9fe796de700" />
<Project Path="src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/fancyzones/FancyZonesEditorCommon/FancyZonesEditorCommon.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

@@ -38,7 +38,7 @@ For C# modules, the settings are accessed through the `SettingsUtils` class in t
using Microsoft.PowerToys.Settings.UI.Library;
// Read settings
var settings = SettingsUtils.GetSettings<ModuleSettings>("ModuleName");
var settings = SettingsUtils.Default.GetSettings<ModuleSettings>("ModuleName");
bool enabled = settings.Enabled;
```
@@ -49,7 +49,7 @@ using Microsoft.PowerToys.Settings.UI.Library;
// Write settings
settings.Enabled = true;
SettingsUtils.SaveSettings(settings.ToJsonString(), "ModuleName");
SettingsUtils.Default.SaveSettings(settings.ToJsonString(), "ModuleName");
```
## Settings Handling in Modules

View File

@@ -16,4 +16,5 @@ public static partial class CLSID
public static readonly Guid CollatorDataSource = new Guid("9E175B8B-F52A-11D8-B9A5-505054503030");
public static readonly Guid ApplicationActivationManager = new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C");
public static readonly Guid VirtualDesktopManager = new("aa509086-5ca9-4c25-8f95-589d3c07b48a");
public static readonly Guid DesktopWallpaper = new("C2CF3110-460E-4FC1-B9D0-8A1C0C9CC4BD");
}

View File

@@ -16,6 +16,12 @@ public static partial class Ole32
CLSCTX dwClsContext,
ref Guid riid,
out IntPtr rReturnedComObject);
[LibraryImport("ole32.dll")]
internal static partial int CoInitializeEx(nint pvReserved, uint dwCoInit);
[LibraryImport("ole32.dll")]
internal static partial void CoUninitialize();
}
[Flags]

View File

@@ -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
{
/// <summary>
/// Provides methods for recording the screen during UI tests.
/// Requires FFmpeg to be installed and available in PATH.
/// </summary>
internal class ScreenRecording : IDisposable
{
private readonly string outputDirectory;
private readonly string framesDirectory;
private readonly string outputFilePath;
private readonly List<string> 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
/// <summary>
/// Initializes a new instance of the <see cref="ScreenRecording"/> class.
/// </summary>
/// <param name="outputDirectory">Directory where the recording will be saved.</param>
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<string>();
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");
}
}
/// <summary>
/// Gets a value indicating whether screen recording is available (FFmpeg found).
/// </summary>
public bool IsAvailable => ffmpegPath != null;
/// <summary>
/// Starts recording the screen.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
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();
}
}
/// <summary>
/// Stops recording and encodes video.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
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();
}
}
/// <summary>
/// Records frames from the screen.
/// </summary>
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}");
}
}
/// <summary>
/// Captures a single frame.
/// </summary>
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<ScreenCapture.CURSORINFO>();
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++;
}
}
/// <summary>
/// Encodes captured frames to video using ffmpeg.
/// </summary>
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}");
}
}
/// <summary>
/// Finds ffmpeg executable.
/// </summary>
private static string? FindFfmpeg()
{
// Check if ffmpeg is in PATH
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty<string>();
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;
}
/// <summary>
/// Gets the path to the recorded video file.
/// </summary>
public string OutputFilePath => outputFilePath;
/// <summary>
/// Gets the directory containing recordings.
/// </summary>
public string OutputDirectory => outputDirectory;
/// <summary>
/// Cleans up resources.
/// </summary>
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}");
}
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (isRecording)
{
StopRecordingAsync().GetAwaiter().GetResult();
}
Cleanup();
recordingLock.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@@ -130,9 +130,13 @@ namespace Microsoft.PowerToys.UITest
/// </summary>
/// <param name="appPath">The path to the application executable.</param>
/// <param name="args">Optional command line arguments to pass to the application.</param>
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;
}
/// <summary>
@@ -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}");
}
}
/// <summary>
/// Restarts now exe and takes control of it.
/// </summary>
public void RestartScopeExe()
public void RestartScopeExe(string? enableModules = null)
{
ExitScopeExe();
StartExe(locationPath + sessionPath, this.commandLineArgs);
StartExe(locationPath + sessionPath, commandLineArgs, enableModules);
}
public WindowsDriver<WindowsElement> 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}");
}
}
}
}
}

View File

@@ -21,19 +21,18 @@ namespace Microsoft.PowerToys.UITest
public class SettingsConfigHelper
{
private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true };
private static readonly SettingsUtils SettingsUtils = new SettingsUtils();
private static readonly SettingsUtils SettingsUtils = SettingsUtils.Default;
/// <summary>
/// Configures global PowerToys settings to enable only specified modules and disable all others.
/// </summary>
/// <param name="modulesToEnable">Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled.</param>
/// <exception cref="ArgumentNullException">Thrown when modulesToEnable is null.</exception>
/// <param name="modulesToEnable">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.</param>
/// <exception cref="InvalidOperationException">Thrown when settings file operations fail.</exception>
[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<string>();
try
{

View File

@@ -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<MonitorInfoData.MonitorInfoDataWrapper>() };
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
}
}
/// <summary>
/// Adds screen recordings to test results directory when test fails.
/// </summary>
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.");
}
}
}
/// <summary>
/// Cleans up recording directory when test passes.
/// </summary>
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}");
}
}
}
/// <summary>
/// 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
/// <summary>
/// Restart scope exe.
/// </summary>
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;
}
/// <summary>

View File

@@ -16,9 +16,54 @@
namespace registry
{
namespace detail
{
struct on_exit
{
std::function<void()> f;
on_exit(std::function<void()> f) :
f{ std::move(f) } {}
~on_exit() { f(); }
};
template<class... Ts>
struct overloaded : Ts...
{
using Ts::operator()...;
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
inline const wchar_t* getScopeName(HKEY scope)
{
if (scope == HKEY_LOCAL_MACHINE)
{
return L"HKLM";
}
else if (scope == HKEY_CURRENT_USER)
{
return L"HKCU";
}
else if (scope == HKEY_CLASSES_ROOT)
{
return L"HKCR";
}
else
{
return L"HK??";
}
}
}
namespace install_scope
{
const wchar_t INSTALL_SCOPE_REG_KEY[] = L"Software\\Classes\\powertoys\\";
const wchar_t UNINSTALL_REG_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall";
// Bundle UpgradeCode from PowerToys.wxs (with braces as stored in registry)
const wchar_t BUNDLE_UPGRADE_CODE[] = L"{6341382D-C0A9-4238-9188-BE9607E3FAB2}";
enum class InstallScope
{
@@ -26,8 +71,67 @@ namespace registry
PerUser,
};
// Helper function to find PowerToys bundle in Windows Uninstall registry by BundleUpgradeCode
inline bool find_powertoys_bundle_in_uninstall_registry(HKEY rootKey)
{
HKEY uninstallKey{};
if (RegOpenKeyExW(rootKey, UNINSTALL_REG_KEY, 0, KEY_READ, &uninstallKey) != ERROR_SUCCESS)
{
return false;
}
detail::on_exit closeUninstallKey{ [uninstallKey] { RegCloseKey(uninstallKey); } };
DWORD index = 0;
wchar_t subKeyName[256];
// Enumerate all subkeys under Uninstall
while (RegEnumKeyW(uninstallKey, index++, subKeyName, 256) == ERROR_SUCCESS)
{
HKEY productKey{};
if (RegOpenKeyExW(uninstallKey, subKeyName, 0, KEY_READ, &productKey) != ERROR_SUCCESS)
{
continue;
}
detail::on_exit closeProductKey{ [productKey] { RegCloseKey(productKey); } };
// Check BundleUpgradeCode value (specific to WiX Bundle installations)
wchar_t bundleUpgradeCode[256]{};
DWORD bundleUpgradeCodeSize = sizeof(bundleUpgradeCode);
if (RegQueryValueExW(productKey, L"BundleUpgradeCode", nullptr, nullptr,
reinterpret_cast<LPBYTE>(bundleUpgradeCode), &bundleUpgradeCodeSize) == ERROR_SUCCESS)
{
if (_wcsicmp(bundleUpgradeCode, BUNDLE_UPGRADE_CODE) == 0)
{
return true;
}
}
}
return false;
}
inline const InstallScope get_current_install_scope()
{
// 1. Check HKCU Uninstall registry first (user-level bundle)
// Note: MSI components are always in HKLM regardless of install scope,
// but the Bundle entry will be in HKCU for per-user installations
if (find_powertoys_bundle_in_uninstall_registry(HKEY_CURRENT_USER))
{
Logger::info(L"Found user-level PowerToys bundle via BundleUpgradeCode in HKCU");
return InstallScope::PerUser;
}
// 2. Check HKLM Uninstall registry (machine-level bundle)
if (find_powertoys_bundle_in_uninstall_registry(HKEY_LOCAL_MACHINE))
{
Logger::info(L"Found machine-level PowerToys bundle via BundleUpgradeCode in HKLM");
return InstallScope::PerMachine;
}
// 3. Fallback to legacy custom registry key detection
Logger::info(L"PowerToys bundle not found in Uninstall registry, falling back to legacy detection");
// Open HKLM key
HKEY perMachineKey{};
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE,
@@ -45,6 +149,7 @@ namespace registry
&perUserKey) != ERROR_SUCCESS)
{
// both keys are missing
Logger::warn(L"No PowerToys installation detected, defaulting to PerMachine");
return InstallScope::PerMachine;
}
else
@@ -96,47 +201,6 @@ namespace registry
template<class>
inline constexpr bool always_false_v = false;
namespace detail
{
struct on_exit
{
std::function<void()> f;
on_exit(std::function<void()> f) :
f{ std::move(f) } {}
~on_exit() { f(); }
};
template<class... Ts>
struct overloaded : Ts...
{
using Ts::operator()...;
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
inline const wchar_t* getScopeName(HKEY scope)
{
if (scope == HKEY_LOCAL_MACHINE)
{
return L"HKLM";
}
else if (scope == HKEY_CURRENT_USER)
{
return L"HKCU";
}
else if (scope == HKEY_CLASSES_ROOT)
{
return L"HKCR";
}
else
{
return L"HK??";
}
}
}
struct ValueChange
{
using value_t = std::variant<DWORD, std::wstring>;

View File

@@ -18,7 +18,7 @@ namespace PowerToys.DSC.UnitTests.SettingsResourceTests;
public abstract class SettingsResourceModuleTest<TSettingsConfig> : BaseDscTest
where TSettingsConfig : ISettingsConfig, new()
{
private readonly SettingsUtils _settingsUtils = new();
private readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
private TSettingsConfig _originalSettings;
protected TSettingsConfig DefaultSettings => new();

View File

@@ -18,7 +18,7 @@ namespace PowerToys.DSC.Models.FunctionData;
public sealed class SettingsFunctionData<TSettingsConfig> : BaseFunctionData, ISettingsFunctionData
where TSettingsConfig : ISettingsConfig, new()
{
private static readonly SettingsUtils _settingsUtils = new();
private static readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
private static readonly TSettingsConfig _settingsConfig = new();
private readonly SettingsResourceObject<TSettingsConfig> _input;

View File

@@ -0,0 +1,56 @@
// 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 AdvancedPaste.Converters;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.UI;
namespace AdvancedPaste.UnitTests.ConvertersTests;
[TestClass]
public sealed class HexColorToColorConverterTests
{
[TestMethod]
public void TestConvert_ValidSixDigitHex_ReturnsColor()
{
Color? result = HexColorConverterHelper.ConvertHexColorToRgb("#FFBFAB");
Assert.IsNotNull(result);
var color = (Windows.UI.Color)result;
Assert.AreEqual(255, color.R);
Assert.AreEqual(191, color.G);
Assert.AreEqual(171, color.B);
Assert.AreEqual(255, color.A);
}
[TestMethod]
public void TestConvert_ValidThreeDigitHex_ReturnsColor()
{
Color? result = HexColorConverterHelper.ConvertHexColorToRgb("#abc");
Assert.IsNotNull(result);
var color = (Windows.UI.Color)result;
// #abc should expand to #aabbcc
Assert.AreEqual(170, color.R); // 0xaa
Assert.AreEqual(187, color.G); // 0xbb
Assert.AreEqual(204, color.B); // 0xcc
Assert.AreEqual(255, color.A);
}
[TestMethod]
public void TestConvert_NullOrEmpty_ReturnsNull()
{
Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb(null));
Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb(string.Empty));
Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb(" "));
}
[TestMethod]
public void TestConvert_InvalidHex_ReturnsNull()
{
Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb("#GGGGGG"));
Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb("#12345"));
}
}

View File

@@ -0,0 +1,36 @@
// 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 AdvancedPaste.Helpers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AdvancedPaste.UnitTests.HelpersTests;
[TestClass]
public sealed class ClipboardItemHelperTests
{
[TestMethod]
[DataRow("#FFBFAB", true)]
[DataRow("#000000", true)]
[DataRow("#FFFFFF", true)]
[DataRow("#fff", true)]
[DataRow("#abc", true)]
[DataRow("#123456", true)]
[DataRow("#AbCdEf", true)]
[DataRow("FFBFAB", false)] // Missing #
[DataRow("#GGGGGG", false)] // Invalid hex characters
[DataRow("#12345", false)] // Wrong length
[DataRow("#1234567", false)] // Too long
[DataRow("", false)]
[DataRow(null, false)]
[DataRow(" #FFF ", true)] // Whitespace should be trimmed
[DataRow("Not a color", false)]
[DataRow("#", false)]
[DataRow("##FFFFFF", false)]
public void TestIsRgbHexColor(string input, bool expected)
{
bool result = ClipboardItemHelper.IsRgbHexColor(input);
Assert.AreEqual(expected, result, $"IsRgbHexColor(\"{input}\") should return {expected}");
}
}

View File

@@ -11,6 +11,7 @@
mc:Ignorable="d">
<UserControl.Resources>
<converters:DateTimeToFriendlyStringConverter x:Key="DateTimeToFriendlyStringConverter" />
<converters:HexColorToBrushConverter x:Key="HexColorToBrushConverter" />
<tkconverters:BoolToVisibilityConverter x:Name="BoolToVisibilityConverter" />
</UserControl.Resources>
<Grid ColumnSpacing="12">
@@ -25,6 +26,26 @@
Source="{x:Bind ClipboardItem.Image, Mode=OneWay}"
Stretch="UniformToFill"
Visibility="{x:Bind HasImage, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Color preview with text -->
<Grid Visibility="{x:Bind HasColor, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse
Grid.Column="0"
Width="8"
Height="8"
Margin="8,0,8,0"
Fill="{x:Bind ClipboardItem.Content, Mode=OneWay, Converter={StaticResource HexColorToBrushConverter}}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
FontSize="10"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ClipboardItem.Content, Mode=OneWay}"
TextWrapping="NoWrap" />
</Grid>
<!-- Text preview -->
<TextBlock
Margin="8,0,0,0"

View File

@@ -38,9 +38,11 @@ namespace AdvancedPaste.Controls
public bool HasImage => ContentImage is not null;
public bool HasText => !string.IsNullOrEmpty(ContentText) && !HasImage;
public bool HasText => !string.IsNullOrEmpty(ContentText) && !HasImage && !HasColor;
public bool HasGlyph => !HasImage && !HasText && !string.IsNullOrEmpty(IconGlyph);
public bool HasGlyph => !HasImage && !HasText && !HasColor && !string.IsNullOrEmpty(IconGlyph);
public bool HasColor => ClipboardItemHelper.IsRgbHexColor(ContentText);
public ClipboardHistoryItemPreviewControl()
{

View File

@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace AdvancedPaste.Converters
{
public static class HexColorConverterHelper
{
public static Windows.UI.Color? ConvertHexColorToRgb(string hexColor)
{
try
{
// Remove # if present
var cleanHex = hexColor.TrimStart('#');
// Expand 3-digit hex to 6-digit (#ABC -> #AABBCC)
if (cleanHex.Length == 3)
{
cleanHex = $"{cleanHex[0]}{cleanHex[0]}{cleanHex[1]}{cleanHex[1]}{cleanHex[2]}{cleanHex[2]}";
}
if (cleanHex.Length == 6)
{
var r = System.Convert.ToByte(cleanHex.Substring(0, 2), 16);
var g = System.Convert.ToByte(cleanHex.Substring(2, 2), 16);
var b = System.Convert.ToByte(cleanHex.Substring(4, 2), 16);
return Windows.UI.Color.FromArgb(255, r, g, b);
}
}
catch
{
// Invalid color format - return null
}
return null;
}
}
}

View File

@@ -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 Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;
namespace AdvancedPaste.Converters
{
public sealed partial class HexColorToBrushConverter : IValueConverter
{
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotSupportedException();
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is not string hexColor || string.IsNullOrWhiteSpace(hexColor))
{
return null;
}
Windows.UI.Color? color = HexColorConverterHelper.ConvertHexColorToRgb(hexColor);
return color != null ? new SolidColorBrush((Windows.UI.Color)color) : null;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using Microsoft.UI.Xaml.Media.Imaging;
@@ -10,8 +11,11 @@ using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Helpers
{
internal static class ClipboardItemHelper
internal static partial class ClipboardItemHelper
{
// Compiled regex for better performance when checking multiple clipboard items
private static readonly Regex HexColorRegex = HexColorCompiledRegex();
/// <summary>
/// Creates a ClipboardItem from current clipboard data.
/// </summary>
@@ -55,6 +59,31 @@ namespace AdvancedPaste.Helpers
return clipboardItem;
}
/// <summary>
/// Checks if text is a valid RGB hex color (e.g., #FFBFAB or #fff).
/// </summary>
public static bool IsRgbHexColor(string text)
{
if (text == null)
{
return false;
}
string trimmedText = text.Trim();
if (trimmedText.Length > 7)
{
return false;
}
if (string.IsNullOrWhiteSpace(trimmedText))
{
return false;
}
// Match #RGB or #RRGGBB format (case-insensitive)
return HexColorRegex.IsMatch(trimmedText);
}
/// <summary>
/// Creates a BitmapImage from clipboard data.
/// </summary>
@@ -80,5 +109,8 @@ namespace AdvancedPaste.Helpers
return null;
}
[GeneratedRegex(@"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$")]
private static partial Regex HexColorCompiledRegex();
}
}

View File

@@ -62,7 +62,7 @@ namespace Hosts.Settings
public UserSettings()
{
_settingsUtils = new SettingsUtils();
_settingsUtils = SettingsUtils.Default;
var defaultSettings = new HostsProperties();
ShowStartupWarning = defaultSettings.ShowStartupWarning;
LoopbackDuplicates = defaultSettings.LoopbackDuplicates;

View File

@@ -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(

View File

@@ -13,10 +13,12 @@
#include <utils/logger_helper.h>
#include "LightSwitchStateManager.h"
#include <LightSwitchUtils.h>
#include <NightLightRegistryObserver.h>
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<NightLightRegistryObserver> 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<NightLightRegistryObserver>(
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<NightLightRegistryObserver>(
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;

View File

@@ -76,6 +76,7 @@
<ClCompile Include="LightSwitchService.cpp" />
<ClCompile Include="LightSwitchSettings.cpp" />
<ClCompile Include="LightSwitchStateManager.cpp" />
<ClCompile Include="NightLightRegistryObserver.cpp" />
<ClCompile Include="SettingsConstants.cpp" />
<ClCompile Include="ThemeHelper.cpp" />
<ClCompile Include="ThemeScheduler.cpp" />
@@ -88,6 +89,7 @@
<ClInclude Include="LightSwitchSettings.h" />
<ClInclude Include="LightSwitchStateManager.h" />
<ClInclude Include="LightSwitchUtils.h" />
<ClInclude Include="NightLightRegistryObserver.h" />
<ClInclude Include="SettingsConstants.h" />
<ClInclude Include="SettingsObserver.h" />
<ClInclude Include="ThemeHelper.h" />

View File

@@ -36,6 +36,9 @@
<ClCompile Include="LightSwitchStateManager.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="NightLightRegistryObserver.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="ThemeScheduler.h">
@@ -62,6 +65,9 @@
<ClInclude Include="LightSwitchUtils.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="NightLightRegistryObserver.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />

View File

@@ -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;
}

View File

@@ -31,7 +31,10 @@ void LightSwitchStateManager::OnSettingsChanged()
void LightSwitchStateManager::OnTick(int currentMinutes)
{
std::lock_guard<std::mutex> 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<std::mutex> 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<int, int> 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;
}

View File

@@ -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();

View File

@@ -0,0 +1 @@
#include "NightLightRegistryObserver.h"

View File

@@ -0,0 +1,134 @@
#pragma once
#include <wtypes.h>
#include <string>
#include <functional>
#include <thread>
#include <atomic>
#include <mutex>
class NightLightRegistryObserver
{
public:
NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function<void()> 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<std::mutex> lock(_mutex);
if (_event)
SetEvent(_event);
}
if (_thread.joinable())
_thread.join();
std::lock_guard<std::mutex> lock(_mutex);
if (_hKey)
{
RegCloseKey(_hKey);
_hKey = nullptr;
}
if (_event)
{
CloseHandle(_event);
_event = nullptr;
}
}
private:
void Run()
{
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(_mutex);
if (_hKey)
{
RegCloseKey(_hKey);
_hKey = nullptr;
}
if (_event)
{
CloseHandle(_event);
_event = nullptr;
}
}
}
HKEY _root;
std::wstring _subkey;
std::function<void()> _callback;
HANDLE _event = nullptr;
HKEY _hKey = nullptr;
std::thread _thread;
std::atomic<bool> _stop;
std::mutex _mutex;
};

View File

@@ -11,4 +11,7 @@ enum class SettingId
Sunset_Offset,
ChangeSystem,
ChangeApps
};
};
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";

View File

@@ -3,6 +3,7 @@
#include <logger/logger.h>
#include <utils/logger_helper.h>
#include "ThemeHelper.h"
#include <SettingsConstants.h>
// 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<BYTE> 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;
}

View File

@@ -3,3 +3,4 @@ void SetSystemTheme(bool dark);
void SetAppsTheme(bool dark);
bool GetCurrentSystemTheme();
bool GetCurrentAppsTheme();
bool IsNightLightEnabled();

View File

@@ -11,7 +11,7 @@ namespace MeasureToolUI
{
public sealed class Settings
{
private static readonly SettingsUtils ModuleSettings = new();
private static readonly SettingsUtils ModuleSettings = SettingsUtils.Default;
public MeasureToolMeasureStyle DefaultMeasureStyle
{

View File

@@ -53,7 +53,7 @@ internal sealed class SettingsHelper
lock (this.LockObject)
{
{
var settingsUtils = new SettingsUtils();
var settingsUtils = SettingsUtils.Default;
// set this to 1 to disable retries
var remainingRetries = 5;

View File

@@ -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);

View File

@@ -192,7 +192,7 @@ namespace MouseWithoutBorders.Class
internal Settings()
{
_settingsUtils = new SettingsUtils();
_settingsUtils = SettingsUtils.Default;
_watcher = SettingsHelper.GetFileWatcher("MouseWithoutBorders", "settings.json", () =>
{

View File

@@ -29,7 +29,7 @@ namespace PowerOCR.Settings
[ImportingConstructor]
public UserSettings(Helpers.IThrottledActionInvoker throttledActionInvoker)
{
_settingsUtils = new SettingsUtils();
_settingsUtils = SettingsUtils.Default;
ActivationShortcut = new SettingItem<string>(DefaultActivationShortcut);
PreferredLanguage = new SettingItem<string>(string.Empty);

View File

@@ -9,7 +9,7 @@ namespace WorkspacesEditor.Utils
public class Settings
{
private const string WorkspacesModuleName = "Workspaces";
private static readonly SettingsUtils _settingsUtils = new();
private static readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
public static WorkspacesSettings ReadSettings()
{

View File

@@ -133,7 +133,7 @@ namespace WorkspacesEditor.ViewModels
_orderByIndex = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
settings.Properties.SortBy = (WorkspacesProperties.SortByProperty)value;
settings.Save(new SettingsUtils());
settings.Save(SettingsUtils.Default);
}
}

View File

@@ -60,7 +60,7 @@ namespace Awake.Core
{
_tokenSource = new CancellationTokenSource();
_stateQueue = [];
ModuleSettings = new SettingsUtils();
ModuleSettings = SettingsUtils.Default;
}
internal static void StartMonitor()

View File

@@ -51,7 +51,7 @@ namespace Awake
private static async Task<int> Main(string[] args)
{
_settingsUtils = new SettingsUtils();
_settingsUtils = SettingsUtils.Default;
LockMutex = new Mutex(true, Core.Constants.AppName, out bool instantiated);

View File

@@ -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<ICommandItem> Model => _commandItemModel;
private ExtensionObject<IExtendedAttributesProvider>? ExtendedAttributesProvider { get; set; }
private readonly ExtensionObject<ICommandItem> _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<IContextItemViewModel> 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<IExtendedAttributesProvider>(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<string, object?>? 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();

View File

@@ -19,6 +19,8 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
public string Body { get; private set; } = string.Empty;
public ContentSize? Size { get; private set; } = ContentSize.Small;
// Metadata is an array of IDetailsElement,
// where IDetailsElement = {IDetailsTags, IDetailsLink, IDetailsSeparator}
public List<DetailsElementViewModel> Metadata { get; private set; } = [];
@@ -40,6 +42,21 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
UpdateProperty(nameof(Body));
UpdateProperty(nameof(HeroImage));
if (model is IExtendedAttributesProvider provider)
{
if (provider.GetProperties()?.TryGetValue("Size", out var rawValue) == true)
{
if (rawValue is int sizeAsInt)
{
Size = (ContentSize)sizeAsInt;
}
}
}
Size ??= ContentSize.Small;
UpdateProperty(nameof(Size));
var meta = model.Metadata;
if (meta is not null)
{

View File

@@ -5,6 +5,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.Core.ViewModels;
@@ -57,7 +58,7 @@ public partial class IconDataViewModel : ObservableObject, IIconData
// because each call to GetProperties() is a cross process hop, and if you
// marshal-by-value the property set, then you don't want to throw it away and
// re-marshal it for every property. MAKE SURE YOU CACHE IT.
if (props?.TryGetValue("FontFamily", out var family) ?? false)
if (props?.TryGetValue(WellKnownExtensionAttributes.FontFamily, out var family) ?? false)
{
FontFamily = family as string;
}

View File

@@ -24,6 +24,8 @@ public partial class ListItemViewModel : CommandItemViewModel
public string Section { get; private set; } = string.Empty;
public bool IsSectionOrSeparator { get; private set; }
public DetailsViewModel? Details { get; private set; }
[MemberNotNullWhen(true, nameof(Details))]
@@ -82,14 +84,18 @@ public partial class ListItemViewModel : CommandItemViewModel
}
UpdateTags(li.Tags);
Section = li.Section ?? string.Empty;
UpdateProperty(nameof(Section));
IsSectionOrSeparator = IsSeparator(li);
UpdateProperty(nameof(Section), nameof(IsSectionOrSeparator));
UpdateAccessibleName();
}
private bool IsSeparator(IListItem item)
{
return item.Command is null;
}
public override void SlowInitializeProperties()
{
base.SlowInitializeProperties();
@@ -104,8 +110,7 @@ public partial class ListItemViewModel : CommandItemViewModel
{
Details = new(extensionDetails, PageContext);
Details.InitializeProperties();
UpdateProperty(nameof(Details));
UpdateProperty(nameof(HasDetails));
UpdateProperty(nameof(Details), nameof(HasDetails));
}
AddShowDetailsCommands();
@@ -135,14 +140,18 @@ public partial class ListItemViewModel : CommandItemViewModel
break;
case nameof(model.Section):
Section = model.Section ?? string.Empty;
UpdateProperty(nameof(Section));
IsSectionOrSeparator = IsSeparator(model);
UpdateProperty(nameof(Section), nameof(IsSectionOrSeparator));
break;
case nameof(model.Details):
case nameof(model.Command):
IsSectionOrSeparator = IsSeparator(model);
UpdateProperty(nameof(IsSectionOrSeparator));
break;
case nameof(Details):
var extensionDetails = model.Details;
Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
Details?.InitializeProperties();
UpdateProperty(nameof(Details));
UpdateProperty(nameof(HasDetails));
UpdateProperty(nameof(Details), nameof(HasDetails));
UpdateShowDetailsCommand();
break;
case nameof(model.MoreCommands):
@@ -194,8 +203,7 @@ public partial class ListItemViewModel : CommandItemViewModel
MoreCommands.Add(showDetailsContextItemViewModel);
}
UpdateProperty(nameof(MoreCommands));
UpdateProperty(nameof(AllCommands));
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
}
}
@@ -227,8 +235,7 @@ public partial class ListItemViewModel : CommandItemViewModel
showDetailsContextItemViewModel.SlowInitializeProperties();
MoreCommands.Add(showDetailsContextItemViewModel);
UpdateProperty(nameof(MoreCommands));
UpdateProperty(nameof(AllCommands));
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
}
}

View File

@@ -4,6 +4,4 @@
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
public record DismissMessage()
{
}
public record DismissMessage(bool ForceGoHome = false);

View File

@@ -4,6 +4,4 @@
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
public record NavigateBackMessage(bool FromBackspace = false)
{
}
public record NavigateBackMessage(bool FromBackspace = false);

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Used to navigate left in a grid view when pressing the Left arrow key in the SearchBox.
/// </summary>
public record NavigateLeftCommand;

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Used to navigate right in a grid view when pressing the Right arrow key in the SearchBox.
/// </summary>
public record NavigateRightCommand;

View File

@@ -3,13 +3,14 @@
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Core.ViewModels;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public partial class SeparatorViewModel() :
CommandItem,
IContextItemViewModel,
IFilterItemViewModel,
ISeparatorContextItem,

View File

@@ -14,6 +14,7 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ShellViewModel : ObservableObject,
IDisposable,
IRecipient<PerformCommandMessage>,
IRecipient<HandleCommandResultMessage>
{
@@ -378,7 +379,7 @@ public partial class ShellViewModel : ObservableObject,
{
// Reset the palette to the main page and dismiss
GoHome(withAnimation: false, focusSearch: false);
WeakReferenceMessenger.Default.Send<DismissMessage>();
WeakReferenceMessenger.Default.Send(new DismissMessage());
break;
}
@@ -398,7 +399,7 @@ public partial class ShellViewModel : ObservableObject,
case CommandResultKind.Hide:
{
// Keep this page open, but hide the palette.
WeakReferenceMessenger.Default.Send<DismissMessage>();
WeakReferenceMessenger.Default.Send(new DismissMessage());
break;
}
@@ -460,4 +461,12 @@ public partial class ShellViewModel : ObservableObject,
{
_navigationCts?.Cancel();
}
public void Dispose()
{
_handleInvokeTask?.Dispose();
_navigationCts?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,390 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
using Windows.UI.ViewManagement;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable
{
private static readonly ObservableCollection<Color> WindowsColorSwatches = [
// row 0
Color.FromArgb(255, 255, 185, 0), // #ffb900
Color.FromArgb(255, 255, 140, 0), // #ff8c00
Color.FromArgb(255, 247, 99, 12), // #f7630c
Color.FromArgb(255, 202, 80, 16), // #ca5010
Color.FromArgb(255, 218, 59, 1), // #da3b01
Color.FromArgb(255, 239, 105, 80), // #ef6950
// row 1
Color.FromArgb(255, 209, 52, 56), // #d13438
Color.FromArgb(255, 255, 67, 67), // #ff4343
Color.FromArgb(255, 231, 72, 86), // #e74856
Color.FromArgb(255, 232, 17, 35), // #e81123
Color.FromArgb(255, 234, 0, 94), // #ea005e
Color.FromArgb(255, 195, 0, 82), // #c30052
// row 2
Color.FromArgb(255, 227, 0, 140), // #e3008c
Color.FromArgb(255, 191, 0, 119), // #bf0077
Color.FromArgb(255, 194, 57, 179), // #c239b3
Color.FromArgb(255, 154, 0, 137), // #9a0089
Color.FromArgb(255, 0, 120, 212), // #0078d4
Color.FromArgb(255, 0, 99, 177), // #0063b1
// row 3
Color.FromArgb(255, 142, 140, 216), // #8e8cd8
Color.FromArgb(255, 107, 105, 214), // #6b69d6
Color.FromArgb(255, 135, 100, 184), // #8764b8
Color.FromArgb(255, 116, 77, 169), // #744da9
Color.FromArgb(255, 177, 70, 194), // #b146c2
Color.FromArgb(255, 136, 23, 152), // #881798
// row 4
Color.FromArgb(255, 0, 153, 188), // #0099bc
Color.FromArgb(255, 45, 125, 154), // #2d7d9a
Color.FromArgb(255, 0, 183, 195), // #00b7c3
Color.FromArgb(255, 3, 131, 135), // #038387
Color.FromArgb(255, 0, 178, 148), // #00b294
Color.FromArgb(255, 1, 133, 116), // #018574
// row 5
Color.FromArgb(255, 0, 204, 106), // #00cc6a
Color.FromArgb(255, 16, 137, 62), // #10893e
Color.FromArgb(255, 122, 117, 116), // #7a7574
Color.FromArgb(255, 93, 90, 88), // #5d5a58
Color.FromArgb(255, 104, 118, 138), // #68768a
Color.FromArgb(255, 81, 92, 107), // #515c6b
// row 6
Color.FromArgb(255, 86, 124, 115), // #567c73
Color.FromArgb(255, 72, 104, 96), // #486860
Color.FromArgb(255, 73, 130, 5), // #498205
Color.FromArgb(255, 16, 124, 16), // #107c10
Color.FromArgb(255, 118, 118, 118), // #767676
Color.FromArgb(255, 76, 74, 72), // #4c4a48
// row 7
Color.FromArgb(255, 105, 121, 126), // #69797e
Color.FromArgb(255, 74, 84, 89), // #4a5459
Color.FromArgb(255, 100, 124, 100), // #647c64
Color.FromArgb(255, 82, 94, 84), // #525e54
Color.FromArgb(255, 132, 117, 69), // #847545
Color.FromArgb(255, 126, 115, 95), // #7e735f
];
private readonly SettingsModel _settings;
private readonly UISettings _uiSettings;
private readonly IThemeService _themeService;
private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread();
private ElementTheme? _elementThemeOverride;
private Color _currentSystemAccentColor;
public ObservableCollection<Color> Swatches => WindowsColorSwatches;
public int ThemeIndex
{
get => (int)_settings.Theme;
set => Theme = (UserTheme)value;
}
public UserTheme Theme
{
get => _settings.Theme;
set
{
if (_settings.Theme != value)
{
_settings.Theme = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ThemeIndex));
Save();
}
}
}
public ColorizationMode ColorizationMode
{
get => _settings.ColorizationMode;
set
{
if (_settings.ColorizationMode != value)
{
_settings.ColorizationMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ColorizationModeIndex));
OnPropertyChanged(nameof(IsCustomTintVisible));
OnPropertyChanged(nameof(IsCustomTintIntensityVisible));
OnPropertyChanged(nameof(IsBackgroundControlsVisible));
OnPropertyChanged(nameof(IsNoBackgroundVisible));
OnPropertyChanged(nameof(IsAccentColorControlsVisible));
if (value == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
IsColorizationDetailsExpanded = value != ColorizationMode.None;
Save();
}
}
}
public int ColorizationModeIndex
{
get => (int)_settings.ColorizationMode;
set => ColorizationMode = (ColorizationMode)value;
}
public Color ThemeColor
{
get => _settings.CustomThemeColor;
set
{
if (_settings.CustomThemeColor != value)
{
_settings.CustomThemeColor = value;
OnPropertyChanged();
if (ColorIntensity == 0)
{
ColorIntensity = 100;
}
Save();
}
}
}
public int ColorIntensity
{
get => _settings.CustomThemeColorIntensity;
set
{
_settings.CustomThemeColorIntensity = value;
OnPropertyChanged();
Save();
}
}
public string BackgroundImagePath
{
get => _settings.BackgroundImagePath ?? string.Empty;
set
{
if (_settings.BackgroundImagePath != value)
{
_settings.BackgroundImagePath = value;
OnPropertyChanged();
if (BackgroundImageOpacity == 0)
{
BackgroundImageOpacity = 100;
}
Save();
}
}
}
public int BackgroundImageOpacity
{
get => _settings.BackgroundImageOpacity;
set
{
if (_settings.BackgroundImageOpacity != value)
{
_settings.BackgroundImageOpacity = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBrightness
{
get => _settings.BackgroundImageBrightness;
set
{
if (_settings.BackgroundImageBrightness != value)
{
_settings.BackgroundImageBrightness = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBlurAmount
{
get => _settings.BackgroundImageBlurAmount;
set
{
if (_settings.BackgroundImageBlurAmount != value)
{
_settings.BackgroundImageBlurAmount = value;
OnPropertyChanged();
Save();
}
}
}
public BackgroundImageFit BackgroundImageFit
{
get => _settings.BackgroundImageFit;
set
{
if (_settings.BackgroundImageFit != value)
{
_settings.BackgroundImageFit = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BackgroundImageFitIndex));
Save();
}
}
}
public int BackgroundImageFitIndex
{
// Naming between UI facing string and enum is a bit confusing, but the enum fields
// are based on XAML Stretch enum values. So I'm choosing to keep the confusion here, close
// to the UI.
// - BackgroundImageFit.Fill corresponds to "Stretch"
// - BackgroundImageFit.UniformToFill corresponds to "Fill"
get => BackgroundImageFit switch
{
BackgroundImageFit.Fill => 1,
_ => 0,
};
set => BackgroundImageFit = value switch
{
1 => BackgroundImageFit.Fill,
_ => BackgroundImageFit.UniformToFill,
};
}
[ObservableProperty]
public partial bool IsColorizationDetailsExpanded { get; set; }
public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image;
public bool IsCustomTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image;
public bool IsNoBackgroundVisible => _settings.ColorizationMode is ColorizationMode.None;
public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor;
public AcrylicBackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f);
public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme;
public Color EffectiveThemeColor => ColorizationMode switch
{
ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
_ => Colors.Transparent,
};
// Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen).
public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f);
public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0;
public ImageSource? EffectiveBackgroundImageSource =>
ColorizationMode is ColorizationMode.Image
&& !string.IsNullOrWhiteSpace(BackgroundImagePath)
&& Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
: null;
public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeServiceOnThemeChanged;
_settings = settings;
_uiSettings = new UISettings();
_uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged;
UpdateAccentColor(_uiSettings);
Reapply();
IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None;
}
private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender));
private void UpdateAccentColor(UISettings sender)
{
_currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent);
if (ColorizationMode == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
}
private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Save()
{
SettingsModel.SaveSettings(_settings);
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Reapply()
{
// Theme services recalculates effective color and opacity based on current settings.
EffectiveBackdrop = _themeService.Current.BackdropParameters;
OnPropertyChanged(nameof(EffectiveBackdrop));
OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness));
OnPropertyChanged(nameof(EffectiveBackgroundImageSource));
OnPropertyChanged(nameof(EffectiveThemeColor));
OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount));
// LOAD BEARING:
// We need to cycle through the EffectiveTheme property to force reload of resources.
_elementThemeOverride = ElementTheme.Light;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = ElementTheme.Dark;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = null;
OnPropertyChanged(nameof(EffectiveTheme));
}
[RelayCommand]
private void ResetBackgroundImageProperties()
{
BackgroundImageBrightness = 0;
BackgroundImageBlurAmount = 0;
BackgroundImageFit = BackgroundImageFit.UniformToFill;
BackgroundImageOpacity = 100;
ColorIntensity = 0;
}
public void Dispose()
{
_uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged;
_themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
public enum BackgroundImageFit
{
Fill,
UniformToFill,
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
public enum ColorizationMode
{
None,
WindowsAccentColor,
CustomColor,
Image,
}

View File

@@ -12,6 +12,7 @@ using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.Ext.Apps.State;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -44,6 +45,9 @@ public partial class MainListPage : DynamicListPage,
private List<Scored<IListItem>>? _filteredItems;
private List<Scored<IListItem>>? _filteredApps;
private List<Scored<IListItem>>? _fallbackItems;
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
// asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
@@ -155,42 +159,18 @@ public partial class MainListPage : DynamicListPage,
public override IListItem[] GetItems()
{
if (string.IsNullOrEmpty(SearchText))
lock (_tlcManager.TopLevelCommands)
{
lock (_tlcManager.TopLevelCommands)
{
return _tlcManager
.TopLevelCommands
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
.ToArray();
}
}
else
{
lock (_tlcManager.TopLevelCommands)
{
var limitedApps = new List<Scored<IListItem>>();
// Fuzzy matching can produce a lot of results, so we want to limit the
// number of apps we show at once if it's a large set.
if (_filteredApps?.Count > 0)
{
limitedApps = _filteredApps.OrderByDescending(s => s.Score).Take(_appResultLimit).ToList();
}
var items = Enumerable.Empty<Scored<IListItem>>()
.Concat(_filteredItems is not null ? _filteredItems : [])
.Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : [])
.Concat(limitedApps)
.OrderByDescending(o => o.Score)
// Add fallback items post-sort so they are always at the end of the list
// and eventually ordered based on user preference
.Concat(_fallbackItems is not null ? _fallbackItems.Where(w => !string.IsNullOrEmpty(w.Item.Title)) : [])
.Select(s => s.Item)
.ToArray();
return items;
}
// Either return the top-level commands (no search text), or the merged and
// filtered results.
return string.IsNullOrEmpty(SearchText)
? _tlcManager.TopLevelCommands.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title)).ToArray()
: MainListPageResultFactory.Create(
_filteredItems,
_scoredFallbackItems?.ToList(),
_filteredApps,
_fallbackItems,
_appResultLimit);
}
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#pragma warning disable IDE0007 // Use implicit type
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Commands;
internal static class MainListPageResultFactory
{
/// <summary>
/// Creates a merged and ordered array of results from multiple scored input lists,
/// applying an application result limit and filtering fallback items as needed.
/// </summary>
public static IListItem[] Create(
IList<Scored<IListItem>>? filteredItems,
IList<Scored<IListItem>>? scoredFallbackItems,
IList<Scored<IListItem>>? filteredApps,
IList<Scored<IListItem>>? fallbackItems,
int appResultLimit)
{
if (appResultLimit < 0)
{
throw new ArgumentOutOfRangeException(
nameof(appResultLimit), "App result limit must be non-negative.");
}
int len1 = filteredItems?.Count ?? 0;
int len2 = scoredFallbackItems?.Count ?? 0;
// Apps are pre-sorted, so we just need to take the top N, limited by appResultLimit.
int len3 = Math.Min(filteredApps?.Count ?? 0, appResultLimit);
// Allocate the exact size of the result array.
int totalCount = len1 + len2 + len3 + GetNonEmptyFallbackItemsCount(fallbackItems);
var result = new IListItem[totalCount];
// Three-way stable merge of already-sorted lists.
int idx1 = 0, idx2 = 0, idx3 = 0;
int writePos = 0;
// Merge while all three lists have items. To maintain a stable sort, the
// priority is: list1 > list2 > list3 when scores are equal.
while (idx1 < len1 && idx2 < len2 && idx3 < len3)
{
// Using null-forgiving operator as we have already checked against lengths.
int score1 = filteredItems![idx1].Score;
int score2 = scoredFallbackItems![idx2].Score;
int score3 = filteredApps![idx3].Score;
if (score1 >= score2 && score1 >= score3)
{
result[writePos++] = filteredItems[idx1++].Item;
}
else if (score2 >= score3)
{
result[writePos++] = scoredFallbackItems[idx2++].Item;
}
else
{
result[writePos++] = filteredApps[idx3++].Item;
}
}
// Two-way merges for remaining pairs.
while (idx1 < len1 && idx2 < len2)
{
if (filteredItems![idx1].Score >= scoredFallbackItems![idx2].Score)
{
result[writePos++] = filteredItems[idx1++].Item;
}
else
{
result[writePos++] = scoredFallbackItems[idx2++].Item;
}
}
while (idx1 < len1 && idx3 < len3)
{
if (filteredItems![idx1].Score >= filteredApps![idx3].Score)
{
result[writePos++] = filteredItems[idx1++].Item;
}
else
{
result[writePos++] = filteredApps[idx3++].Item;
}
}
while (idx2 < len2 && idx3 < len3)
{
if (scoredFallbackItems![idx2].Score >= filteredApps![idx3].Score)
{
result[writePos++] = scoredFallbackItems[idx2++].Item;
}
else
{
result[writePos++] = filteredApps[idx3++].Item;
}
}
// Drain remaining items from a non-empty list.
while (idx1 < len1)
{
result[writePos++] = filteredItems![idx1++].Item;
}
while (idx2 < len2)
{
result[writePos++] = scoredFallbackItems![idx2++].Item;
}
while (idx3 < len3)
{
result[writePos++] = filteredApps![idx3++].Item;
}
// Append filtered fallback items. Fallback items are added post-sort so they are
// always at the end of the list and eventually ordered based on user preference.
if (fallbackItems is not null)
{
for (int i = 0; i < fallbackItems.Count; i++)
{
var item = fallbackItems[i].Item;
if (!string.IsNullOrEmpty(item.Title))
{
result[writePos++] = item;
}
}
}
return result;
}
private static int GetNonEmptyFallbackItemsCount(IList<Scored<IListItem>>? fallbackItems)
{
int fallbackItemsCount = 0;
if (fallbackItems is not null)
{
for (int i = 0; i < fallbackItems.Count; i++)
{
if (!string.IsNullOrEmpty(fallbackItems[i].Item.Title))
{
fallbackItemsCount++;
}
}
}
return fallbackItemsCount;
}
}
#pragma warning restore IDE0007 // Use implicit type

View File

@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class MainWindowViewModel : ObservableObject, IDisposable
{
private readonly IThemeService _themeService;
private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!;
[ObservableProperty]
public partial ImageSource? BackgroundImageSource { get; private set; }
[ObservableProperty]
public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill;
[ObservableProperty]
public partial double BackgroundImageOpacity { get; private set; }
[ObservableProperty]
public partial Color BackgroundImageTint { get; private set; }
[ObservableProperty]
public partial double BackgroundImageTintIntensity { get; private set; }
[ObservableProperty]
public partial int BackgroundImageBlurAmount { get; private set; }
[ObservableProperty]
public partial double BackgroundImageBrightness { get; private set; }
[ObservableProperty]
public partial bool ShowBackgroundImage { get; private set; }
public MainWindowViewModel(IThemeService themeService)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeService_ThemeChanged;
}
private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_uiDispatcherQueue.TryEnqueue(() =>
{
BackgroundImageSource = _themeService.Current.BackgroundImageSource;
BackgroundImageStretch = _themeService.Current.BackgroundImageStretch;
BackgroundImageOpacity = _themeService.Current.BackgroundImageOpacity;
BackgroundImageBrightness = _themeService.Current.BackgroundBrightness;
BackgroundImageTint = _themeService.Current.Tint;
BackgroundImageTintIntensity = _themeService.Current.TintIntensity;
BackgroundImageBlurAmount = _themeService.Current.BlurAmount;
ShowBackgroundImage = BackgroundImageSource != null;
});
}
public void Dispose()
{
_themeService.ThemeChanged -= ThemeService_ThemeChanged;
GC.SuppressFinalize(this);
}
}

View File

@@ -23,11 +23,12 @@
<PackageReference Include="CommunityToolkit.Common" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="AdaptiveCards.Templating" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="Microsoft.Bot.AdaptiveExpressions.Core" />
<PackageReference Include="AdaptiveCards.ObjectModel.WinUI3" GeneratePathProperty="true">
<ExcludeAssets>compile</ExcludeAssets>
</PackageReference>
<PackageReference Include="AdaptiveCards.Rendering.WinUI3" GeneratePathProperty="True" >
<PackageReference Include="AdaptiveCards.Rendering.WinUI3" GeneratePathProperty="True">
<ExcludeAssets>compile</ExcludeAssets>
</PackageReference>

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
@@ -411,6 +411,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Pick background image.
/// </summary>
public static string builtin_settings_appearance_pick_background_image_title {
get {
return ResourceManager.GetString("builtin_settings_appearance_pick_background_image_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} extensions found.
/// </summary>

View File

@@ -239,4 +239,7 @@
<data name="builtin_settings_extension_n_extensions_installed" xml:space="preserve">
<value>{0} extensions installed</value>
</data>
<data name="builtin_settings_appearance_pick_background_image_title" xml:space="preserve">
<value>Pick background image</value>
</data>
</root>

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
public sealed record AcrylicBackdropParameters(Color TintColor, Color FallbackColor, float TintOpacity, float LuminosityOpacity);

View File

@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Provides theme-related values for the Command Palette and notifies listeners about
/// changes that affect visual appearance (theme, tint, background image, and backdrop).
/// </summary>
/// <remarks>
/// Implementations are expected to monitor system/app theme changes and raise
/// <see cref="ThemeChanged"/> accordingly. Consumers should call <see cref="Initialize"/>
/// once to hook required sources and then query properties/methods for the current visuals.
/// </remarks>
public interface IThemeService
{
/// <summary>
/// Occurs when the effective theme or any visual-affecting setting changes.
/// </summary>
/// <remarks>
/// Triggered for changes such as app theme (light/dark/default), background image,
/// tint/accent, or backdrop parameters that would require UI to refresh styling.
/// </remarks>
event EventHandler<ThemeChangedEventArgs>? ThemeChanged;
/// <summary>
/// Initializes the theme service and starts listening for theme-related changes.
/// </summary>
/// <remarks>
/// Safe to call once during application startup before consuming the service.
/// </remarks>
void Initialize();
/// <summary>
/// Gets the current theme settings.
/// </summary>
ThemeSnapshot Current { get; }
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Event arguments for theme-related changes. </summary>
public class ThemeChangedEventArgs : EventArgs;

View File

@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Represents a snapshot of theme-related visual settings, including accent color, theme preference, and background
/// image configuration, for use in rendering the Command Palette UI.
/// </summary>
public sealed class ThemeSnapshot
{
/// <summary>
/// Gets the accent tint color used by the Command Palette visuals.
/// </summary>
public required Color Tint { get; init; }
/// <summary>
/// Gets the accent tint color used by the Command Palette visuals.
/// </summary>
public required float TintIntensity { get; init; }
/// <summary>
/// Gets the configured application theme preference.
/// </summary>
public required ElementTheme Theme { get; init; }
/// <summary>
/// Gets the image source to render as the background, if any.
/// </summary>
/// <remarks>
/// Returns <see langword="null"/> when no background image is configured.
/// </remarks>
public required ImageSource? BackgroundImageSource { get; init; }
/// <summary>
/// Gets the stretch mode used to lay out the background image.
/// </summary>
public required Stretch BackgroundImageStretch { get; init; }
/// <summary>
/// Gets the opacity applied to the background image.
/// </summary>
/// <value>
/// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque.
/// </value>
public required double BackgroundImageOpacity { get; init; }
/// <summary>
/// Gets the effective acrylic backdrop parameters based on current settings and theme.
/// </summary>
/// <returns>The resolved <c>AcrylicBackdropParameters</c> to apply.</returns>
public required AcrylicBackdropParameters BackdropParameters { get; init; }
public required int BlurAmount { get; init; }
public required float BackgroundBrightness { get; init; }
}

View File

@@ -11,7 +11,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.UI;
using Windows.Foundation;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -60,6 +62,26 @@ public partial class SettingsModel : ObservableObject
public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan;
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
public UserTheme Theme { get; set; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
public Color CustomThemeColor { get; set; } = Colors.Transparent;
public int CustomThemeColorIntensity { get; set; } = 100;
public int BackgroundImageOpacity { get; set; } = 20;
public int BackgroundImageBlurAmount { get; set; }
public int BackgroundImageBrightness { get; set; }
public BackgroundImageFit BackgroundImageFit { get; set; }
public string? BackgroundImagePath { get; set; }
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
@@ -282,3 +304,11 @@ public enum MonitorBehavior
InPlace = 3,
ToLast = 4,
}
public enum EscapeKeyBehavior
{
ClearSearchFirstThenGoBack = 0,
AlwaysGoBack = 1,
AlwaysDismiss = 2,
AlwaysHide = 3,
}

View File

@@ -4,6 +4,8 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
@@ -29,6 +31,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
public AppearanceSettingsViewModel Appearance { get; }
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
@@ -160,6 +164,16 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
public int EscapeKeyBehaviorIndex
{
get => (int)_settings.EscapeKeyBehaviorSetting;
set
{
_settings.EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value;
Save();
}
}
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = [];
public SettingsExtensionsViewModel Extensions { get; }
@@ -169,6 +183,9 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_settings = settings;
_serviceProvider = serviceProvider;
var themeService = serviceProvider.GetRequiredService<IThemeService>();
Appearance = new AppearanceSettingsViewModel(themeService, _settings);
var activeProviders = GetCommandProviders();
var allProviderSettings = _settings.ProviderSettings;

View File

@@ -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<string, object?> GetProperties()
{
return new Dictionary<string, object?>
{
[WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage,
};
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
public enum UserTheme
{
Default,
Light,
Dark,
}

View File

@@ -4,19 +4,23 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
xmlns:local="using:Microsoft.CmdPal.UI">
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:services="using:Microsoft.CmdPal.UI.Services">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Other merged dictionaries here -->
<ResourceDictionary Source="Styles/Colors.xaml" />
<ResourceDictionary Source="Styles/TextBlock.xaml" />
<ResourceDictionary Source="Styles/TextBox.xaml" />
<ResourceDictionary Source="Styles/Settings.xaml" />
<ResourceDictionary Source="Controls/Tag.xaml" />
<ResourceDictionary Source="Controls/KeyVisual/KeyVisual.xaml" />
<ResourceDictionary Source="Controls/IsEnabledTextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/Colors.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TextBox.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/Settings.xaml" />
<ResourceDictionary Source="ms-appx:///Controls/Tag.xaml" />
<ResourceDictionary Source="ms-appx:///Controls/KeyVisual/KeyVisual.xaml" />
<ResourceDictionary Source="ms-appx:///Controls/IsEnabledTextBlock.xaml" />
<!-- Default theme dictionary -->
<ResourceDictionary Source="ms-appx:///Styles/Theme.Normal.xaml" />
<services:MutableOverridesDictionary />
</ResourceDictionary.MergedDictionaries>
<!-- Other app resources here -->

View File

@@ -24,9 +24,11 @@ using Microsoft.CmdPal.Ext.WindowsTerminal;
using Microsoft.CmdPal.Ext.WindowWalker;
using Microsoft.CmdPal.Ext.WinGet;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerToys.Telemetry;
@@ -112,6 +114,17 @@ public partial class App : Application
// Root services
services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext());
AddBuiltInCommands(services);
AddCoreServices(services);
AddUIServices(services);
return services.BuildServiceProvider();
}
private static void AddBuiltInCommands(ServiceCollection services)
{
// Built-in Commands. Order matters - this is the order they'll be presented by default.
var allApps = new AllAppsCommandProvider();
var files = new IndexerCommandsProvider();
@@ -154,17 +167,32 @@ public partial class App : Application
services.AddSingleton<ICommandProvider, TimeDateCommandsProvider>();
services.AddSingleton<ICommandProvider, SystemCommandExtensionProvider>();
services.AddSingleton<ICommandProvider, RemoteDesktopCommandProvider>();
}
private static void AddUIServices(ServiceCollection services)
{
// Models
services.AddSingleton<TopLevelCommandManager>();
services.AddSingleton<AliasManager>();
services.AddSingleton<HotkeyManager>();
var sm = SettingsModel.LoadSettings();
services.AddSingleton(sm);
var state = AppStateModel.LoadState();
services.AddSingleton(state);
services.AddSingleton<IExtensionService, ExtensionService>();
// Services
services.AddSingleton<TopLevelCommandManager>();
services.AddSingleton<AliasManager>();
services.AddSingleton<HotkeyManager>();
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<TrayIconService>();
services.AddSingleton<IThemeService, ThemeService>();
services.AddSingleton<ResourceSwapper>();
}
private static void AddCoreServices(ServiceCollection services)
{
// Core services
services.AddSingleton<IExtensionService, ExtensionService>();
services.AddSingleton<IRunHistoryService, RunHistoryService>();
services.AddSingleton<IRootPageService, PowerToysRootPageService>();
@@ -174,7 +202,5 @@ public partial class App : Application
// ViewModels
services.AddSingleton<ShellViewModel>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
return services.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,449 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Numerics;
using ManagedCommon;
using Microsoft.Graphics.Canvas.Effects;
using Microsoft.UI;
using Microsoft.UI.Composition;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Controls;
internal sealed partial class BlurImageControl : Control
{
private const string ImageSourceParameterName = "ImageSource";
private const string BrightnessEffectName = "Brightness";
private const string BrightnessOverlayEffectName = "BrightnessOverlay";
private const string BlurEffectName = "Blur";
private const string TintBlendEffectName = "TintBlend";
private const string TintEffectName = "Tint";
#pragma warning disable CA1507 // Use nameof to express symbol names ... some of these refer to effect properties that are separate from the class properties
private static readonly string BrightnessSource1AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source1Amount");
private static readonly string BrightnessSource2AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source2Amount");
private static readonly string BrightnessOverlayColorEffectProperty = GetPropertyName(BrightnessOverlayEffectName, "Color");
private static readonly string BlurBlurAmountEffectProperty = GetPropertyName(BlurEffectName, "BlurAmount");
private static readonly string TintColorEffectProperty = GetPropertyName(TintEffectName, "Color");
#pragma warning restore CA1507
private static readonly string[] AnimatableProperties = [
BrightnessSource1AmountEffectProperty,
BrightnessSource2AmountEffectProperty,
BrightnessOverlayColorEffectProperty,
BlurBlurAmountEffectProperty,
TintColorEffectProperty
];
public static readonly DependencyProperty ImageSourceProperty =
DependencyProperty.Register(
nameof(ImageSource),
typeof(ImageSource),
typeof(BlurImageControl),
new PropertyMetadata(null, OnImageChanged));
public static readonly DependencyProperty ImageStretchProperty =
DependencyProperty.Register(
nameof(ImageStretch),
typeof(Stretch),
typeof(BlurImageControl),
new PropertyMetadata(Stretch.UniformToFill, OnImageStretchChanged));
public static readonly DependencyProperty ImageOpacityProperty =
DependencyProperty.Register(
nameof(ImageOpacity),
typeof(double),
typeof(BlurImageControl),
new PropertyMetadata(1.0, OnOpacityChanged));
public static readonly DependencyProperty ImageBrightnessProperty =
DependencyProperty.Register(
nameof(ImageBrightness),
typeof(double),
typeof(BlurImageControl),
new PropertyMetadata(1.0, OnBrightnessChanged));
public static readonly DependencyProperty BlurAmountProperty =
DependencyProperty.Register(
nameof(BlurAmount),
typeof(double),
typeof(BlurImageControl),
new PropertyMetadata(0.0, OnBlurAmountChanged));
public static readonly DependencyProperty TintColorProperty =
DependencyProperty.Register(
nameof(TintColor),
typeof(Color),
typeof(BlurImageControl),
new PropertyMetadata(Colors.Transparent, OnVisualPropertyChanged));
public static readonly DependencyProperty TintIntensityProperty =
DependencyProperty.Register(
nameof(TintIntensity),
typeof(double),
typeof(BlurImageControl),
new PropertyMetadata(0.0, OnVisualPropertyChanged));
private Compositor? _compositor;
private SpriteVisual? _effectVisual;
private CompositionEffectBrush? _effectBrush;
private CompositionSurfaceBrush? _imageBrush;
public BlurImageControl()
{
this.DefaultStyleKey = typeof(BlurImageControl);
this.Loaded += OnLoaded;
this.SizeChanged += OnSizeChanged;
}
public ImageSource ImageSource
{
get => (ImageSource)GetValue(ImageSourceProperty);
set => SetValue(ImageSourceProperty, value);
}
public Stretch ImageStretch
{
get => (Stretch)GetValue(ImageStretchProperty);
set => SetValue(ImageStretchProperty, value);
}
public double ImageOpacity
{
get => (double)GetValue(ImageOpacityProperty);
set => SetValue(ImageOpacityProperty, value);
}
public double ImageBrightness
{
get => (double)GetValue(ImageBrightnessProperty);
set => SetValue(ImageBrightnessProperty, Math.Clamp(value, -1, 1));
}
public double BlurAmount
{
get => (double)GetValue(BlurAmountProperty);
set => SetValue(BlurAmountProperty, value);
}
public Color TintColor
{
get => (Color)GetValue(TintColorProperty);
set => SetValue(TintColorProperty, value);
}
public double TintIntensity
{
get => (double)GetValue(TintIntensityProperty);
set => SetValue(TintIntensityProperty, value);
}
private static void OnImageStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is BlurImageControl control && control._imageBrush != null)
{
control._imageBrush.Stretch = ConvertStretch((Stretch)e.NewValue);
}
}
private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is BlurImageControl control && control._compositor != null)
{
control.UpdateEffect();
}
}
private static void OnOpacityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is BlurImageControl control && control._effectVisual != null)
{
control._effectVisual.Opacity = (float)(double)e.NewValue;
}
}
private static void OnBlurAmountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is BlurImageControl control && control._effectBrush != null)
{
control.UpdateEffect();
}
}
private static void OnBrightnessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is BlurImageControl control && control._effectBrush != null)
{
control.UpdateEffect();
}
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
InitializeComposition();
}
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
if (_effectVisual != null)
{
_effectVisual.Size = new Vector2(
(float)Math.Max(1, e.NewSize.Width),
(float)Math.Max(1, e.NewSize.Height));
}
}
private static void OnImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not BlurImageControl control)
{
return;
}
control.EnsureEffect(force: true);
control.UpdateEffect();
}
private void InitializeComposition()
{
var visual = ElementCompositionPreview.GetElementVisual(this);
_compositor = visual.Compositor;
_effectVisual = _compositor.CreateSpriteVisual();
_effectVisual.Size = new Vector2(
(float)Math.Max(1, ActualWidth),
(float)Math.Max(1, ActualHeight));
_effectVisual.Opacity = (float)ImageOpacity;
ElementCompositionPreview.SetElementChildVisual(this, _effectVisual);
UpdateEffect();
}
private void EnsureEffect(bool force = false)
{
if (_compositor is null)
{
return;
}
if (_effectBrush is not null && !force)
{
return;
}
var imageSource = new CompositionEffectSourceParameter(ImageSourceParameterName);
// 1) Brightness via ArithmeticCompositeEffect
// We blend between the original image and either black or white,
// depending on whether we want to darken or brighten. BrightnessEffect isn't supported
// in the composition graph.
var brightnessEffect = new ArithmeticCompositeEffect
{
Name = BrightnessEffectName,
Source1 = imageSource, // original image
Source2 = new ColorSourceEffect
{
Name = BrightnessOverlayEffectName,
Color = Colors.Black, // we'll swap black/white via properties
},
MultiplyAmount = 0.0f,
Source1Amount = 1.0f, // original
Source2Amount = 0.0f, // overlay
Offset = 0.0f,
};
// 2) Blur
var blurEffect = new GaussianBlurEffect
{
Name = BlurEffectName,
BlurAmount = 0.0f,
BorderMode = EffectBorderMode.Hard,
Optimization = EffectOptimization.Balanced,
Source = brightnessEffect,
};
// 3) Tint (always in the chain; intensity via alpha)
var tintEffect = new BlendEffect
{
Name = TintBlendEffectName,
Background = blurEffect,
Foreground = new ColorSourceEffect
{
Name = TintEffectName,
Color = Colors.Transparent,
},
Mode = BlendEffectMode.Multiply,
};
var effectFactory = _compositor.CreateEffectFactory(tintEffect, AnimatableProperties);
_effectBrush?.Dispose();
_effectBrush = effectFactory.CreateBrush();
// Set initial source
if (ImageSource is not null)
{
_imageBrush ??= _compositor.CreateSurfaceBrush();
LoadImageAsync(ImageSource);
_effectBrush.SetSourceParameter(ImageSourceParameterName, _imageBrush);
}
else
{
_effectBrush.SetSourceParameter(ImageSourceParameterName, _compositor.CreateBackdropBrush());
}
if (_effectVisual is not null)
{
_effectVisual.Brush = _effectBrush;
}
}
private void UpdateEffect()
{
if (_compositor is null)
{
return;
}
EnsureEffect();
if (_effectBrush is null)
{
return;
}
var props = _effectBrush.Properties;
// Brightness
var b = (float)Math.Clamp(ImageBrightness, -1.0, 1.0);
float source1Amount;
float source2Amount;
Color overlayColor;
if (b >= 0)
{
// Brighten: blend towards white
overlayColor = Colors.White;
source1Amount = 1.0f - b; // original image contribution
source2Amount = b; // white overlay contribution
}
else
{
// Darken: blend towards black
overlayColor = Colors.Black;
var t = -b; // 0..1
source1Amount = 1.0f - t; // original image
source2Amount = t; // black overlay
}
props.InsertScalar(BrightnessSource1AmountEffectProperty, source1Amount);
props.InsertScalar(BrightnessSource2AmountEffectProperty, source2Amount);
props.InsertColor(BrightnessOverlayColorEffectProperty, overlayColor);
// Blur
props.InsertScalar(BlurBlurAmountEffectProperty, (float)BlurAmount);
// Tint
var tintColor = TintColor;
var clampedIntensity = (float)Math.Clamp(TintIntensity, 0.0, 1.0);
var adjustedColor = Color.FromArgb(
(byte)(clampedIntensity * 255),
tintColor.R,
tintColor.G,
tintColor.B);
props.InsertColor(TintColorEffectProperty, adjustedColor);
}
private void LoadImageAsync(ImageSource imageSource)
{
try
{
if (imageSource is not Microsoft.UI.Xaml.Media.Imaging.BitmapImage bitmapImage)
{
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)
{
return stretch switch
{
Stretch.None => CompositionStretch.None,
Stretch.Fill => CompositionStretch.Fill,
Stretch.Uniform => CompositionStretch.Uniform,
Stretch.UniformToFill => CompositionStretch.UniformToFill,
_ => CompositionStretch.UniformToFill,
};
}
private static string GetPropertyName(string effectName, string propertyName) => $"{effectName}.{propertyName}";
}

View File

@@ -0,0 +1,216 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.ColorPalette"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI.Controls"
xmlns:localConverters="using:Microsoft.CmdPal.UI.Converters"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:toolkitConverters="using:CommunityToolkit.WinUI.Converters"
xmlns:ui="using:CommunityToolkit.WinUI"
mc:Ignorable="d">
<UserControl.Resources>
<toolkitConverters:ColorToDisplayNameConverter x:Key="ColorToDisplayNameConverter" />
<localConverters:ContrastBrushConverter x:Key="ContrastBrushConverter" />
<Style x:Key="PaletteGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{ThemeResource SystemControlForegroundBaseHighBrush}" />
<Setter Property="TabNavigation" Value="Local" />
<Setter Property="IsHoldingEnabled" Value="True" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="MinWidth" Value="32" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
<Setter Property="FocusVisualMargin" Value="-2" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
<Grid
x:Name="ContentBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{TemplateBinding CornerRadius}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<ScaleTransform x:Name="ContentBorderScale" />
</Grid.RenderTransform>
<ContentPresenter
x:Name="ContentPresenter"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
ContentTransitions="{TemplateBinding ContentTransitions}" />
<!--
The 'Xg' text simulates the amount of space one line of text will occupy.
In the DataPlaceholder state, the Content is not loaded yet so we
approximate the size of the item using placeholder text.
-->
<TextBlock
x:Name="PlaceholderTextBlock"
Margin="{TemplateBinding Padding}"
AutomationProperties.AccessibilityView="Raw"
Foreground="{x:Null}"
IsHitTestVisible="False"
Text="Xg"
Visibility="Collapsed" />
<Rectangle
x:Name="PlaceholderRect"
Fill="{ThemeResource ListViewItemPlaceholderBackground}"
Visibility="Collapsed" />
<Rectangle
x:Name="BorderRectangle"
IsHitTestVisible="False"
Opacity="0"
RadiusX="6"
RadiusY="6"
Stroke="{ThemeResource SystemControlHighlightListAccentLowBrush}"
StrokeThickness="2" />
<Border
x:Name="MultiSelectSquare"
Width="20"
Height="20"
Margin="0,2,2,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Background="{ThemeResource SystemControlBackgroundChromeMediumBrush}"
CornerRadius="6"
Visibility="Collapsed">
<FontIcon
x:Name="MultiSelectCheck"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="16"
Foreground="{ThemeResource SystemControlForegroundBaseMediumHighBrush}"
Glyph="&#xE73E;"
Opacity="0" />
</Border>
<Border
x:Name="MultiArrangeOverlayTextBorder"
Height="20"
MinWidth="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{ThemeResource SystemControlBackgroundAccentBrush}"
BorderBrush="{ThemeResource SystemControlBackgroundChromeWhiteBrush}"
BorderThickness="2"
CornerRadius="6"
IsHitTestVisible="False"
Opacity="0">
<TextBlock
x:Name="MultiArrangeOverlayText"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
IsHitTestVisible="False"
Opacity="0"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.DragItemsCount}" />
</Border>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="FocusStates">
<VisualState x:Name="Focused">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderRectangle" Storyboard.TargetProperty="Visibility">
<DiscreteObjectKeyFrame KeyTime="0" Value="Collapsed" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
<VisualState x:Name="Unfocused" />
</VisualStateGroup>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal">
<Storyboard>
<PointerUpThemeAnimation Storyboard.TargetName="ContentPresenter" />
</Storyboard>
</VisualState>
<VisualState x:Name="PointerOver">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="BorderRectangle"
Storyboard.TargetProperty="Opacity"
To="1"
Duration="0" />
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderRectangle" Storyboard.TargetProperty="Stroke">
<DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentBorder" Storyboard.TargetProperty="FocusVisualSecondaryBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentBorder" Storyboard.TargetProperty="FocusVisualSecondaryThickness">
<DiscreteObjectKeyFrame KeyTime="0" Value="2" />
</ObjectAnimationUsingKeyFrames>
<PointerUpThemeAnimation Storyboard.TargetName="ContentPresenter" />
<DoubleAnimation
Storyboard.TargetName="MultiSelectCheck"
Storyboard.TargetProperty="Opacity"
To="1"
Duration="0" />
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="MultiSelectSquare" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid HorizontalAlignment="Stretch">
<GridView
Margin="0"
Padding="0"
IsItemClickEnabled="True"
ItemClick="ListViewBase_OnItemClick"
ItemContainerStyle="{StaticResource PaletteGridViewItemStyle}"
ItemsSource="{x:Bind PaletteColors}"
SelectionMode="None">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<controls:UniformGrid ui:FrameworkElementExtensions.AncestorType="local:ColorPalette" Columns="{Binding (ui:FrameworkElementExtensions.Ancestor).CustomPaletteColumnCount, RelativeSource={RelativeSource Self}}" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemTemplate>
<DataTemplate x:DataType="Color">
<Border
Margin="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
AutomationProperties.Name="{Binding Converter={StaticResource ColorToDisplayNameConverter}}"
CornerRadius="4"
ToolTipService.ToolTip="{Binding Converter={StaticResource ColorToDisplayNameConverter}}">
<Border.Background>
<SolidColorBrush Color="{Binding}" />
</Border.Background>
</Border>
</DataTemplate>
</GridView.ItemTemplate>
</GridView>
</Grid>
</UserControl>

View File

@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class ColorPalette : UserControl
{
public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection<Color>), typeof(ColorPalette), null!)!;
public static readonly DependencyProperty CustomPaletteColumnCountProperty = DependencyProperty.Register(nameof(CustomPaletteColumnCount), typeof(int), typeof(ColorPalette), new PropertyMetadata(10))!;
public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPalette), new PropertyMetadata(null!))!;
public event EventHandler<Color?>? SelectedColorChanged;
private Color? _selectedColor;
public Color? SelectedColor
{
get => _selectedColor;
set
{
if (_selectedColor != value)
{
_selectedColor = value;
if (value is not null)
{
SetValue(SelectedColorProperty, value);
}
else
{
ClearValue(SelectedColorProperty);
}
}
}
}
public ObservableCollection<Color> PaletteColors
{
get => (ObservableCollection<Color>)GetValue(PaletteColorsProperty)!;
set => SetValue(PaletteColorsProperty, value);
}
public int CustomPaletteColumnCount
{
get => (int)GetValue(CustomPaletteColumnCountProperty);
set => SetValue(CustomPaletteColumnCountProperty, value);
}
public ColorPalette()
{
PaletteColors = [];
InitializeComponent();
}
private void ListViewBase_OnItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is Color color)
{
SelectedColor = color;
SelectedColorChanged?.Invoke(this, color);
}
}
}

View File

@@ -0,0 +1,90 @@
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.ColorPickerButton"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="300"
d:DesignWidth="400"
mc:Ignorable="d">
<UserControl.Resources>
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<converters:BoolToVisibilityConverter
x:Key="BoolToVisibilityInvertedConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
</UserControl.Resources>
<StackPanel Orientation="Horizontal" Spacing="8">
<DropDownButton Padding="{x:Bind ToDropDownPadding(HasSelectedColor), Mode=OneWay}">
<Grid>
<TextBlock x:Uid="OptionalColorPickerButton_UnsetTextBlock" Visibility="{x:Bind HasSelectedColor, Mode=OneWay, Converter={StaticResource BoolToVisibilityInvertedConverter}}" />
<Border
x:Name="ColorPreviewBorder"
Width="48"
Height="24"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{ThemeResource ControlCornerRadius}"
Visibility="{x:Bind HasSelectedColor, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Border.Background>
<SolidColorBrush Color="{x:Bind SelectedColor, Mode=OneWay}" />
</Border.Background>
</Border>
</Grid>
<DropDownButton.Flyout>
<Flyout
x:Name="ColorPickerFlyout"
Opened="FlyoutBase_OnOpened"
Placement="Bottom"
ShouldConstrainToRootBounds="False">
<Flyout.FlyoutPresenterStyle>
<Style BasedOn="{StaticResource DefaultFlyoutPresenterStyle}" TargetType="FlyoutPresenter">
<Setter Property="MinWidth" Value="660" />
</Style>
</Flyout.FlyoutPresenterStyle>
<StackPanel
x:Name="FlyoutRoot"
Orientation="Horizontal"
SizeChanged="FlyoutRoot_OnSizeChanged"
Spacing="20">
<!-- Left column: Preset colors and reset button -->
<StackPanel Margin="2">
<TextBlock
x:Uid="OptionalColorPickerButton_WindowsColorsSectionHeading"
Margin="0,0,0,12"
Style="{StaticResource BodyTextBlockStyle}" />
<controls:ColorPalette
HorizontalAlignment="Left"
VerticalAlignment="Top"
CustomPaletteColumnCount="9"
PaletteColors="{x:Bind PaletteColors}"
SelectedColorChanged="ColorPalette_OnSelectedColorChanged" />
</StackPanel>
<!-- Right column: Spectrum -->
<StackPanel>
<TextBlock
x:Uid="OptionalColorPickerButton_CustomColorsSectionHeading"
Margin="0,0,0,12"
Style="{StaticResource BodyTextBlockStyle}" />
<ColorPicker
IsAlphaEnabled="{x:Bind IsAlphaEnabled, Mode=OneWay}"
IsAlphaSliderVisible="{x:Bind IsAlphaEnabled, Mode=OneWay}"
IsAlphaTextInputVisible="{x:Bind IsAlphaEnabled, Mode=OneWay}"
IsColorChannelTextInputVisible="True"
IsColorSliderVisible="True"
IsHexInputVisible="True"
IsMoreButtonVisible="True"
Color="{x:Bind SelectedColor, Mode=TwoWay}" />
</StackPanel>
</StackPanel>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,146 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using ManagedCommon;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class ColorPickerButton : UserControl
{
public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection<Color>), typeof(ColorPickerButton), new PropertyMetadata(new ObservableCollection<Color>()))!;
public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(Colors.Black))!;
public static readonly DependencyProperty IsAlphaEnabledProperty = DependencyProperty.Register(nameof(IsAlphaEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(defaultValue: false))!;
public static readonly DependencyProperty IsValueEditorEnabledProperty = DependencyProperty.Register(nameof(IsValueEditorEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!;
public static readonly DependencyProperty HasSelectedColorProperty = DependencyProperty.Register(nameof(HasSelectedColor), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!;
private Color _selectedColor;
public Color SelectedColor
{
get
{
return _selectedColor;
}
set
{
if (_selectedColor != value)
{
_selectedColor = value;
SetValue(SelectedColorProperty, value);
HasSelectedColor = true;
}
}
}
public bool HasSelectedColor
{
get { return (bool)GetValue(HasSelectedColorProperty); }
set { SetValue(HasSelectedColorProperty, value); }
}
public bool IsAlphaEnabled
{
get => (bool)GetValue(IsAlphaEnabledProperty);
set => SetValue(IsAlphaEnabledProperty, value);
}
public bool IsValueEditorEnabled
{
get { return (bool)GetValue(IsValueEditorEnabledProperty); }
set { SetValue(IsValueEditorEnabledProperty, value); }
}
public ObservableCollection<Color> PaletteColors
{
get { return (ObservableCollection<Color>)GetValue(PaletteColorsProperty); }
set { SetValue(PaletteColorsProperty, value); }
}
public ColorPickerButton()
{
this.InitializeComponent();
IsEnabledChanged -= ColorPickerButton_IsEnabledChanged;
SetEnabledState();
IsEnabledChanged += ColorPickerButton_IsEnabledChanged;
}
private void ColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
{
SetEnabledState();
}
private void SetEnabledState()
{
if (this.IsEnabled)
{
ColorPreviewBorder.Opacity = 1;
}
else
{
ColorPreviewBorder.Opacity = 0.2;
}
}
private void ColorPalette_OnSelectedColorChanged(object? sender, Color? e)
{
if (e.HasValue)
{
HasSelectedColor = true;
SelectedColor = e.Value;
}
}
private void FlyoutBase_OnOpened(object? sender, object e)
{
if (sender is not Flyout flyout || (flyout.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter)
{
return;
}
FlyoutRoot!.UpdateLayout();
flyoutPresenter.UpdateLayout();
// Logger.LogInfo($"FlyoutBase_OnOpened: {flyoutPresenter}, {FlyoutRoot!.ActualWidth}");
flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth;
flyoutPresenter.MinWidth = 660;
flyoutPresenter.Width = FlyoutRoot!.ActualWidth;
}
private void FlyoutRoot_OnSizeChanged(object sender, SizeChangedEventArgs e)
{
if ((ColorPickerFlyout!.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter)
{
return;
}
FlyoutRoot!.UpdateLayout();
flyoutPresenter.UpdateLayout();
flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth;
flyoutPresenter.MinWidth = 660;
flyoutPresenter.Width = FlyoutRoot!.ActualWidth;
}
private Thickness ToDropDownPadding(bool hasColor)
{
return hasColor ? new Thickness(3, 3, 8, 3) : new Thickness(8, 4, 8, 4);
}
private void ResetButton_Click(object sender, RoutedEventArgs e)
{
HasSelectedColor = false;
ColorPickerFlyout?.Hide();
}
}

View File

@@ -142,9 +142,9 @@
FontSize="16"
Glyph="&#xE713;" />
<TextBlock
x:Uid="SettingsButtonTextBlock"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="Settings" />
Style="{StaticResource CaptionTextBlockStyle}" />
</StackPanel>
</Button>
<TextBlock

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.CommandPalettePreview"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:h="using:Microsoft.CmdPal.UI.Helpers"
xmlns:local="using:Microsoft.CmdPal.UI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Border
Width="200"
Height="120"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8"
Translation="0,0,8">
<Grid>
<Border
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
Visibility="{x:Bind h:BindTransformers.NegateVisibility(ShowBackgroundImage), Mode=OneWay}">
<Border.Background>
<AcrylicBrush
FallbackColor="{x:Bind PreviewBackgroundColor, Mode=OneWay}"
TintColor="{x:Bind PreviewBackgroundColor, Mode=OneWay}"
TintOpacity="{x:Bind PreviewBackgroundOpacity, Mode=OneWay}" />
</Border.Background>
</Border>
<local:BlurImageControl
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BlurAmount="{x:Bind PreviewBackgroundImageBlurAmount, Mode=OneWay}"
ImageBrightness="{x:Bind PreviewBackgroundImageBrightness, Mode=OneWay}"
ImageSource="{x:Bind PreviewBackgroundImageSource, Mode=OneWay}"
ImageStretch="{x:Bind ToStretch(PreviewBackgroundImageFit), Mode=OneWay}"
IsHitTestVisible="False"
TintColor="{x:Bind PreviewBackgroundImageTint, Mode=OneWay}"
TintIntensity="{x:Bind ToTintIntensity(PreviewBackgroundImageTintIntensity), Mode=OneWay}"
Visibility="{x:Bind ShowBackgroundImage, Mode=OneWay}" />
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="100" />
<RowDefinition Height="20" />
</Grid.RowDefinitions>
<Border
x:Name="ContentPreview"
Grid.Row="0"
Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="20" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border BorderBrush="{ThemeResource CmdPal.TopBarBorderBrush}" BorderThickness="0,0,0,1" />
</Grid>
</Border>
<Border
x:Name="CommandBarPreview"
Grid.Row="1"
Background="{ThemeResource LayerOnAcrylicSecondaryBackgroundBrush}"
BorderBrush="{ThemeResource CmdPal.CommandBarBorderBrush}"
BorderThickness="0,1,0,0" />
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,123 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class CommandPalettePreview : UserControl
{
public static readonly DependencyProperty PreviewBackgroundOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, PropertyChangedCallback));
public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
public static readonly DependencyProperty PreviewBackgroundImageFitProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageFit), typeof(BackgroundImageFit), typeof(CommandPalettePreview), new PropertyMetadata(default(BackgroundImageFit)));
public static readonly DependencyProperty PreviewBackgroundImageBrightnessProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBrightness), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
public static readonly DependencyProperty PreviewBackgroundImageBlurAmountProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBlurAmount), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
public static readonly DependencyProperty PreviewBackgroundImageTintProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTint), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
public static readonly DependencyProperty PreviewBackgroundImageTintIntensityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTintIntensity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed));
public BackgroundImageFit PreviewBackgroundImageFit
{
get { return (BackgroundImageFit)GetValue(PreviewBackgroundImageFitProperty); }
set { SetValue(PreviewBackgroundImageFitProperty, value); }
}
public double PreviewBackgroundOpacity
{
get { return (double)GetValue(PreviewBackgroundOpacityProperty); }
set { SetValue(PreviewBackgroundOpacityProperty, value); }
}
public Color PreviewBackgroundColor
{
get { return (Color)GetValue(PreviewBackgroundColorProperty); }
set { SetValue(PreviewBackgroundColorProperty, value); }
}
public ImageSource PreviewBackgroundImageSource
{
get { return (ImageSource)GetValue(PreviewBackgroundImageSourceProperty); }
set { SetValue(PreviewBackgroundImageSourceProperty, value); }
}
public int PreviewBackgroundImageOpacity
{
get { return (int)GetValue(PreviewBackgroundImageOpacityProperty); }
set { SetValue(PreviewBackgroundImageOpacityProperty, value); }
}
public double PreviewBackgroundImageBrightness
{
get => (double)GetValue(PreviewBackgroundImageBrightnessProperty);
set => SetValue(PreviewBackgroundImageBrightnessProperty, value);
}
public double PreviewBackgroundImageBlurAmount
{
get => (double)GetValue(PreviewBackgroundImageBlurAmountProperty);
set => SetValue(PreviewBackgroundImageBlurAmountProperty, value);
}
public Color PreviewBackgroundImageTint
{
get => (Color)GetValue(PreviewBackgroundImageTintProperty);
set => SetValue(PreviewBackgroundImageTintProperty, value);
}
public int PreviewBackgroundImageTintIntensity
{
get => (int)GetValue(PreviewBackgroundImageTintIntensityProperty);
set => SetValue(PreviewBackgroundImageTintIntensityProperty, value);
}
public Visibility ShowBackgroundImage
{
get => (Visibility)GetValue(ShowBackgroundImageProperty);
set => SetValue(ShowBackgroundImageProperty, value);
}
public CommandPalettePreview()
{
InitializeComponent();
}
private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not CommandPalettePreview preview)
{
return;
}
preview.ShowBackgroundImage = e.NewValue is ImageSource ? Visibility.Visible : Visibility.Collapsed;
}
private double ToOpacity(int value) => value / 100.0;
private double ToTintIntensity(int value) => value / 100.0;
private Stretch ToStretch(BackgroundImageFit fit)
{
return fit switch
{
BackgroundImageFit.Fill => Stretch.Fill,
BackgroundImageFit.UniformToFill => Stretch.UniformToFill,
_ => Stretch.None,
};
}
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.ScreenPreview"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Border
x:Name="ScreenBorder"
HorizontalAlignment="Center"
VerticalAlignment="Center"
BorderBrush="Black"
BorderThickness="8"
CornerRadius="8"
UseLayoutRounding="True">
<Viewbox Height="120" UseLayoutRounding="True">
<Grid>
<Image
x:Name="WallpaperImage"
MaxHeight="200"
Stretch="Uniform"
UseLayoutRounding="True" />
<ContentPresenter
x:Name="ContentPresenter"
Margin="40"
Content="{x:Bind PreviewContent}"
UseLayoutRounding="True" />
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Markup;
using Microsoft.UI.Xaml.Media;
namespace Microsoft.CmdPal.UI.Controls;
[ContentProperty(Name = nameof(PreviewContent))]
public sealed partial class ScreenPreview : UserControl
{
public static readonly DependencyProperty PreviewContentProperty =
DependencyProperty.Register(nameof(PreviewContent), typeof(object), typeof(ScreenPreview), new PropertyMetadata(null!))!;
public object PreviewContent
{
get => GetValue(PreviewContentProperty)!;
set => SetValue(PreviewContentProperty, value);
}
public ScreenPreview()
{
InitializeComponent();
var wallpaperHelper = new WallpaperHelper();
WallpaperImage!.Source = wallpaperHelper.GetWallpaperImage()!;
ScreenBorder!.Background = new SolidColorBrush(wallpaperHelper.GetWallpaperColor());
}
}

View File

@@ -4,9 +4,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUi="using:Microsoft.CmdPal.UI"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:h="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
@@ -22,6 +21,7 @@
MinHeight="32"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
h:TextBoxCaretColor.SyncWithForeground="True"
AutomationProperties.AutomationId="MainSearchBox"
KeyDown="FilterBox_KeyDown"
PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}"

View File

@@ -7,8 +7,10 @@ using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Commands;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.Views;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
@@ -49,6 +51,8 @@ public sealed partial class SearchBar : UserControl,
// 0.6+ suggestions
private string? _textToSuggest;
private SettingsModel Settings => App.Current.Services.GetRequiredService<SettingsModel>();
public PageViewModel? CurrentPageViewModel
{
get => (PageViewModel?)GetValue(CurrentPageViewModelProperty);
@@ -131,20 +135,39 @@ public sealed partial class SearchBar : UserControl,
}
else if (e.Key == VirtualKey.Escape)
{
if (string.IsNullOrEmpty(FilterBox.Text))
switch (Settings.EscapeKeyBehaviorSetting)
{
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
}
else
{
// Clear the search box
FilterBox.Text = string.Empty;
case EscapeKeyBehavior.AlwaysGoBack:
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
break;
// hack TODO GH #245
if (CurrentPageViewModel is not null)
{
CurrentPageViewModel.SearchTextBox = FilterBox.Text;
}
case EscapeKeyBehavior.AlwaysDismiss:
WeakReferenceMessenger.Default.Send<DismissMessage>(new(ForceGoHome: true));
break;
case EscapeKeyBehavior.AlwaysHide:
WeakReferenceMessenger.Default.Send<HideWindowMessage>(new());
break;
case EscapeKeyBehavior.ClearSearchFirstThenGoBack:
default:
if (string.IsNullOrEmpty(FilterBox.Text))
{
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
}
else
{
// Clear the search box
FilterBox.Text = string.Empty;
// hack TODO GH #245
if (CurrentPageViewModel is not null)
{
CurrentPageViewModel.SearchTextBox = FilterBox.Text;
}
}
break;
}
e.Handled = true;
@@ -185,21 +208,32 @@ public sealed partial class SearchBar : UserControl,
e.Handled = true;
}
else if (e.Key == VirtualKey.Left)
{
// Check if we're in a grid view, and if so, send grid navigation command
var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true };
// Special handling is required if we're in grid view.
if (isGridView)
{
WeakReferenceMessenger.Default.Send<NavigateLeftCommand>();
e.Handled = true;
}
}
else if (e.Key == VirtualKey.Right)
{
// Check if the "replace search text with suggestion" feature from 0.4-0.5 is enabled.
// If it isn't, then only use the suggestion when the caret is at the end of the input.
if (!IsTextToSuggestEnabled)
{
if (_textToSuggest != null &&
if (!string.IsNullOrEmpty(_textToSuggest) &&
FilterBox.SelectionStart == FilterBox.Text.Length)
{
FilterBox.Text = _textToSuggest;
FilterBox.Select(_textToSuggest.Length, 0);
e.Handled = true;
return;
}
return;
}
// Here, we're using the "replace search text with suggestion" feature.
@@ -209,6 +243,20 @@ public sealed partial class SearchBar : UserControl,
_lastText = null;
DoFilterBoxUpdate();
}
// Wouldn't want to perform text completion *and* move the selected item, so only perform this if text suggestion wasn't performed.
if (!e.Handled)
{
// Check if we're in a grid view, and if so, send grid navigation command
var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true };
// Special handling is required if we're in grid view.
if (isGridView)
{
WeakReferenceMessenger.Default.Send<NavigateRightCommand>();
e.Handled = true;
}
}
}
else if (e.Key == VirtualKey.Down)
{
@@ -251,6 +299,8 @@ public sealed partial class SearchBar : UserControl,
e.Key == VirtualKey.Up ||
e.Key == VirtualKey.Down ||
e.Key == VirtualKey.Left ||
e.Key == VirtualKey.Right ||
e.Key == VirtualKey.RightMenu ||
e.Key == VirtualKey.LeftMenu ||

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
/// <summary>
/// Arranges elements by wrapping them to fit the available space.
/// When <see cref="Orientation"/> is set to Orientation.Horizontal, element are arranged in rows until the available width is reached and then to a new row.
/// When <see cref="Orientation"/> is set to Orientation.Vertical, element are arranged in columns until the available height is reached.
/// </summary>
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<UvRect> 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<UvRect> 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),
};
}
}
/// <summary>
/// Gets or sets a uniform Horizontal distance (in pixels) between items when <see cref="Orientation"/> is set to Horizontal,
/// or between columns of items when <see cref="Orientation"/> is set to Vertical.
/// </summary>
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;
/// <summary>
/// Identifies the <see cref="HorizontalSpacing"/> dependency property.
/// </summary>
public static readonly DependencyProperty HorizontalSpacingProperty =
DependencyProperty.Register(
nameof(HorizontalSpacing),
typeof(double),
typeof(WrapPanel),
new PropertyMetadata(0d, LayoutPropertyChanged));
/// <summary>
/// Gets or sets a uniform Vertical distance (in pixels) between items when <see cref="Orientation"/> is set to Vertical,
/// or between rows of items when <see cref="Orientation"/> is set to Horizontal.
/// </summary>
public double VerticalSpacing
{
get { return (double)GetValue(VerticalSpacingProperty); }
set { SetValue(VerticalSpacingProperty, value); }
}
/// <summary>
/// Identifies the <see cref="VerticalSpacing"/> dependency property.
/// </summary>
public static readonly DependencyProperty VerticalSpacingProperty =
DependencyProperty.Register(
nameof(VerticalSpacing),
typeof(double),
typeof(WrapPanel),
new PropertyMetadata(0d, LayoutPropertyChanged));
/// <summary>
/// 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.
/// </summary>
public Orientation Orientation
{
get { return (Orientation)GetValue(OrientationProperty); }
set { SetValue(OrientationProperty, value); }
}
/// <summary>
/// Identifies the <see cref="Orientation"/> dependency property.
/// </summary>
public static readonly DependencyProperty OrientationProperty =
DependencyProperty.Register(
nameof(Orientation),
typeof(Orientation),
typeof(WrapPanel),
new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged));
/// <summary>
/// Gets or sets the distance between the border and its child object.
/// </summary>
/// <returns>
/// 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.
/// </returns>
public Thickness Padding
{
get { return (Thickness)GetValue(PaddingProperty); }
set { SetValue(PaddingProperty, value); }
}
/// <summary>
/// Identifies the Padding dependency property.
/// </summary>
/// <returns>The identifier for the <see cref="Padding"/> dependency property.</returns>
public static readonly DependencyProperty PaddingProperty =
DependencyProperty.Register(
nameof(Padding),
typeof(Thickness),
typeof(WrapPanel),
new PropertyMetadata(default(Thickness), LayoutPropertyChanged));
/// <summary>
/// Gets or sets a value indicating how to arrange child items
/// </summary>
public StretchChild StretchChild
{
get { return (StretchChild)GetValue(StretchChildProperty); }
set { SetValue(StretchChildProperty, value); }
}
/// <summary>
/// Identifies the <see cref="StretchChild"/> dependency property.
/// </summary>
/// <returns>The identifier for the <see cref="StretchChild"/> dependency property.</returns>
public static readonly DependencyProperty StretchChildProperty =
DependencyProperty.Register(
nameof(StretchChild),
typeof(StretchChild),
typeof(WrapPanel),
new PropertyMetadata(StretchChild.None, LayoutPropertyChanged));
/// <summary>
/// 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.
/// </summary>
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<Row> _rows = new List<Row>();
/// <inheritdoc />
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;
}
/// <inheritdoc />
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<UvRect>(), 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<UvRect>(), 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);
}
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Converters;
/// <summary>
/// Gets a color, either black or white, depending on the brightness of the supplied color.
/// </summary>
public sealed partial class ContrastBrushConverter : IValueConverter
{
/// <summary>
/// Gets or sets the alpha channel threshold below which a default color is used instead of black/white.
/// </summary>
public byte AlphaThreshold { get; set; } = 128;
/// <inheritdoc />
public object Convert(
object value,
Type targetType,
object parameter,
string language)
{
Color comparisonColor;
Color? defaultColor = null;
// Get the changing color to compare against
if (value is Color valueColor)
{
comparisonColor = valueColor;
}
else if (value is SolidColorBrush valueBrush)
{
comparisonColor = valueBrush.Color;
}
else
{
// Invalid color value provided
return DependencyProperty.UnsetValue;
}
// Get the default color when transparency is high
if (parameter is Color parameterColor)
{
defaultColor = parameterColor;
}
else if (parameter is SolidColorBrush parameterBrush)
{
defaultColor = parameterBrush.Color;
}
if (comparisonColor.A < AlphaThreshold &&
defaultColor.HasValue)
{
// If the transparency is less than 50 %, just use the default brush
// This can commonly be something like the TextControlForeground brush
return new SolidColorBrush(defaultColor.Value);
}
else
{
// Chose a white/black brush based on contrast to the base color
return UseLightContrastColor(comparisonColor)
? new SolidColorBrush(Colors.White)
: new SolidColorBrush(Colors.Black);
}
}
/// <inheritdoc />
public object ConvertBack(
object value,
Type targetType,
object parameter,
string language)
{
return DependencyProperty.UnsetValue;
}
/// <summary>
/// Determines whether a light or dark contrast color should be used with the given displayed color.
/// </summary>
/// <remarks>
/// This code is using the WinUI algorithm.
/// </remarks>
private bool UseLightContrastColor(Color displayedColor)
{
// The selection ellipse should be light if and only if the chosen color
// contrasts more with black than it does with white.
// To find how much something contrasts with white, we use the equation
// for relative luminance, which is given by
//
// L = 0.2126 * Rg + 0.7152 * Gg + 0.0722 * Bg
//
// where Xg = { X/3294 if X <= 10, (R/269 + 0.0513)^2.4 otherwise }
//
// If L is closer to 1, then the color is closer to white; if it is closer to 0,
// then the color is closer to black. This is based on the fact that the human
// eye perceives green to be much brighter than red, which in turn is perceived to be
// brighter than blue.
//
// If the third dimension is value, then we won't be updating the spectrum's displayed colors,
// so in that case we should use a value of 1 when considering the backdrop
// for the selection ellipse.
var rg = displayedColor.R <= 10
? displayedColor.R / 3294.0
: Math.Pow((displayedColor.R / 269.0) + 0.0513, 2.4);
var gg = displayedColor.G <= 10
? displayedColor.G / 3294.0
: Math.Pow((displayedColor.G / 269.0) + 0.0513, 2.4);
var bg = displayedColor.B <= 10
? displayedColor.B / 3294.0
: Math.Pow((displayedColor.B / 269.0) + 0.0513, 2.4);
return (0.2126 * rg) + (0.7152 * gg) + (0.0722 * bg) <= 0.5;
}
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -28,6 +28,8 @@
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
@@ -90,6 +92,8 @@
</Style>
<Style x:Key="GalleryGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
@@ -168,8 +172,17 @@
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource MediumGridItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}"
Small="{StaticResource SmallGridItemViewModelTemplate}" />
<cmdpalUI:ListItemTemplateSelector
x:Key="ListItemTemplateSelector"
x:DataType="coreViewModels:ListItemViewModel"
ListItem="{StaticResource ListItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
<cmdpalUI:GridItemContainerStyleSelector
x:Key="GridItemContainerStyleSelector"
Gallery="{StaticResource GalleryGridViewItemStyle}"
@@ -241,12 +254,46 @@
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Rectangle
Grid.Column="1"
Height="1"
Margin="0,2,0,2"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<Grid
Margin="0"
VerticalAlignment="Center"
cpcontrols:WrapPanel.IsFullLine="True"
ColumnSpacing="8"
IsTabStop="False"
IsTapEnabled="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Foreground="{ThemeResource TextFillColorDisabled}"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{x:Bind Section}" />
<Rectangle
Grid.Column="1"
Height="1"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
<!-- Grid item templates for visual grid representation -->
<DataTemplate x:Key="SmallGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<StackPanel
Width="60"
Height="60"
Padding="8,16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
@@ -265,7 +312,6 @@
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
</StackPanel>
</DataTemplate>
@@ -393,13 +439,16 @@
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemTemplate="{StaticResource ListItemViewModelTemplate}"
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
@@ -411,10 +460,13 @@
<controls:Case Value="True">
<GridView
x:Name="ItemsGrid"
Padding="8"
Padding="16,0"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
@@ -423,10 +475,14 @@
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<cpcontrols:WrapPanel HorizontalSpacing="8" Orientation="Horizontal" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemContainerTransitions>
<TransitionCollection />
</GridView.ItemContainerTransitions>
<GridView.ItemContainerStyle />
</GridView>
</controls:Case>
</controls:SwitchPresenter>

View File

@@ -18,6 +18,7 @@ using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using Windows.ApplicationModel.DataTransfer;
using Windows.Foundation;
using Windows.System;
@@ -26,6 +27,8 @@ namespace Microsoft.CmdPal.UI;
public sealed partial class ListPage : Page,
IRecipient<NavigateNextCommand>,
IRecipient<NavigatePreviousCommand>,
IRecipient<NavigateLeftCommand>,
IRecipient<NavigateRightCommand>,
IRecipient<NavigatePageDownCommand>,
IRecipient<NavigatePageUpCommand>,
IRecipient<ActivateSelectedListItemMessage>,
@@ -74,17 +77,25 @@ public sealed partial class ListPage : Page,
ViewModel = listViewModel;
if (e.NavigationMode == NavigationMode.Back
|| (e.NavigationMode == NavigationMode.New && ItemView.Items.Count > 0))
if (e.NavigationMode == NavigationMode.Back)
{
// Upon navigating _back_ to this page, immediately select the
// first item in the list
ItemView.SelectedIndex = 0;
// Must dispatch the selection to run at a lower priority; otherwise, GetFirstSelectableIndex
// may return an incorrect index because item containers are not yet rendered.
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{
var firstUsefulIndex = GetFirstSelectableIndex();
if (firstUsefulIndex != -1)
{
ItemView.SelectedIndex = firstUsefulIndex;
}
});
}
// RegisterAll isn't AOT compatible
WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Register<NavigateLeftCommand>(this);
WeakReferenceMessenger.Default.Register<NavigateRightCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
@@ -99,6 +110,8 @@ public sealed partial class ListPage : Page,
WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateRightCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Unregister<ActivateSelectedListItemMessage>(this);
@@ -122,6 +135,29 @@ public sealed partial class ListPage : Page,
GC.Collect();
}
/// <summary>
/// Finds the index of the first item in the list that is not a separator.
/// Returns -1 if the list is empty or only contains separators.
/// </summary>
private int GetFirstSelectableIndex()
{
var items = ItemView.Items;
if (items is null || items.Count == 0)
{
return -1;
}
for (var i = 0; i < items.Count; i++)
{
if (!IsSeparator(items[i]))
{
return i;
}
}
return -1;
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void Items_ItemClick(object sender, ItemClickEventArgs e)
{
@@ -177,19 +213,33 @@ public sealed partial class ListPage : Page,
// here, then in Page_ItemsUpdated trying to select that cached item if
// it's in the list (otherwise, clear the cache), but that seems
// aggressively BODGY for something that mostly just works today.
if (ItemView.SelectedItem is not null)
if (ItemView.SelectedItem is not null && !IsSeparator(ItemView.SelectedItem))
{
ItemView.ScrollIntoView(ItemView.SelectedItem);
var items = ItemView.Items;
var firstUsefulIndex = GetFirstSelectableIndex();
var shouldScroll = false;
if (e.RemovedItems.Count > 0)
{
shouldScroll = true;
}
else if (ItemView.SelectedIndex > firstUsefulIndex)
{
shouldScroll = true;
}
if (shouldScroll)
{
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
// Automation notification for screen readers
var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView);
if (listViewPeer is not null && li is not null)
{
var notificationText = li.Title;
UIHelper.AnnounceActionForAccessibility(
ItemsList,
notificationText,
li.Title,
"CommandPaletteSelectedItemChanged");
}
}
@@ -257,25 +307,56 @@ public sealed partial class ListPage : Page,
// And then have these commands manipulate that state being bound to the UI instead
// We may want to see how other non-list UIs need to behave to make this decision
// At least it's decoupled from the SearchBox now :)
if (ItemView.SelectedIndex < ItemView.Items.Count - 1)
if (ViewModel?.IsGridView == true)
{
ItemView.SelectedIndex++;
// For grid views, use spatial navigation (down)
HandleGridArrowNavigation(VirtualKey.Down);
}
else
{
ItemView.SelectedIndex = 0;
// For list views, use simple linear navigation
NavigateDown();
}
}
public void Receive(NavigatePreviousCommand message)
{
if (ItemView.SelectedIndex > 0)
if (ViewModel?.IsGridView == true)
{
ItemView.SelectedIndex--;
// For grid views, use spatial navigation (up)
HandleGridArrowNavigation(VirtualKey.Up);
}
else
{
ItemView.SelectedIndex = ItemView.Items.Count - 1;
NavigateUp();
}
}
public void Receive(NavigateLeftCommand message)
{
// For grid views, use spatial navigation. For list views, just move up.
if (ViewModel?.IsGridView == true)
{
HandleGridArrowNavigation(VirtualKey.Left);
}
else
{
// In list view, left arrow doesn't navigate
// This maintains consistency with the SearchBar behavior
}
}
public void Receive(NavigateRightCommand message)
{
// For grid views, use spatial navigation. For list views, just move down.
if (ViewModel?.IsGridView == true)
{
HandleGridArrowNavigation(VirtualKey.Right);
}
else
{
// In list view, right arrow doesn't navigate
// This maintains consistency with the SearchBar behavior
}
}
@@ -314,7 +395,10 @@ public sealed partial class ListPage : Page,
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
{
ItemView.SelectedIndex = indexes.Value.TargetIndex;
ItemView.ScrollIntoView(ItemView.SelectedItem);
if (ItemView.SelectedItem is not null)
{
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
}
}
@@ -329,7 +413,10 @@ public sealed partial class ListPage : Page,
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
{
ItemView.SelectedIndex = indexes.Value.TargetIndex;
ItemView.ScrollIntoView(ItemView.SelectedItem);
if (ItemView.SelectedItem is not null)
{
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
}
}
@@ -472,17 +559,65 @@ public sealed partial class ListPage : Page,
// ItemView_SelectionChanged again to give us another chance to change
// the selection from null -> something. Better to just update the
// selection once, at the end of all the updating.
if (ItemView.SelectedItem is null)
// The selection logic must be deferred to the DispatcherQueue
// to ensure the UI has processed the updated ItemsSource binding,
// preventing ItemView.Items from appearing empty/null immediately after update.
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{
ItemView.SelectedIndex = 0;
}
var items = ItemView.Items;
// Always reset the selected item when the top-level list page changes
// its items
if (!sender.IsNested)
{
ItemView.SelectedIndex = 0;
}
// If the list is null or empty, clears the selection and return
if (items is null || items.Count == 0)
{
ItemView.SelectedIndex = -1;
return;
}
// Finds the first item that is not a separator
var firstUsefulIndex = GetFirstSelectableIndex();
// If there is only separators in the list, don't select anything.
if (firstUsefulIndex == -1)
{
ItemView.SelectedIndex = -1;
return;
}
var shouldUpdateSelection = false;
// If it's a top level list update we force the reset to the top useful item
if (!sender.IsNested)
{
shouldUpdateSelection = true;
}
// No current selection or current selection is null
else if (ItemView.SelectedItem is null)
{
shouldUpdateSelection = true;
}
// The current selected item is a separator
else if (IsSeparator(ItemView.SelectedItem))
{
shouldUpdateSelection = true;
}
// The selected item does not exist in the new list
else if (!items.Contains(ItemView.SelectedItem))
{
shouldUpdateSelection = true;
}
if (shouldUpdateSelection)
{
if (firstUsefulIndex != -1)
{
ItemView.SelectedIndex = firstUsefulIndex;
}
}
});
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -514,6 +649,135 @@ public sealed partial class ListPage : Page,
return null;
}
// Find a logical neighbor in the requested direction using containers' positions.
private void HandleGridArrowNavigation(VirtualKey key)
{
if (ItemView.Items.Count == 0)
{
// No items, goodbye.
return;
}
var currentIndex = ItemView.SelectedIndex;
if (currentIndex < 0)
{
// -1 is a valid value (no item currently selected)
currentIndex = 0;
ItemView.SelectedIndex = 0;
}
try
{
// Try to compute using container positions; if not available, fall back to simple +/-1.
var currentContainer = ItemView.ContainerFromIndex(currentIndex) as FrameworkElement;
if (currentContainer is not null && currentContainer.ActualWidth != 0 && currentContainer.ActualHeight != 0)
{
// Use center of current container as reference
var curPoint = currentContainer.TransformToVisual(ItemView).TransformPoint(new Point(0, 0));
var curCenterX = curPoint.X + (currentContainer.ActualWidth / 2.0);
var curCenterY = curPoint.Y + (currentContainer.ActualHeight / 2.0);
var bestScore = double.MaxValue;
var bestIndex = currentIndex;
for (var i = 0; i < ItemView.Items.Count; i++)
{
if (i == currentIndex)
{
continue;
}
if (IsSeparator(ItemView.Items[i]))
{
continue;
}
if (ItemView.ContainerFromIndex(i) is FrameworkElement c && c.ActualWidth > 0 && c.ActualHeight > 0)
{
var p = c.TransformToVisual(ItemView).TransformPoint(new Point(0, 0));
var centerX = p.X + (c.ActualWidth / 2.0);
var centerY = p.Y + (c.ActualHeight / 2.0);
var dx = centerX - curCenterX;
var dy = centerY - curCenterY;
var candidate = false;
var score = double.MaxValue;
switch (key)
{
case VirtualKey.Left:
if (dx < 0)
{
candidate = true;
score = Math.Abs(dy) + (Math.Abs(dx) * 0.7);
}
break;
case VirtualKey.Right:
if (dx > 0)
{
candidate = true;
score = Math.Abs(dy) + (Math.Abs(dx) * 0.7);
}
break;
case VirtualKey.Up:
if (dy < 0)
{
candidate = true;
score = Math.Abs(dx) + (Math.Abs(dy) * 0.7);
}
break;
case VirtualKey.Down:
if (dy > 0)
{
candidate = true;
score = Math.Abs(dx) + (Math.Abs(dy) * 0.7);
}
break;
}
if (candidate && score < bestScore)
{
bestScore = score;
bestIndex = i;
}
}
}
if (bestIndex != currentIndex)
{
ItemView.SelectedIndex = bestIndex;
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
return;
}
}
catch
{
// ignore transform errors and fall back
}
// fallback linear behavior
var fallback = key switch
{
VirtualKey.Left => Math.Max(0, currentIndex - 1),
VirtualKey.Right => Math.Min(ItemView.Items.Count - 1, currentIndex + 1),
VirtualKey.Up => Math.Max(0, currentIndex - 1),
VirtualKey.Down => Math.Min(ItemView.Items.Count - 1, currentIndex + 1),
_ => currentIndex,
};
if (fallback != currentIndex)
{
ItemView.SelectedIndex = fallback;
ItemView.ScrollIntoView(ItemView.SelectedItem);
}
}
private void Items_OnContextRequested(UIElement sender, ContextRequestedEventArgs e)
{
var (item, element) = e.OriginalSource switch
@@ -564,12 +828,209 @@ public sealed partial class ListPage : Page,
private void Items_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
// Track keyboard as the last input source for activation logic.
if (e.Key is VirtualKey.Enter or VirtualKey.Space)
{
_lastInputSource = InputSource.Keyboard;
return;
}
// Handle arrow navigation when we're showing a grid.
if (ViewModel?.IsGridView == true)
{
switch (e.Key)
{
case VirtualKey.Left:
case VirtualKey.Right:
case VirtualKey.Up:
case VirtualKey.Down:
_lastInputSource = InputSource.Keyboard;
HandleGridArrowNavigation(e.Key);
e.Handled = true;
break;
}
}
}
/// <summary>
/// Code stealed from <see cref="Controls.ContextMenu.NavigateUp"/>
/// </summary>
private void NavigateUp()
{
var newIndex = ItemView.SelectedIndex;
if (ItemView.SelectedIndex > 0)
{
newIndex--;
while (
newIndex >= 0 &&
IsSeparator(ItemView.Items[newIndex]) &&
newIndex != ItemView.SelectedIndex)
{
newIndex--;
}
if (newIndex < 0)
{
newIndex = ItemView.Items.Count - 1;
while (
newIndex >= 0 &&
IsSeparator(ItemView.Items[newIndex]) &&
newIndex != ItemView.SelectedIndex)
{
newIndex--;
}
}
}
else
{
newIndex = ItemView.Items.Count - 1;
}
ItemView.SelectedIndex = newIndex;
}
private void Items_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
{
try
{
if (e.Items.FirstOrDefault() is not ListItemViewModel item || item.DataPackage is null)
{
e.Cancel = true;
return;
}
// copy properties
foreach (var (key, value) in item.DataPackage.Properties)
{
try
{
e.Data.Properties[key] = value;
}
catch (Exception)
{
// noop - skip any properties that fail
}
}
// setup e.Data formats as deferred renderers to read from the item's DataPackage
foreach (var format in item.DataPackage.AvailableFormats)
{
try
{
e.Data.SetDataProvider(format, request => DelayRenderer(request, item, format));
}
catch (Exception)
{
// noop - skip any formats that fail
}
}
WeakReferenceMessenger.Default.Send(new DragStartedMessage());
}
catch (Exception ex)
{
WeakReferenceMessenger.Default.Send(new DragCompletedMessage());
Logger.LogError("Failed to start dragging an item", ex);
}
}
private static void DelayRenderer(DataProviderRequest request, ListItemViewModel item, string format)
{
var deferral = request.GetDeferral();
try
{
item.DataPackage?.GetDataAsync(format)
.AsTask()
.ContinueWith(dataTask =>
{
try
{
if (dataTask.IsCompletedSuccessfully)
{
request.SetData(dataTask.Result);
}
else if (dataTask.IsFaulted && dataTask.Exception is not null)
{
Logger.LogError($"Failed to get data for format '{format}' during drag-and-drop", dataTask.Exception);
}
}
finally
{
deferral.Complete();
}
});
}
catch (Exception ex)
{
Logger.LogError($"Failed to set data for format '{format}' during drag-and-drop", ex);
deferral.Complete();
}
}
private void Items_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args)
{
WeakReferenceMessenger.Default.Send(new DragCompletedMessage());
}
/// <summary>
/// Code stealed from <see cref="Controls.ContextMenu.NavigateDown"/>
/// </summary>
private void NavigateDown()
{
var newIndex = ItemView.SelectedIndex;
if (ItemView.SelectedIndex == ItemView.Items.Count - 1)
{
newIndex = 0;
while (
newIndex < ItemView.Items.Count &&
IsSeparator(ItemView.Items[newIndex]))
{
newIndex++;
}
if (newIndex >= ItemView.Items.Count)
{
return;
}
}
else
{
newIndex++;
while (
newIndex < ItemView.Items.Count &&
IsSeparator(ItemView.Items[newIndex]) &&
newIndex != ItemView.SelectedIndex)
{
newIndex++;
}
if (newIndex >= ItemView.Items.Count)
{
newIndex = 0;
while (
newIndex < ItemView.Items.Count &&
IsSeparator(ItemView.Items[newIndex]) &&
newIndex != ItemView.SelectedIndex)
{
newIndex++;
}
}
}
ItemView.SelectedIndex = newIndex;
}
/// <summary>
/// Code stealed from <see cref="Controls.ContextMenu.IsSeparator(object)"/>
/// </summary>
private bool IsSeparator(object? item) => item is ListItemViewModel li && li.IsSectionOrSeparator;
private enum InputSource
{
None,

View File

@@ -10,6 +10,8 @@ internal static class BindTransformers
{
public static bool Negate(bool value) => !value;
public static Visibility NegateVisibility(Visibility value) => value == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible;
public static Visibility EmptyToCollapsed(string? input)
=> string.IsNullOrEmpty(input) ? Visibility.Collapsed : Visibility.Visible;

View File

@@ -0,0 +1,132 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.WinUI.Helpers;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// Extension methods for <see cref="Color"/>.
/// </summary>
internal static class ColorExtensions
{
/// <param name="color">Input color.</param>
public static double CalculateBrightness(this Color color)
{
return color.ToHsv().V;
}
/// <summary>
/// Allows to change the brightness by a factor based on the HSV color space.
/// </summary>
/// <param name="color">Input color.</param>
/// <param name="brightnessFactor">The brightness adjustment factor, ranging from -1 to 1.</param>
/// <returns>Updated color.</returns>
public static Color UpdateBrightness(this Color color, double brightnessFactor)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1);
var hsvColor = color.ToHsv();
return ColorHelper.FromHsv(hsvColor.H, hsvColor.S, Math.Clamp(hsvColor.V + brightnessFactor, 0, 1), hsvColor.A);
}
/// <summary>
/// Updates the color by adjusting brightness, saturation, and luminance factors.
/// </summary>
/// <param name="color">Input color.</param>
/// <param name="brightnessFactor">The brightness adjustment factor, ranging from -1 to 1.</param>
/// <param name="saturationFactor">The saturation adjustment factor, ranging from -1 to 1. Defaults to 0.</param>
/// <param name="luminanceFactor">The luminance adjustment factor, ranging from -1 to 1. Defaults to 0.</param>
/// <returns>Updated color.</returns>
public static Color Update(this Color color, double brightnessFactor, double saturationFactor = 0, double luminanceFactor = 0)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(saturationFactor, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(saturationFactor, -1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(luminanceFactor, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(luminanceFactor, -1);
var hsv = color.ToHsv();
var rgb = ColorHelper.FromHsv(
hsv.H,
Clamp01(hsv.S + saturationFactor),
Clamp01(hsv.V + brightnessFactor));
if (luminanceFactor == 0)
{
return rgb;
}
var hsl = rgb.ToHsl();
var lightness = Clamp01(hsl.L + luminanceFactor);
return ColorHelper.FromHsl(hsl.H, hsl.S, lightness);
}
/// <summary>
/// Linearly interpolates between two colors in HSV space.
/// Hue is blended along the shortest arc on the color wheel (wrap-aware).
/// Saturation, Value, and Alpha are blended linearly.
/// </summary>
/// <param name="a">Start color.</param>
/// <param name="b">End color.</param>
/// <param name="t">Interpolation factor in [0,1].</param>
/// <returns>Interpolated color.</returns>
public static Color LerpHsv(this Color a, Color b, double t)
{
t = Clamp01(t);
// Convert to HSV
var hslA = a.ToHsv();
var hslB = b.ToHsv();
var h1 = hslA.H;
var h2 = hslB.H;
// Handle near-gray hues (undefined hue) by inheriting the other's hue
const double satEps = 1e-4f;
if (hslA.S < satEps && hslB.S >= satEps)
{
h1 = h2;
}
else if (hslB.S < satEps && hslA.S >= satEps)
{
h2 = h1;
}
return ColorHelper.FromHsv(
hue: LerpHueDegrees(h1, h2, t),
saturation: Lerp(hslA.S, hslB.S, t),
value: Lerp(hslA.V, hslB.V, t),
alpha: (byte)Math.Round(Lerp(hslA.A, hslB.A, t)));
}
private static double LerpHueDegrees(double a, double b, double t)
{
a = Mod360(a);
b = Mod360(b);
var delta = ((b - a + 540f) % 360f) - 180f;
return Mod360(a + (delta * t));
}
private static double Mod360(double angle)
{
angle %= 360f;
if (angle < 0f)
{
angle += 360f;
}
return angle;
}
private static double Lerp(double a, double b, double t) => a + ((b - a) * t);
private static double Clamp01(double x) => Math.Clamp(x, 0, 1);
}

Some files were not shown because too many files have changed in this diff Show More