diff --git a/Avalonia.sln b/Avalonia.sln
index 1e2a3c6027..ea30514c3e 100644
--- a/Avalonia.sln
+++ b/Avalonia.sln
@@ -169,6 +169,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlatformSanityChecks", "sam
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.ReactiveUI.UnitTests", "tests\Avalonia.ReactiveUI.UnitTests\Avalonia.ReactiveUI.UnitTests.csproj", "{AF915D5C-AB00-4EA0-B5E6-001F4AE84E68}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.ColorPicker", "src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj", "{1ECC012A-8837-4AE2-9BDA-3E2857898727}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.DataGrid", "src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj", "{3278F3A9-9509-4A3F-A15B-BDC8B5BFF632}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Dialogs", "src\Avalonia.Dialogs\Avalonia.Dialogs.csproj", "{4D55985A-1EE2-4F25-AD39-6EA8BC04F8FB}"
@@ -1963,6 +1965,30 @@ Global
{2B390431-288C-435C-BB6B-A374033BD8D1}.Release|iPhone.Build.0 = Release|Any CPU
{2B390431-288C-435C-BB6B-A374033BD8D1}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
{2B390431-288C-435C-BB6B-A374033BD8D1}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|Any CPU.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhone.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhone.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhone.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhone.ActiveCfg = Release|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhone.Build.0 = Release|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+ {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs
index 866fb8632a..6539cdaee6 100644
--- a/samples/ControlCatalog/App.xaml.cs
+++ b/samples/ControlCatalog/App.xaml.cs
@@ -18,6 +18,16 @@ namespace ControlCatalog
DataContext = new ApplicationViewModel();
}
+ public static readonly StyleInclude ColorPickerFluent = new StyleInclude(new Uri("avares://ControlCatalog/Styles"))
+ {
+ Source = new Uri("avares://Avalonia.Controls.ColorPicker/Themes/Fluent.xaml")
+ };
+
+ public static readonly StyleInclude ColorPickerDefault = new StyleInclude(new Uri("avares://ControlCatalog/Styles"))
+ {
+ Source = new Uri("avares://Avalonia.Controls.ColorPicker/Themes/Default.xaml")
+ };
+
public static readonly StyleInclude DataGridFluent = new StyleInclude(new Uri("avares://ControlCatalog/Styles"))
{
Source = new Uri("avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml")
@@ -69,7 +79,8 @@ namespace ControlCatalog
public override void Initialize()
{
Styles.Insert(0, Fluent);
- Styles.Insert(1, DataGridFluent);
+ Styles.Insert(1, ColorPickerFluent);
+ Styles.Insert(2, DataGridFluent);
AvaloniaXamlLoader.Load(this);
}
diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj
index c0e24357ca..7cbd8a3f9c 100644
--- a/samples/ControlCatalog/ControlCatalog.csproj
+++ b/samples/ControlCatalog/ControlCatalog.csproj
@@ -23,6 +23,7 @@
+
diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index 85f278b5fa..59d724db69 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -43,6 +43,9 @@
+
+
+
diff --git a/samples/ControlCatalog/MainView.xaml.cs b/samples/ControlCatalog/MainView.xaml.cs
index e8ea39abbb..0326946c08 100644
--- a/samples/ControlCatalog/MainView.xaml.cs
+++ b/samples/ControlCatalog/MainView.xaml.cs
@@ -49,7 +49,8 @@ namespace ControlCatalog
App.Fluent.Mode = FluentThemeMode.Light;
}
Application.Current.Styles[0] = App.Fluent;
- Application.Current.Styles[1] = App.DataGridFluent;
+ Application.Current.Styles[1] = App.ColorPickerFluent;
+ Application.Current.Styles[2] = App.DataGridFluent;
}
else if (theme == CatalogTheme.FluentDark)
{
@@ -59,19 +60,22 @@ namespace ControlCatalog
App.Fluent.Mode = FluentThemeMode.Dark;
}
Application.Current.Styles[0] = App.Fluent;
- Application.Current.Styles[1] = App.DataGridFluent;
+ Application.Current.Styles[1] = App.ColorPickerFluent;
+ Application.Current.Styles[2] = App.DataGridFluent;
}
else if (theme == CatalogTheme.DefaultLight)
{
App.Default.Mode = Avalonia.Themes.Default.SimpleThemeMode.Light;
Application.Current.Styles[0] = App.DefaultLight;
- Application.Current.Styles[1] = App.DataGridDefault;
+ Application.Current.Styles[1] = App.ColorPickerDefault;
+ Application.Current.Styles[2] = App.DataGridDefault;
}
else if (theme == CatalogTheme.DefaultDark)
{
App.Default.Mode = Avalonia.Themes.Default.SimpleThemeMode.Dark;
Application.Current.Styles[0] = App.DefaultDark;
- Application.Current.Styles[1] = App.DataGridDefault;
+ Application.Current.Styles[1] = App.ColorPickerDefault;
+ Application.Current.Styles[2] = App.DataGridDefault;
}
}
};
diff --git a/samples/ControlCatalog/Pages/ColorPickerPage.xaml b/samples/ControlCatalog/Pages/ColorPickerPage.xaml
new file mode 100644
index 0000000000..ec34193f8c
--- /dev/null
+++ b/samples/ControlCatalog/Pages/ColorPickerPage.xaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/ColorPickerPage.xaml.cs b/samples/ControlCatalog/Pages/ColorPickerPage.xaml.cs
new file mode 100644
index 0000000000..6e017e381f
--- /dev/null
+++ b/samples/ControlCatalog/Pages/ColorPickerPage.xaml.cs
@@ -0,0 +1,19 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace ControlCatalog.Pages
+{
+ public partial class ColorPickerPage : UserControl
+ {
+ public ColorPickerPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+ }
+}
diff --git a/samples/Sandbox/Sandbox.csproj b/samples/Sandbox/Sandbox.csproj
index 8f2812e048..f3c38cd96e 100644
--- a/samples/Sandbox/Sandbox.csproj
+++ b/samples/Sandbox/Sandbox.csproj
@@ -8,6 +8,7 @@
+
diff --git a/src/Avalonia.Base/Properties/AssemblyInfo.cs b/src/Avalonia.Base/Properties/AssemblyInfo.cs
index a0560924e7..2c40c768f5 100644
--- a/src/Avalonia.Base/Properties/AssemblyInfo.cs
+++ b/src/Avalonia.Base/Properties/AssemblyInfo.cs
@@ -19,6 +19,7 @@ using Avalonia.Metadata;
[assembly: InternalsVisibleTo("Avalonia.Base.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")]
[assembly: InternalsVisibleTo("Avalonia.Controls, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")]
+[assembly: InternalsVisibleTo("Avalonia.Controls.ColorPicker, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")]
[assembly: InternalsVisibleTo("Avalonia.Controls.DataGrid, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")]
[assembly: InternalsVisibleTo("Avalonia.Controls.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")]
[assembly: InternalsVisibleTo("Avalonia.DesignerSupport, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")]
diff --git a/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj
new file mode 100644
index 0000000000..0952c899d4
--- /dev/null
+++ b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj
@@ -0,0 +1,25 @@
+
+
+ net6.0;netstandard2.0
+ Avalonia.Controls.ColorPicker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Controls.ColorPicker/ColorChangedEventArgs.cs b/src/Avalonia.Controls.ColorPicker/ColorChangedEventArgs.cs
new file mode 100644
index 0000000000..b1d15d6b17
--- /dev/null
+++ b/src/Avalonia.Controls.ColorPicker/ColorChangedEventArgs.cs
@@ -0,0 +1,41 @@
+// Portions of this source file are adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under the MIT License.
+
+using System;
+using Avalonia.Media;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// Holds the details of a ColorChanged event.
+ ///
+ ///
+ /// HSV color information is intentionally not provided.
+ /// Use to obtain it.
+ ///
+ public class ColorChangedEventArgs : EventArgs
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The old/original color from before the change event.
+ /// The new/updated color that triggered the change event.
+ public ColorChangedEventArgs(Color oldColor, Color newColor)
+ {
+ OldColor = oldColor;
+ NewColor = newColor;
+ }
+
+ ///
+ /// Gets the old/original color from before the change event.
+ ///
+ public Color OldColor { get; private set; }
+
+ ///
+ /// Gets the new/updated color that triggered the change event.
+ ///
+ public Color NewColor { get; private set; }
+ }
+}
diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorHelpers.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorHelpers.cs
new file mode 100644
index 0000000000..b912d39aba
--- /dev/null
+++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorHelpers.cs
@@ -0,0 +1,414 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+
+namespace Avalonia.Controls.Primitives
+{
+ internal static class ColorHelpers
+ {
+ public const int CheckerSize = 4;
+
+ public static bool ToDisplayNameExists
+ {
+ get => false;
+ }
+
+ public static string ToDisplayName(Color color)
+ {
+ return string.Empty;
+ }
+
+ public static Hsv IncrementColorComponent(
+ Hsv originalHsv,
+ HsvComponent component,
+ IncrementDirection direction,
+ IncrementAmount amount,
+ bool shouldWrap,
+ double minBound,
+ double maxBound)
+ {
+ Hsv newHsv = originalHsv;
+
+ if (amount == IncrementAmount.Small || !ToDisplayNameExists)
+ {
+ // In order to avoid working with small values that can incur rounding issues,
+ // we'll multiple saturation and value by 100 to put them in the range of 0-100 instead of 0-1.
+ newHsv.S *= 100;
+ newHsv.V *= 100;
+
+ // Note: *valueToIncrement replaced with ref local variable for C#, must be initialized
+ ref double valueToIncrement = ref newHsv.H;
+ double incrementAmount = 0.0;
+
+ // If we're adding a small increment, then we'll just add or subtract 1.
+ // If we're adding a large increment, then we want to snap to the next
+ // or previous major value - for hue, this is every increment of 30;
+ // for saturation and value, this is every increment of 10.
+ switch (component)
+ {
+ case HsvComponent.Hue:
+ valueToIncrement = ref newHsv.H;
+ incrementAmount = amount == IncrementAmount.Small ? 1 : 30;
+ break;
+
+ case HsvComponent.Saturation:
+ valueToIncrement = ref newHsv.S;
+ incrementAmount = amount == IncrementAmount.Small ? 1 : 10;
+ break;
+
+ case HsvComponent.Value:
+ valueToIncrement = ref newHsv.V;
+ incrementAmount = amount == IncrementAmount.Small ? 1 : 10;
+ break;
+
+ default:
+ throw new InvalidOperationException("Invalid HsvComponent.");
+ }
+
+ double previousValue = valueToIncrement;
+
+ valueToIncrement += (direction == IncrementDirection.Lower ? -incrementAmount : incrementAmount);
+
+ // If the value has reached outside the bounds, we were previous at the boundary, and we should wrap,
+ // then we'll place the selection on the other side of the spectrum.
+ // Otherwise, we'll place it on the boundary that was exceeded.
+ if (valueToIncrement < minBound)
+ {
+ valueToIncrement = (shouldWrap && previousValue == minBound) ? maxBound : minBound;
+ }
+
+ if (valueToIncrement > maxBound)
+ {
+ valueToIncrement = (shouldWrap && previousValue == maxBound) ? minBound : maxBound;
+ }
+
+ // We multiplied saturation and value by 100 previously, so now we want to put them back in the 0-1 range.
+ newHsv.S /= 100;
+ newHsv.V /= 100;
+ }
+ else
+ {
+ // While working with named colors, we're going to need to be working in actual HSV units,
+ // so we'll divide the min bound and max bound by 100 in the case of saturation or value,
+ // since we'll have received units between 0-100 and we need them within 0-1.
+ if (component == HsvComponent.Saturation ||
+ component == HsvComponent.Value)
+ {
+ minBound /= 100;
+ maxBound /= 100;
+ }
+
+ newHsv = FindNextNamedColor(originalHsv, component, direction, shouldWrap, minBound, maxBound);
+ }
+
+ return newHsv;
+ }
+
+ public static Hsv FindNextNamedColor(
+ Hsv originalHsv,
+ HsvComponent component,
+ IncrementDirection direction,
+ bool shouldWrap,
+ double minBound,
+ double maxBound)
+ {
+ // There's no easy way to directly get the next named color, so what we'll do
+ // is just iterate in the direction that we want to find it until we find a color
+ // in that direction that has a color name different than our current color name.
+ // Once we find a new color name, then we'll iterate across that color name until
+ // we find its bounds on the other side, and then select the color that is exactly
+ // in the middle of that color's bounds.
+ Hsv newHsv = originalHsv;
+
+ string originalColorName = ColorHelpers.ToDisplayName(originalHsv.ToRgb().ToColor());
+ string newColorName = originalColorName;
+
+ // Note: *newValue replaced with ref local variable for C#, must be initialized
+ double originalValue = 0.0;
+ ref double newValue = ref newHsv.H;
+ double incrementAmount = 0.0;
+
+ switch (component)
+ {
+ case HsvComponent.Hue:
+ originalValue = originalHsv.H;
+ newValue = ref newHsv.H;
+ incrementAmount = 1;
+ break;
+
+ case HsvComponent.Saturation:
+ originalValue = originalHsv.S;
+ newValue = ref newHsv.S;
+ incrementAmount = 0.01;
+ break;
+
+ case HsvComponent.Value:
+ originalValue = originalHsv.V;
+ newValue = ref newHsv.V;
+ incrementAmount = 0.01;
+ break;
+
+ default:
+ throw new InvalidOperationException("Invalid HsvComponent.");
+ }
+
+ bool shouldFindMidPoint = true;
+
+ while (newColorName == originalColorName)
+ {
+ double previousValue = newValue;
+ newValue += (direction == IncrementDirection.Lower ? -1 : 1) * incrementAmount;
+
+ bool justWrapped = false;
+
+ // If we've hit a boundary, then either we should wrap or we shouldn't.
+ // If we should, then we'll perform that wrapping if we were previously up against
+ // the boundary that we've now hit. Otherwise, we'll stop at that boundary.
+ if (newValue > maxBound)
+ {
+ if (shouldWrap)
+ {
+ newValue = minBound;
+ justWrapped = true;
+ }
+ else
+ {
+ newValue = maxBound;
+ shouldFindMidPoint = false;
+ newColorName = ColorHelpers.ToDisplayName(newHsv.ToRgb().ToColor());
+ break;
+ }
+ }
+ else if (newValue < minBound)
+ {
+ if (shouldWrap)
+ {
+ newValue = maxBound;
+ justWrapped = true;
+ }
+ else
+ {
+ newValue = minBound;
+ shouldFindMidPoint = false;
+ newColorName = ColorHelpers.ToDisplayName(newHsv.ToRgb().ToColor());
+ break;
+ }
+ }
+
+ if (!justWrapped &&
+ previousValue != originalValue &&
+ Math.Sign(newValue - originalValue) != Math.Sign(previousValue - originalValue))
+ {
+ // If we've wrapped all the way back to the start and have failed to find a new color name,
+ // then we'll just quit - there isn't a new color name that we're going to find.
+ shouldFindMidPoint = false;
+ break;
+ }
+
+ newColorName = ColorHelpers.ToDisplayName(newHsv.ToRgb().ToColor());
+ }
+
+ if (shouldFindMidPoint)
+ {
+ Hsv startHsv = newHsv;
+ Hsv currentHsv = startHsv;
+ double startEndOffset = 0;
+ string currentColorName = newColorName;
+
+ // Note: *startValue/*currentValue replaced with ref local variables for C#, must be initialized
+ ref double startValue = ref startHsv.H;
+ ref double currentValue = ref currentHsv.H;
+ double wrapIncrement = 0;
+
+ switch (component)
+ {
+ case HsvComponent.Hue:
+ startValue = ref startHsv.H;
+ currentValue = ref currentHsv.H;
+ wrapIncrement = 360.0;
+ break;
+
+ case HsvComponent.Saturation:
+ startValue = ref startHsv.S;
+ currentValue = ref currentHsv.S;
+ wrapIncrement = 1.0;
+ break;
+
+ case HsvComponent.Value:
+ startValue = ref startHsv.V;
+ currentValue = ref currentHsv.V;
+ wrapIncrement = 1.0;
+ break;
+
+ default:
+ throw new InvalidOperationException("Invalid HsvComponent.");
+ }
+
+ while (newColorName == currentColorName)
+ {
+ currentValue += (direction == IncrementDirection.Lower ? -1 : 1) * incrementAmount;
+
+ // If we've hit a boundary, then either we should wrap or we shouldn't.
+ // If we should, then we'll perform that wrapping if we were previously up against
+ // the boundary that we've now hit. Otherwise, we'll stop at that boundary.
+ if (currentValue > maxBound)
+ {
+ if (shouldWrap)
+ {
+ currentValue = minBound;
+ startEndOffset = maxBound - minBound;
+ }
+ else
+ {
+ currentValue = maxBound;
+ break;
+ }
+ }
+ else if (currentValue < minBound)
+ {
+ if (shouldWrap)
+ {
+ currentValue = maxBound;
+ startEndOffset = minBound - maxBound;
+ }
+ else
+ {
+ currentValue = minBound;
+ break;
+ }
+ }
+
+ currentColorName = ColorHelpers.ToDisplayName(currentHsv.ToRgb().ToColor());
+ }
+
+ newValue = (startValue + currentValue + startEndOffset) / 2;
+
+ // Dividing by 2 may have gotten us halfway through a single step, so we'll
+ // remove that half-step if it exists.
+ double leftoverValue = Math.Abs(newValue);
+
+ while (leftoverValue > incrementAmount)
+ {
+ leftoverValue -= incrementAmount;
+ }
+
+ newValue -= leftoverValue;
+
+ while (newValue < minBound)
+ {
+ newValue += wrapIncrement;
+ }
+
+ while (newValue > maxBound)
+ {
+ newValue -= wrapIncrement;
+ }
+ }
+
+ return newHsv;
+ }
+
+ public static double IncrementAlphaComponent(
+ double originalAlpha,
+ IncrementDirection direction,
+ IncrementAmount amount,
+ bool shouldWrap,
+ double minBound,
+ double maxBound)
+ {
+ // In order to avoid working with small values that can incur rounding issues,
+ // we'll multiple alpha by 100 to put it in the range of 0-100 instead of 0-1.
+ originalAlpha *= 100;
+
+ const double smallIncrementAmount = 1;
+ const double largeIncrementAmount = 10;
+
+ if (amount == IncrementAmount.Small)
+ {
+ originalAlpha += (direction == IncrementDirection.Lower ? -1 : 1) * smallIncrementAmount;
+ }
+ else
+ {
+ if (direction == IncrementDirection.Lower)
+ {
+ originalAlpha = Math.Ceiling((originalAlpha - largeIncrementAmount) / largeIncrementAmount) * largeIncrementAmount;
+ }
+ else
+ {
+ originalAlpha = Math.Floor((originalAlpha + largeIncrementAmount) / largeIncrementAmount) * largeIncrementAmount;
+ }
+ }
+
+ // If the value has reached outside the bounds and we should wrap, then we'll place the selection
+ // on the other side of the spectrum. Otherwise, we'll place it on the boundary that was exceeded.
+ if (originalAlpha < minBound)
+ {
+ originalAlpha = shouldWrap ? maxBound : minBound;
+ }
+
+ if (originalAlpha > maxBound)
+ {
+ originalAlpha = shouldWrap ? minBound : maxBound;
+ }
+
+ // We multiplied alpha by 100 previously, so now we want to put it back in the 0-1 range.
+ return originalAlpha / 100;
+ }
+
+ public static WriteableBitmap CreateBitmapFromPixelData(
+ int pixelWidth,
+ int pixelHeight,
+ List bgraPixelData)
+ {
+ Vector dpi = new Vector(96, 96); // Standard may need to change on some devices
+
+ WriteableBitmap bitmap = new WriteableBitmap(
+ new PixelSize(pixelWidth, pixelHeight),
+ dpi,
+ PixelFormat.Bgra8888,
+ AlphaFormat.Premul);
+
+ // Warning: This is highly questionable
+ using (var frameBuffer = bitmap.Lock())
+ {
+ Marshal.Copy(bgraPixelData.ToArray(), 0, frameBuffer.Address, bgraPixelData.Count);
+ }
+
+ return bitmap;
+ }
+
+ ///
+ /// Gets the relative (perceptual) luminance/brightness of the given color.
+ /// 1 is closer to white while 0 is closer to black.
+ ///
+ /// The color to calculate relative luminance for.
+ /// The relative (perceptual) luminance/brightness of the given color.
+ public static double GetRelativeLuminance(Color color)
+ {
+ // The equation for relative luminance 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.
+
+ double rg = color.R <= 10 ? color.R / 3294.0 : Math.Pow(color.R / 269.0 + 0.0513, 2.4);
+ double gg = color.G <= 10 ? color.G / 3294.0 : Math.Pow(color.G / 269.0 + 0.0513, 2.4);
+ double bg = color.B <= 10 ? color.B / 3294.0 : Math.Pow(color.B / 269.0 + 0.0513, 2.4);
+
+ return (0.2126 * rg + 0.7152 * gg + 0.0722 * bg);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs
new file mode 100644
index 0000000000..824bf9ab05
--- /dev/null
+++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs
@@ -0,0 +1,207 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under the MIT License.
+
+using Avalonia.Media;
+
+namespace Avalonia.Controls.Primitives
+{
+ ///
+ public partial class ColorSpectrum
+ {
+ ///
+ /// Gets or sets the currently selected color in the RGB color model.
+ ///
+ ///
+ /// For control authors use instead to avoid loss
+ /// of precision and color drifting.
+ ///
+ public Color Color
+ {
+ get => GetValue(ColorProperty);
+ set => SetValue(ColorProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ColorProperty =
+ AvaloniaProperty.Register(
+ nameof(Color),
+ Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF));
+
+ ///
+ /// Gets or sets the two HSV color components displayed by the spectrum.
+ ///
+ ///
+ /// Internally, the uses the HSV color model.
+ ///
+ public ColorSpectrumComponents Components
+ {
+ get => GetValue(ComponentsProperty);
+ set => SetValue(ComponentsProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ComponentsProperty =
+ AvaloniaProperty.Register(
+ nameof(Components),
+ ColorSpectrumComponents.HueSaturation);
+
+ ///
+ /// Gets or sets the currently selected color in the HSV color model.
+ ///
+ ///
+ /// This should be used in all cases instead of the property.
+ /// Internally, the uses the HSV color model and using
+ /// this property will avoid loss of precision and color drifting.
+ ///
+ public HsvColor HsvColor
+ {
+ get => GetValue(HsvColorProperty);
+ set => SetValue(HsvColorProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty HsvColorProperty =
+ AvaloniaProperty.Register(
+ nameof(HsvColor),
+ new HsvColor(1, 0, 0, 1));
+
+ ///
+ /// Gets or sets the maximum value of the Hue component in the range from 0..359.
+ /// This property must be greater than .
+ ///
+ ///
+ /// Internally, the uses the HSV color model.
+ ///
+ public int MaxHue
+ {
+ get => GetValue(MaxHueProperty);
+ set => SetValue(MaxHueProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MaxHueProperty =
+ AvaloniaProperty.Register(nameof(MaxHue), 359);
+
+ ///
+ /// Gets or sets the maximum value of the Saturation component in the range from 0..100.
+ /// This property must be greater than .
+ ///
+ ///
+ /// Internally, the uses the HSV color model.
+ ///
+ public int MaxSaturation
+ {
+ get => GetValue(MaxSaturationProperty);
+ set => SetValue(MaxSaturationProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MaxSaturationProperty =
+ AvaloniaProperty.Register(nameof(MaxSaturation), 100);
+
+ ///
+ /// Gets or sets the maximum value of the Value component in the range from 0..100.
+ /// This property must be greater than .
+ ///
+ ///
+ /// Internally, the uses the HSV color model.
+ ///
+ public int MaxValue
+ {
+ get => GetValue(MaxValueProperty);
+ set => SetValue(MaxValueProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MaxValueProperty =
+ AvaloniaProperty.Register(nameof(MaxValue), 100);
+
+ ///
+ /// Gets or sets the minimum value of the Hue component in the range from 0..359.
+ /// This property must be less than .
+ ///
+ ///
+ /// Internally, the uses the HSV color model.
+ ///
+ public int MinHue
+ {
+ get => GetValue(MinHueProperty);
+ set => SetValue(MinHueProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MinHueProperty =
+ AvaloniaProperty.Register(nameof(MinHue), 0);
+
+ ///
+ /// Gets or sets the minimum value of the Saturation component in the range from 0..100.
+ /// This property must be less than .
+ ///
+ ///
+ /// Internally, the uses the HSV color model.
+ ///
+ public int MinSaturation
+ {
+ get => GetValue(MinSaturationProperty);
+ set => SetValue(MinSaturationProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MinSaturationProperty =
+ AvaloniaProperty.Register(nameof(MinSaturation), 0);
+
+ ///
+ /// Gets or sets the minimum value of the Value component in the range from 0..100.
+ /// This property must be less than .
+ ///
+ ///
+ /// Internally, the uses the HSV color model.
+ ///
+ public int MinValue
+ {
+ get => GetValue(MinValueProperty);
+ set => SetValue(MinValueProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MinValueProperty =
+ AvaloniaProperty.Register(nameof(MinValue), 0);
+
+ ///
+ /// Gets or sets the displayed shape of the spectrum.
+ ///
+ public ColorSpectrumShape Shape
+ {
+ get => GetValue(ShapeProperty);
+ set => SetValue(ShapeProperty, value);
+ }
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ShapeProperty =
+ AvaloniaProperty.Register(
+ nameof(Shape),
+ ColorSpectrumShape.Box);
+ }
+}
diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs
new file mode 100644
index 0000000000..fe9a2fac43
--- /dev/null
+++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs
@@ -0,0 +1,1578 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Avalonia.Utilities;
+
+namespace Avalonia.Controls.Primitives
+{
+ ///
+ /// A two dimensional spectrum for color selection.
+ ///
+ [TemplatePart("PART_ColorNameToolTip", typeof(ToolTip))]
+ [TemplatePart("PART_InputTarget", typeof(Canvas))]
+ [TemplatePart("PART_LayoutRoot", typeof(Panel))]
+ [TemplatePart("PART_SelectionEllipsePanel", typeof(Panel))]
+ [TemplatePart("PART_SizingPanel", typeof(Panel))]
+ [TemplatePart("PART_SpectrumEllipse", typeof(Ellipse))]
+ [TemplatePart("PART_SpectrumRectangle", typeof(Rectangle))]
+ [TemplatePart("PART_SpectrumOverlayEllipse", typeof(Ellipse))]
+ [TemplatePart("PART_SpectrumOverlayRectangle", typeof(Rectangle))]
+ [PseudoClasses(pcPressed, pcLargeSelector, pcLightSelector)]
+ public partial class ColorSpectrum : TemplatedControl
+ {
+ protected const string pcPressed = ":pressed";
+ protected const string pcLargeSelector = ":large-selector";
+ protected const string pcLightSelector = ":light-selector";
+
+ ///
+ /// Event for when the selected color changes within the spectrum.
+ ///
+ public event EventHandler? ColorChanged;
+
+ private bool _updatingColor = false;
+ private bool _updatingHsvColor = false;
+ private bool _isPointerOver = false;
+ private bool _isPointerPressed = false;
+ private bool _shouldShowLargeSelection = false;
+ private List _hsvValues = new List();
+
+ private IDisposable? _layoutRootDisposable;
+ private IDisposable? _selectionEllipsePanelDisposable;
+
+ // XAML template parts
+ private Panel? _layoutRoot;
+ private Panel? _sizingPanel;
+ private Rectangle? _spectrumRectangle;
+ private Ellipse? _spectrumEllipse;
+ private Rectangle? _spectrumOverlayRectangle;
+ private Ellipse? _spectrumOverlayEllipse;
+ private Canvas? _inputTarget;
+ private Panel? _selectionEllipsePanel;
+ private ToolTip? _colorNameToolTip;
+
+ // Put the spectrum images in a bitmap, which is then given to an ImageBrush.
+ private WriteableBitmap? _hueRedBitmap;
+ private WriteableBitmap? _hueYellowBitmap;
+ private WriteableBitmap? _hueGreenBitmap;
+ private WriteableBitmap? _hueCyanBitmap;
+ private WriteableBitmap? _hueBlueBitmap;
+ private WriteableBitmap? _huePurpleBitmap;
+
+ private WriteableBitmap? _saturationMinimumBitmap;
+ private WriteableBitmap? _saturationMaximumBitmap;
+
+ private WriteableBitmap? _valueBitmap;
+
+ // Fields used by UpdateEllipse() to ensure that it's using the data
+ // associated with the last call to CreateBitmapsAndColorMap(),
+ // in order to function properly while the asynchronous bitmap creation
+ // is in progress.
+ private ColorSpectrumShape _shapeFromLastBitmapCreation = ColorSpectrumShape.Box;
+ private ColorSpectrumComponents _componentsFromLastBitmapCreation = ColorSpectrumComponents.HueSaturation;
+ private double _imageWidthFromLastBitmapCreation = 0.0;
+ private double _imageHeightFromLastBitmapCreation = 0.0;
+ private int _minHueFromLastBitmapCreation = 0;
+ private int _maxHueFromLastBitmapCreation = 0;
+ private int _minSaturationFromLastBitmapCreation = 0;
+ private int _maxSaturationFromLastBitmapCreation = 0;
+ private int _minValueFromLastBitmapCreation = 0;
+ private int _maxValueFromLastBitmapCreation = 0;
+
+ private Color _oldColor = Color.FromArgb(255, 255, 255, 255);
+ private HsvColor _oldHsvColor = HsvColor.FromAhsv(0.0f, 0.0f, 1.0f, 1.0f);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ColorSpectrum()
+ {
+ _shapeFromLastBitmapCreation = Shape;
+ _componentsFromLastBitmapCreation = Components;
+ _imageWidthFromLastBitmapCreation = 0;
+ _imageHeightFromLastBitmapCreation = 0;
+ _minHueFromLastBitmapCreation = MinHue;
+ _maxHueFromLastBitmapCreation = MaxHue;
+ _minSaturationFromLastBitmapCreation = MinSaturation;
+ _maxSaturationFromLastBitmapCreation = MaxSaturation;
+ _minValueFromLastBitmapCreation = MinValue;
+ _maxValueFromLastBitmapCreation = MaxValue;
+ }
+
+ ///
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ UnregisterEvents(); // Failsafe
+
+ _colorNameToolTip = e.NameScope.Find("PART_ColorNameToolTip");
+ _inputTarget = e.NameScope.Find
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
index b36e0c7297..5b217e4764 100644
--- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
@@ -61,8 +61,8 @@
-
-
+
+
diff --git a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj
index 0c663e1a8f..a00b24bdd7 100644
--- a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj
+++ b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj
@@ -9,6 +9,7 @@
+