color picker

This commit is contained in:
kaitao-ms
2025-12-01 13:35:04 +08:00
parent fe32c32f73
commit bffb576581
13 changed files with 505 additions and 3 deletions

View File

@@ -831,6 +831,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerToys.ModuleContracts",
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Awake.ModuleServices", "src\modules\awake\Awake.ModuleServices\Awake.ModuleServices.csproj", "{2141FF78-5F51-ED6B-E11B-C7079CCA1456}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Awake.ModuleServices", "src\modules\awake\Awake.ModuleServices\Awake.ModuleServices.csproj", "{2141FF78-5F51-ED6B-E11B-C7079CCA1456}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColorPicker.ModuleServices", "src\modules\colorPicker\ColorPicker.ModuleServices\ColorPicker.ModuleServices.csproj", "{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -5273,6 +5275,22 @@ Global
{2141FF78-5F51-ED6B-E11B-C7079CCA1456}.Release|x64.Build.0 = Release|x64 {2141FF78-5F51-ED6B-E11B-C7079CCA1456}.Release|x64.Build.0 = Release|x64
{2141FF78-5F51-ED6B-E11B-C7079CCA1456}.Release|x86.ActiveCfg = Release|x64 {2141FF78-5F51-ED6B-E11B-C7079CCA1456}.Release|x86.ActiveCfg = Release|x64
{2141FF78-5F51-ED6B-E11B-C7079CCA1456}.Release|x86.Build.0 = Release|x64 {2141FF78-5F51-ED6B-E11B-C7079CCA1456}.Release|x86.Build.0 = Release|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|Any CPU.ActiveCfg = Debug|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|Any CPU.Build.0 = Debug|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|ARM64.ActiveCfg = Debug|ARM64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|ARM64.Build.0 = Debug|ARM64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|x64.ActiveCfg = Debug|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|x64.Build.0 = Debug|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|x86.ActiveCfg = Debug|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Debug|x86.Build.0 = Debug|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|Any CPU.ActiveCfg = Release|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|Any CPU.Build.0 = Release|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|ARM64.ActiveCfg = Release|ARM64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|ARM64.Build.0 = Release|ARM64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|x64.ActiveCfg = Release|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|x64.Build.0 = Release|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|x86.ActiveCfg = Release|x64
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|x86.Build.0 = Release|x64
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -5607,6 +5625,7 @@ Global
{D52AAF95-DE88-49EA-B28A-10E382BCD4AB} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} {D52AAF95-DE88-49EA-B28A-10E382BCD4AB} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF}
{094E65D5-585E-4898-B465-97A47CD44380} = {1AFB6476-670D-4E80-A464-657E01DFF482} {094E65D5-585E-4898-B465-97A47CD44380} = {1AFB6476-670D-4E80-A464-657E01DFF482}
{2141FF78-5F51-ED6B-E11B-C7079CCA1456} = {127F38E0-40AA-4594-B955-5616BF206882} {2141FF78-5F51-ED6B-E11B-C7079CCA1456} = {127F38E0-40AA-4594-B955-5616BF206882}
{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52} = {1D78B84B-CA39-406C-98F4-71F7EC266CC0}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0}

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 System;
using System.Windows.Forms;
using ColorPicker.ModuleServices;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace PowerToysExtension.Commands;
/// <summary>
/// Copies a saved color in a chosen format.
/// </summary>
internal sealed partial class CopySavedColorCommand : InvokableCommand
{
private readonly SavedColor _color;
private readonly string _copyValue;
public CopySavedColorCommand(SavedColor color, string copyValue)
{
_color = color;
_copyValue = copyValue;
Name = $"Copy {_color.Hex}";
}
public override CommandResult Invoke()
{
try
{
Clipboard.SetText(_copyValue);
return CommandResult.ShowToast($"Copied {_copyValue}");
}
catch (Exception ex)
{
return CommandResult.ShowToast($"Failed to copy color: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ColorPicker.ModuleServices;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace PowerToysExtension.Commands;
/// <summary>
/// Opens the Color Picker picker session via shared event.
/// </summary>
internal sealed partial class OpenColorPickerCommand : InvokableCommand
{
public OpenColorPickerCommand()
{
Name = "Open Color Picker";
}
public override CommandResult Invoke()
{
try
{
var result = ColorPickerService.Instance.OpenPickerAsync().GetAwaiter().GetResult();
if (!result.Success)
{
return CommandResult.ShowToast(result.Error ?? "Failed to open Color Picker.");
}
return CommandResult.Dismiss();
}
catch (Exception ex)
{
return CommandResult.ShowToast($"Failed to open Color Picker: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,40 @@
// 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.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace PowerToysExtension.Helpers;
internal static class ColorSwatchIconFactory
{
public static IconInfo Create(byte r, byte g, byte b, byte a)
{
try
{
using var bmp = new Bitmap(32, 32, PixelFormat.Format32bppArgb);
using var gfx = Graphics.FromImage(bmp);
gfx.SmoothingMode = SmoothingMode.AntiAlias;
gfx.Clear(Color.Transparent);
using var brush = new SolidBrush(Color.FromArgb(a, r, g, b));
const int padding = 4;
gfx.FillEllipse(brush, padding, padding, bmp.Width - (padding * 2), bmp.Height - (padding * 2));
using var ms = new MemoryStream();
bmp.Save(ms, ImageFormat.Png);
var iconData = IconInfo.FromStream(ms.AsRandomAccessStream()).Dark as IconData;
return iconData != null ? new IconInfo(iconData, iconData) : new IconInfo("\u25CF");
}
catch
{
// Fallback to a simple colored glyph when drawing fails.
return new IconInfo("\u25CF"); // Black circle glyph
}
}
}

View File

@@ -57,6 +57,7 @@
<ProjectReference Include="..\..\..\..\common\Common.Search\Common.Search.csproj" /> <ProjectReference Include="..\..\..\..\common\Common.Search\Common.Search.csproj" />
<ProjectReference Include="..\..\..\..\common\Common.UI\Common.UI.csproj" /> <ProjectReference Include="..\..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\Awake\Awake.ModuleServices\Awake.ModuleServices.csproj" /> <ProjectReference Include="..\..\..\Awake\Awake.ModuleServices\Awake.ModuleServices.csproj" />
<ProjectReference Include="..\..\..\colorPicker\ColorPicker.ModuleServices\ColorPicker.ModuleServices.csproj" />
<ProjectReference Include="..\..\..\Workspaces\Workspaces.ModuleServices\Workspaces.ModuleServices.csproj" /> <ProjectReference Include="..\..\..\Workspaces\Workspaces.ModuleServices\Workspaces.ModuleServices.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -7,6 +7,7 @@ using Common.UI;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
using PowerToysExtension.Commands; using PowerToysExtension.Commands;
using PowerToysExtension.Helpers; using PowerToysExtension.Helpers;
using PowerToysExtension.Pages;
namespace PowerToysExtension.Modules; namespace PowerToysExtension.Modules;
@@ -17,19 +18,39 @@ internal sealed class ColorPickerModuleCommandProvider : ModuleCommandProvider
var title = SettingsDeepLink.SettingsWindow.ColorPicker.ModuleDisplayName(); var title = SettingsDeepLink.SettingsWindow.ColorPicker.ModuleDisplayName();
var icon = SettingsDeepLink.SettingsWindow.ColorPicker.ModuleIcon(); var icon = SettingsDeepLink.SettingsWindow.ColorPicker.ModuleIcon();
var commands = new List<ListItem>();
// Quick actions under MoreCommands.
var more = new List<CommandContextItem> var more = new List<CommandContextItem>
{ {
new CommandContextItem(new OpenColorPickerCommand()),
new CommandContextItem(new CopyColorCommand()), new CommandContextItem(new CopyColorCommand()),
new CommandContextItem(new ColorPickerSavedColorsPage()),
}; };
var item = new ListItem(new OpenInSettingsCommand(SettingsDeepLink.SettingsWindow.ColorPicker, title)) commands.Add(new ListItem(new OpenInSettingsCommand(SettingsDeepLink.SettingsWindow.ColorPicker, title))
{ {
Title = title, Title = title,
Subtitle = "Open Color Picker settings", Subtitle = "Open Color Picker settings",
Icon = icon, Icon = icon,
MoreCommands = more.ToArray(), MoreCommands = more.ToArray(),
}; });
return [item]; // Direct entries in the module list.
commands.Add(new ListItem(new OpenColorPickerCommand())
{
Title = "Open Color Picker",
Subtitle = "Start a color pick session",
Icon = icon,
});
commands.Add(new ListItem(new CommandItem(new ColorPickerSavedColorsPage()))
{
Title = "Saved colors",
Subtitle = "Browse and copy saved colors",
Icon = icon,
});
return commands;
} }
} }

View File

@@ -0,0 +1,111 @@
// 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.Linq;
using System.Text;
using ColorPicker.ModuleServices;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using PowerToysExtension.Commands;
using PowerToysExtension.Helpers;
namespace PowerToysExtension.Pages;
internal sealed partial class ColorPickerSavedColorsPage : DynamicListPage
{
private readonly CommandItem _emptyContent;
public ColorPickerSavedColorsPage()
{
Icon = IconHelpers.FromRelativePath("Assets\\ColorPicker.png");
Title = "Saved colors";
Name = "ColorPickerSavedColors";
Id = "com.microsoft.powertoys.colorpicker.savedcolors";
_emptyContent = new CommandItem()
{
Title = "No saved colors",
Subtitle = "Pick a color first, then try again.",
Icon = IconHelpers.FromRelativePath("Assets\\ColorPicker.png"),
};
EmptyContent = _emptyContent;
}
public override IListItem[] GetItems()
{
var result = ColorPickerService.Instance.GetSavedColorsAsync().GetAwaiter().GetResult();
if (!result.Success || result.Value is null || result.Value.Count == 0)
{
return Array.Empty<IListItem>();
}
var search = SearchText;
var filtered = string.IsNullOrWhiteSpace(search)
? result.Value
: result.Value.Where(saved =>
saved.Hex.Contains(search, StringComparison.OrdinalIgnoreCase) ||
saved.Formats.Any(f => f.Value.Contains(search, StringComparison.OrdinalIgnoreCase) ||
f.Format.Contains(search, StringComparison.OrdinalIgnoreCase)));
var items = filtered.Select(saved =>
{
var copyValue = SelectPreferredFormat(saved);
var subtitle = BuildSubtitle(saved);
return (IListItem)new CommandItem(new CopySavedColorCommand(saved, copyValue))
{
Title = saved.Hex,
Subtitle = subtitle,
Icon = ColorSwatchIconFactory.Create(saved.R, saved.G, saved.B, saved.A),
};
}).ToArray();
return items;
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
_emptyContent.Subtitle = string.IsNullOrWhiteSpace(newSearch)
? "Pick a color first, then try again."
: $"No saved colors matching '{newSearch}'";
RaiseItemsChanged(0);
}
private static string SelectPreferredFormat(SavedColor saved)
{
// Prefer RGBA, then RGB, otherwise fallback to hex.
var rgba = saved.Formats.FirstOrDefault(f => f.Format.Equals("RGBA", StringComparison.OrdinalIgnoreCase));
if (rgba is not null)
{
return rgba.Value;
}
var rgb = saved.Formats.FirstOrDefault(f => f.Format.Equals("RGB", StringComparison.OrdinalIgnoreCase));
if (rgb is not null)
{
return rgb.Value;
}
return saved.Hex;
}
private static string BuildSubtitle(SavedColor saved)
{
var sb = new StringBuilder();
foreach (var format in saved.Formats.Take(3))
{
if (sb.Length > 0)
{
sb.Append(" · ");
}
sb.Append(format.Value);
}
return sb.Length > 0 ? sb.ToString() : saved.Hex;
}
}

View File

@@ -0,0 +1,7 @@
// 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 ColorPicker.ModuleServices;
public sealed record ColorFormatValue(string Format, string Value);

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="ColorFormatValue.cs" />
<Compile Include="ColorPickerService.cs" />
<Compile Include="IColorPickerService.cs" />
<Compile Include="ColorPickerServiceJsonContext.cs" />
<Compile Include="SavedColor.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\PowerToys.ModuleContracts\PowerToys.ModuleContracts.csproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,157 @@
// 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.Drawing;
using System.Text.Json;
using Common.UI;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using PowerToys.Interop;
using PowerToys.ModuleContracts;
namespace ColorPicker.ModuleServices;
/// <summary>
/// Provides programmatic control for Color Picker actions.
/// </summary>
public sealed class ColorPickerService : ModuleServiceBase, IColorPickerService
{
public static ColorPickerService Instance { get; } = new();
public override string Key => SettingsDeepLink.SettingsWindow.ColorPicker.ToString();
protected override SettingsDeepLink.SettingsWindow SettingsWindow => SettingsDeepLink.SettingsWindow.ColorPicker;
public override Task<OperationResult> LaunchAsync(CancellationToken cancellationToken = default)
{
// Default launch -> open picker.
return OpenPickerAsync(cancellationToken);
}
public Task<OperationResult> OpenPickerAsync(CancellationToken cancellationToken = default)
{
return SignalEventAsync(Constants.ShowColorPickerSharedEvent(), "Color Picker");
}
public Task<OperationResult<IReadOnlyList<SavedColor>>> GetSavedColorsAsync(CancellationToken cancellationToken = default)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var historyPath = Path.Combine(localAppData, "Microsoft", "PowerToys", "ColorPicker", "colorHistory.json");
if (!File.Exists(historyPath))
{
return Task.FromResult(OperationResults.Ok<IReadOnlyList<SavedColor>>(Array.Empty<SavedColor>()));
}
using var stream = File.OpenRead(historyPath);
var colors = JsonSerializer.Deserialize(stream, ColorPickerServiceJsonContext.Default.ListString) ?? new List<string>();
var settingsUtils = new SettingsUtils();
var settings = settingsUtils.GetSettingsOrDefault<ColorPickerSettings>(ColorPickerSettings.ModuleName);
var results = new List<SavedColor>(colors.Count);
foreach (var entry in colors)
{
if (!TryParseArgb(entry, out var color))
{
continue;
}
var formats = BuildFormats(color, settings);
var hex = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
results.Add(new SavedColor(
hex,
color.A,
color.R,
color.G,
color.B,
formats));
}
return Task.FromResult(OperationResults.Ok<IReadOnlyList<SavedColor>>(results));
}
catch (OperationCanceledException)
{
return Task.FromResult(OperationResults.Fail<IReadOnlyList<SavedColor>>("Reading saved colors was cancelled."));
}
catch (Exception ex)
{
return Task.FromResult(OperationResults.Fail<IReadOnlyList<SavedColor>>($"Failed to read saved colors: {ex.Message}"));
}
}
private static Task<OperationResult> SignalEventAsync(string eventName, string actionDescription)
{
try
{
using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName);
if (!eventHandle.Set())
{
return Task.FromResult(OperationResult.Fail($"Failed to signal {actionDescription}."));
}
return Task.FromResult(OperationResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(OperationResult.Fail($"Failed to signal {actionDescription}: {ex.Message}"));
}
}
private static bool TryParseArgb(string value, out Color color)
{
color = Color.Empty;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var parts = value.Split('|');
if (parts.Length != 4)
{
return false;
}
if (byte.TryParse(parts[0], out var a) &&
byte.TryParse(parts[1], out var r) &&
byte.TryParse(parts[2], out var g) &&
byte.TryParse(parts[3], out var b))
{
color = Color.FromArgb(a, r, g, b);
return true;
}
return false;
}
private static IReadOnlyList<ColorFormatValue> BuildFormats(Color color, ColorPickerSettings settings)
{
var formats = new List<ColorFormatValue>();
foreach (var kvp in settings.Properties.VisibleColorFormats)
{
var formatName = kvp.Key;
var (isVisible, formatString) = kvp.Value;
if (!isVisible)
{
continue;
}
var formatted = ColorFormatHelper.GetStringRepresentation(color, formatString);
if (formatName.Equals("HEX", StringComparison.OrdinalIgnoreCase) && !formatted.StartsWith('#'))
{
formatted = "#" + formatted;
}
formats.Add(new ColorFormatValue(formatName, formatted));
}
return formats;
}
}

View File

@@ -0,0 +1,19 @@
// 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.Generic;
using System.Text.Json.Serialization;
using Microsoft.PowerToys.Settings.UI.Library;
namespace ColorPicker.ModuleServices;
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(List<SavedColor>))]
[JsonSerializable(typeof(SavedColor))]
[JsonSerializable(typeof(ColorFormatValue))]
[JsonSerializable(typeof(ColorPickerSettings))]
internal sealed partial class ColorPickerServiceJsonContext : JsonSerializerContext
{
}

View File

@@ -0,0 +1,14 @@
// 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 PowerToys.ModuleContracts;
namespace ColorPicker.ModuleServices;
public interface IColorPickerService : IModuleService
{
Task<OperationResult> OpenPickerAsync(CancellationToken cancellationToken = default);
Task<OperationResult<IReadOnlyList<SavedColor>>> GetSavedColorsAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,7 @@
// 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 ColorPicker.ModuleServices;
public sealed record SavedColor(string Hex, byte A, byte R, byte G, byte B, IReadOnlyList<ColorFormatValue> Formats);