Browse Source

Merge pull request #7842 from robloo/colorspectrum

Add new ColorSpectrum Primitive
Max Katz 3 years ago
parent
commit
4f0ad9921b
33 changed files with 3139 additions and 23 deletions
  1. 26 0
      Avalonia.sln
  2. 12 1
      samples/ControlCatalog/App.xaml.cs
  3. 1 0
      samples/ControlCatalog/ControlCatalog.csproj
  4. 3 0
      samples/ControlCatalog/MainView.xaml
  5. 8 4
      samples/ControlCatalog/MainView.xaml.cs
  6. 29 0
      samples/ControlCatalog/Pages/ColorPickerPage.xaml
  7. 19 0
      samples/ControlCatalog/Pages/ColorPickerPage.xaml.cs
  8. 1 0
      samples/Sandbox/Sandbox.csproj
  9. 1 0
      src/Avalonia.Base/Properties/AssemblyInfo.cs
  10. 25 0
      src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj
  11. 41 0
      src/Avalonia.Controls.ColorPicker/ColorChangedEventArgs.cs
  12. 414 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorHelpers.cs
  13. 207 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs
  14. 1578 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs
  15. 86 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrum/Hsv.cs
  16. 23 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrum/IncrementAmount.cs
  17. 23 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrum/IncrementDirection.cs
  18. 94 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrum/Rgb.cs
  19. 73 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrumComponents.cs
  20. 26 0
      src/Avalonia.Controls.ColorPicker/ColorSpectrumShape.cs
  21. 47 0
      src/Avalonia.Controls.ColorPicker/HsvComponent.cs
  22. 8 0
      src/Avalonia.Controls.ColorPicker/Properties/AssemblyInfo.cs
  23. 134 0
      src/Avalonia.Controls.ColorPicker/Themes/Default.xaml
  24. 134 0
      src/Avalonia.Controls.ColorPicker/Themes/Fluent.xaml
  25. 9 8
      src/Avalonia.Controls/Converters/CornerRadiusFilterConverter.cs
  26. 46 0
      src/Avalonia.Controls/Converters/CornerRadiusToDoubleConverter.cs
  27. 11 7
      src/Avalonia.Controls/Converters/Corners.cs
  28. 54 0
      src/Avalonia.Controls/Converters/EnumValueEqualsConverter.cs
  29. 0 1
      src/Avalonia.Controls/Converters/MarginMultiplierConverter.cs
  30. 2 0
      src/Avalonia.Controls/SplitButton/SplitButton.cs
  31. 1 0
      src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj
  32. 2 2
      src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
  33. 1 0
      tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj

+ 26 - 0
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

+ 12 - 1
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);
         }
 

+ 1 - 0
samples/ControlCatalog/ControlCatalog.csproj

@@ -23,6 +23,7 @@
 
   <ItemGroup>
     <ProjectReference Include="..\..\packages\Avalonia\Avalonia.csproj" />
+    <ProjectReference Include="..\..\src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj" />
     <ProjectReference Include="..\MiniMvvm\MiniMvvm.csproj" />
     <ProjectReference Include="..\SampleControls\ControlSamples.csproj" />

+ 3 - 0
samples/ControlCatalog/MainView.xaml

@@ -43,6 +43,9 @@
       <TabItem Header="Clipboard">
         <pages:ClipboardPage />
       </TabItem>
+      <TabItem Header="ColorPicker">
+        <pages:ColorPickerPage />
+      </TabItem>
       <TabItem Header="ComboBox">
         <pages:ComboBoxPage />
       </TabItem>

+ 8 - 4
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;
                     }
                 }
             };

+ 29 - 0
samples/ControlCatalog/Pages/ColorPickerPage.xaml

@@ -0,0 +1,29 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             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"
+             xmlns:primitives="clr-namespace:Avalonia.Controls.Primitives;assembly=Avalonia.Controls"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="ControlCatalog.Pages.ColorPickerPage">
+
+  <Grid ColumnDefinitions="Auto,Auto"
+        RowDefinitions="Auto,Auto">
+    <ColorSpectrum Grid.Column="0"
+                   Grid.Row="0"
+                   Color="Red"
+                   Height="256"
+                   Width="256" />
+    <ColorSpectrum Grid.Column="1"
+                   Grid.Row="0"
+                   Color="Green"
+                   Shape="Ring"
+                   Height="256"
+                   Width="256" />
+    <ColorSpectrum Grid.Column="0"
+                   Grid.Row="1"
+                   CornerRadius="10"
+                   Color="Blue"
+                   Height="256"
+                   Width="256" />
+  </Grid>
+</UserControl>

+ 19 - 0
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);
+        }
+    }
+}

+ 1 - 0
samples/Sandbox/Sandbox.csproj

@@ -8,6 +8,7 @@
 
   <ItemGroup>
     <ProjectReference Include="..\..\src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj" />
+    <ProjectReference Include="..\..\src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj" />
   </ItemGroup>
   

+ 1 - 0
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")]

+ 25 - 0
src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj

@@ -0,0 +1,25 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFrameworks>net6.0;netstandard2.0</TargetFrameworks>
+    <PackageId>Avalonia.Controls.ColorPicker</PackageId>
+  </PropertyGroup>
+  <ItemGroup>
+    <Compile Include="..\Avalonia.Base\Metadata\NullableAttributes.cs" Link="NullableAttributes.cs" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\Avalonia.Base\Avalonia.Base.csproj" />
+    <ProjectReference Include="..\Avalonia.Remote.Protocol\Avalonia.Remote.Protocol.csproj" />
+    <ProjectReference Include="..\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />
+    <ProjectReference Include="..\Markup\Avalonia.Markup\Avalonia.Markup.csproj" />    
+    <ProjectReference Include="..\Avalonia.Controls\Avalonia.Controls.csproj" />
+    <!-- Compatibility with old apps -->
+    <EmbeddedResource Include="Themes\**\*.xaml" />
+  </ItemGroup>
+  <Import Project="..\..\build\Rx.props" />
+  <Import Project="..\..\build\EmbedXaml.props" />
+  <Import Project="..\..\build\JetBrains.Annotations.props" />
+  <Import Project="..\..\build\BuildTargets.targets" />
+  <!--<Import Project="..\..\build\ApiDiff.props" />-->
+  <Import Project="..\..\build\NullableEnable.props" />
+  <Import Project="..\..\build\DevAnalyzers.props" />
+</Project>

+ 41 - 0
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
+{
+    /// <summary>
+    /// Holds the details of a ColorChanged event.
+    /// </summary>
+    /// <remarks>
+    /// HSV color information is intentionally not provided.
+    /// Use <see cref="Color.ToHsv()"/> to obtain it.
+    /// </remarks>
+    public class ColorChangedEventArgs : EventArgs
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ColorChangedEventArgs"/> class.
+        /// </summary>
+        /// <param name="oldColor">The old/original color from before the change event.</param>
+        /// <param name="newColor">The new/updated color that triggered the change event.</param>
+        public ColorChangedEventArgs(Color oldColor, Color newColor)
+        {
+            OldColor = oldColor;
+            NewColor = newColor;
+        }
+
+        /// <summary>
+        /// Gets the old/original color from before the change event.
+        /// </summary>
+        public Color OldColor { get; private set; }
+
+        /// <summary>
+        /// Gets the new/updated color that triggered the change event.
+        /// </summary>
+        public Color NewColor { get; private set; }
+    }
+}

+ 414 - 0
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<byte> 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;
+        }
+
+        /// <summary>
+        /// Gets the relative (perceptual) luminance/brightness of the given color.
+        /// 1 is closer to white while 0 is closer to black.
+        /// </summary>
+        /// <param name="color">The color to calculate relative luminance for.</param>
+        /// <returns>The relative (perceptual) luminance/brightness of the given color.</returns>
+        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);
+        }
+    }
+}

+ 207 - 0
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
+{
+    /// <inheritdoc/>
+    public partial class ColorSpectrum
+    {
+        /// <summary>
+        /// Gets or sets the currently selected color in the RGB color model.
+        /// </summary>
+        /// <remarks>
+        /// For control authors use <see cref="HsvColor"/> instead to avoid loss
+        /// of precision and color drifting.
+        /// </remarks>
+        public Color Color
+        {
+            get => GetValue(ColorProperty);
+            set => SetValue(ColorProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="Color"/> property.
+        /// </summary>
+        public static readonly StyledProperty<Color> ColorProperty =
+            AvaloniaProperty.Register<ColorSpectrum, Color>(
+                nameof(Color),
+                Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF));
+
+        /// <summary>
+        /// Gets or sets the two HSV color components displayed by the spectrum.
+        /// </summary>
+        /// <remarks>
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model.
+        /// </remarks>
+        public ColorSpectrumComponents Components
+        {
+            get => GetValue(ComponentsProperty);
+            set => SetValue(ComponentsProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="Components"/> property.
+        /// </summary>
+        public static readonly StyledProperty<ColorSpectrumComponents> ComponentsProperty =
+            AvaloniaProperty.Register<ColorSpectrum, ColorSpectrumComponents>(
+                nameof(Components),
+                ColorSpectrumComponents.HueSaturation);
+
+        /// <summary>
+        /// Gets or sets the currently selected color in the HSV color model.
+        /// </summary>
+        /// <remarks>
+        /// This should be used in all cases instead of the <see cref="Color"/> property.
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model and using
+        /// this property will avoid loss of precision and color drifting.
+        /// </remarks>
+        public HsvColor HsvColor
+        {
+            get => GetValue(HsvColorProperty);
+            set => SetValue(HsvColorProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="HsvColor"/> property.
+        /// </summary>
+        public static readonly StyledProperty<HsvColor> HsvColorProperty =
+            AvaloniaProperty.Register<ColorSpectrum, HsvColor>(
+                nameof(HsvColor),
+                new HsvColor(1, 0, 0, 1));
+
+        /// <summary>
+        /// Gets or sets the maximum value of the Hue component in the range from 0..359.
+        /// This property must be greater than <see cref="MinHue"/>.
+        /// </summary>
+        /// <remarks>
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model.
+        /// </remarks>
+        public int MaxHue
+        {
+            get => GetValue(MaxHueProperty);
+            set => SetValue(MaxHueProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="MaxHue"/> property.
+        /// </summary>
+        public static readonly StyledProperty<int> MaxHueProperty =
+            AvaloniaProperty.Register<ColorSpectrum, int>(nameof(MaxHue), 359);
+
+        /// <summary>
+        /// Gets or sets the maximum value of the Saturation component in the range from 0..100.
+        /// This property must be greater than <see cref="MinSaturation"/>.
+        /// </summary>
+        /// <remarks>
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model.
+        /// </remarks>
+        public int MaxSaturation
+        {
+            get => GetValue(MaxSaturationProperty);
+            set => SetValue(MaxSaturationProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="MaxSaturation"/> property.
+        /// </summary>
+        public static readonly StyledProperty<int> MaxSaturationProperty =
+            AvaloniaProperty.Register<ColorSpectrum, int>(nameof(MaxSaturation), 100);
+
+        /// <summary>
+        /// Gets or sets the maximum value of the Value component in the range from 0..100.
+        /// This property must be greater than <see cref="MinValue"/>.
+        /// </summary>
+        /// <remarks>
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model.
+        /// </remarks>
+        public int MaxValue
+        {
+            get => GetValue(MaxValueProperty);
+            set => SetValue(MaxValueProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="MaxValue"/> property.
+        /// </summary>
+        public static readonly StyledProperty<int> MaxValueProperty =
+            AvaloniaProperty.Register<ColorSpectrum, int>(nameof(MaxValue), 100);
+
+        /// <summary>
+        /// Gets or sets the minimum value of the Hue component in the range from 0..359.
+        /// This property must be less than <see cref="MaxHue"/>.
+        /// </summary>
+        /// <remarks>
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model.
+        /// </remarks>
+        public int MinHue
+        {
+            get => GetValue(MinHueProperty);
+            set => SetValue(MinHueProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="MinHue"/> property.
+        /// </summary>
+        public static readonly StyledProperty<int> MinHueProperty =
+            AvaloniaProperty.Register<ColorSpectrum, int>(nameof(MinHue), 0);
+
+        /// <summary>
+        /// Gets or sets the minimum value of the Saturation component in the range from 0..100.
+        /// This property must be less than <see cref="MaxSaturation"/>.
+        /// </summary>
+        /// <remarks>
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model.
+        /// </remarks>
+        public int MinSaturation
+        {
+            get => GetValue(MinSaturationProperty);
+            set => SetValue(MinSaturationProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="MinSaturation"/> property.
+        /// </summary>
+        public static readonly StyledProperty<int> MinSaturationProperty =
+            AvaloniaProperty.Register<ColorSpectrum, int>(nameof(MinSaturation), 0);
+
+        /// <summary>
+        /// Gets or sets the minimum value of the Value component in the range from 0..100.
+        /// This property must be less than <see cref="MaxValue"/>.
+        /// </summary>
+        /// <remarks>
+        /// Internally, the <see cref="ColorSpectrum"/> uses the HSV color model.
+        /// </remarks>
+        public int MinValue
+        {
+            get => GetValue(MinValueProperty);
+            set => SetValue(MinValueProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="MinValue"/> property.
+        /// </summary>
+        public static readonly StyledProperty<int> MinValueProperty =
+            AvaloniaProperty.Register<ColorSpectrum, int>(nameof(MinValue), 0);
+
+        /// <summary>
+        /// Gets or sets the displayed shape of the spectrum.
+        /// </summary>
+        public ColorSpectrumShape Shape
+        {
+            get => GetValue(ShapeProperty);
+            set => SetValue(ShapeProperty, value);
+        }
+
+        /// <summary>
+        /// Defines the <see cref="Shape"/> property.
+        /// </summary>
+        public static readonly StyledProperty<ColorSpectrumShape> ShapeProperty =
+            AvaloniaProperty.Register<ColorSpectrum, ColorSpectrumShape>(
+                nameof(Shape),
+                ColorSpectrumShape.Box);
+    }
+}

+ 1578 - 0
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
+{
+    /// <summary>
+    /// A two dimensional spectrum for color selection.
+    /// </summary>
+    [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";
+
+        /// <summary>
+        /// Event for when the selected color changes within the spectrum.
+        /// </summary>
+        public event EventHandler<ColorChangedEventArgs>? ColorChanged;
+
+        private bool _updatingColor = false;
+        private bool _updatingHsvColor = false;
+        private bool _isPointerOver = false;
+        private bool _isPointerPressed = false;
+        private bool _shouldShowLargeSelection = false;
+        private List<Hsv> _hsvValues = new List<Hsv>();
+
+        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);
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ColorSpectrum"/> class.
+        /// </summary>
+        public ColorSpectrum()
+        {
+            _shapeFromLastBitmapCreation = Shape;
+            _componentsFromLastBitmapCreation = Components;
+            _imageWidthFromLastBitmapCreation = 0;
+            _imageHeightFromLastBitmapCreation = 0;
+            _minHueFromLastBitmapCreation = MinHue;
+            _maxHueFromLastBitmapCreation = MaxHue;
+            _minSaturationFromLastBitmapCreation = MinSaturation;
+            _maxSaturationFromLastBitmapCreation = MaxSaturation;
+            _minValueFromLastBitmapCreation = MinValue;
+            _maxValueFromLastBitmapCreation = MaxValue;
+        }
+
+        /// <inheritdoc/>
+        protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+        {
+            base.OnApplyTemplate(e);
+
+            UnregisterEvents(); // Failsafe
+
+            _colorNameToolTip = e.NameScope.Find<ToolTip>("PART_ColorNameToolTip");
+            _inputTarget = e.NameScope.Find<Canvas>("PART_InputTarget");
+            _layoutRoot = e.NameScope.Find<Panel>("PART_LayoutRoot");
+            _selectionEllipsePanel = e.NameScope.Find<Panel>("PART_SelectionEllipsePanel");
+            _sizingPanel = e.NameScope.Find<Panel>("PART_SizingPanel");
+            _spectrumEllipse = e.NameScope.Find<Ellipse>("PART_SpectrumEllipse");
+            _spectrumRectangle = e.NameScope.Find<Rectangle>("PART_SpectrumRectangle");
+            _spectrumOverlayEllipse = e.NameScope.Find<Ellipse>("PART_SpectrumOverlayEllipse");
+            _spectrumOverlayRectangle = e.NameScope.Find<Rectangle>("PART_SpectrumOverlayRectangle");
+
+            if (_inputTarget != null)
+            {
+                _inputTarget.PointerEnter += InputTarget_PointerEnter;
+                _inputTarget.PointerLeave += InputTarget_PointerLeave;
+                _inputTarget.PointerPressed += InputTarget_PointerPressed;
+                _inputTarget.PointerMoved += InputTarget_PointerMoved;
+                _inputTarget.PointerReleased += InputTarget_PointerReleased;
+            }
+
+            if (_layoutRoot != null)
+            {
+                _layoutRootDisposable = _layoutRoot.GetObservable(BoundsProperty).Subscribe(_ => 
+                {
+                    CreateBitmapsAndColorMap();
+                });
+            }
+
+            if (_selectionEllipsePanel != null)
+            {
+                _selectionEllipsePanelDisposable = _selectionEllipsePanel.GetObservable(FlowDirectionProperty).Subscribe(_ => 
+                {
+                    UpdateEllipse();
+                });
+            }
+
+            if (ColorHelpers.ToDisplayNameExists &&
+                _colorNameToolTip != null)
+            {
+                _colorNameToolTip.Content = ColorHelpers.ToDisplayName(Color);
+            }
+
+            // If we haven't yet created our bitmaps, do so now.
+            if (_hsvValues.Count == 0)
+            {
+                CreateBitmapsAndColorMap();
+            }
+
+            UpdateEllipse();
+            UpdatePseudoClasses();
+        }
+
+        /// <inheritdoc/>
+        protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+        {
+            base.OnAttachedToVisualTree(e);
+
+            // OnAttachedToVisualTree is called after OnApplyTemplate so events cannot be connected here
+        }
+
+        /// <inheritdoc/>
+        protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+        {
+            base.OnDetachedFromVisualTree(e);
+        }
+
+        /// <summary>
+        /// Explicitly unregisters all events connected in OnApplyTemplate().
+        /// </summary>
+        private void UnregisterEvents()
+        {
+            _layoutRootDisposable?.Dispose();
+            _layoutRootDisposable = null;
+
+            _selectionEllipsePanelDisposable?.Dispose();
+            _selectionEllipsePanelDisposable = null;
+
+            if (_inputTarget != null)
+            {
+                _inputTarget.PointerEnter -= InputTarget_PointerEnter;
+                _inputTarget.PointerLeave -= InputTarget_PointerLeave;
+                _inputTarget.PointerPressed -= InputTarget_PointerPressed;
+                _inputTarget.PointerMoved -= InputTarget_PointerMoved;
+                _inputTarget.PointerReleased -= InputTarget_PointerReleased;
+            }
+        }
+
+        /// <inheritdoc/>
+        protected override void OnKeyDown(KeyEventArgs e)
+        {
+            var key = e.Key;
+
+            if (key != Key.Left &&
+                key != Key.Right &&
+                key != Key.Up &&
+                key != Key.Down)
+            {
+                base.OnKeyDown(e);
+                return;
+            }
+
+            bool isControlDown = e.KeyModifiers.HasFlag(KeyModifiers.Control);
+
+            HsvComponent incrementComponent = HsvComponent.Hue;
+
+            bool isSaturationValue = false;
+
+            if (key == Key.Left ||
+                key == Key.Right)
+            {
+                switch (Components)
+                {
+                    case ColorSpectrumComponents.HueSaturation:
+                    case ColorSpectrumComponents.HueValue:
+                        incrementComponent = HsvComponent.Hue;
+                        break;
+
+                    case ColorSpectrumComponents.SaturationValue:
+                        isSaturationValue = true;
+                        goto case ColorSpectrumComponents.SaturationHue;
+                    case ColorSpectrumComponents.SaturationHue:
+                        incrementComponent = HsvComponent.Saturation;
+                        break;
+
+                    case ColorSpectrumComponents.ValueHue:
+                    case ColorSpectrumComponents.ValueSaturation:
+                        incrementComponent = HsvComponent.Value;
+                        break;
+                }
+            }
+            else if (key == Key.Up ||
+                     key == Key.Down)
+            {
+                switch (Components)
+                {
+                    case ColorSpectrumComponents.SaturationHue:
+                    case ColorSpectrumComponents.ValueHue:
+                        incrementComponent = HsvComponent.Hue;
+                        break;
+
+                    case ColorSpectrumComponents.HueSaturation:
+                    case ColorSpectrumComponents.ValueSaturation:
+                        incrementComponent = HsvComponent.Saturation;
+                        break;
+
+                    case ColorSpectrumComponents.SaturationValue:
+                        isSaturationValue = true;
+                        goto case ColorSpectrumComponents.HueValue;
+                    case ColorSpectrumComponents.HueValue:
+                        incrementComponent = HsvComponent.Value;
+                        break;
+                }
+            }
+
+            double minBound = 0.0;
+            double maxBound = 0.0;
+
+            switch (incrementComponent)
+            {
+                case HsvComponent.Hue:
+                    minBound = MinHue;
+                    maxBound = MaxHue;
+                    break;
+
+                case HsvComponent.Saturation:
+                    minBound = MinSaturation;
+                    maxBound = MaxSaturation;
+                    break;
+
+                case HsvComponent.Value:
+                    minBound = MinValue;
+                    maxBound = MaxValue;
+                    break;
+            }
+
+            // The order of saturation and value in the spectrum is reversed - the max value is at the bottom while the min value is at the top -
+            // so we want left and up to be lower for hue, but higher for saturation and value.
+            // This will ensure that the icon always moves in the direction of the key press.
+            IncrementDirection direction =
+                (incrementComponent == HsvComponent.Hue && (key == Key.Left || key == Key.Up)) ||
+                (incrementComponent != HsvComponent.Hue && (key == Key.Right || key == Key.Down)) ?
+                IncrementDirection.Lower :
+                IncrementDirection.Higher;
+
+            // Image is flipped in RightToLeft, so we need to invert direction in that case.
+            // The combination saturation and value is also flipped, so we need to invert in that case too.
+            // If both are false, we don't need to invert.
+            // If both are true, we would invert twice, so not invert at all.
+            if ((FlowDirection == FlowDirection.RightToLeft) != isSaturationValue &&
+                (key == Key.Left || key == Key.Right))
+            {
+                if (direction == IncrementDirection.Higher)
+                {
+                    direction = IncrementDirection.Lower;
+                }
+                else
+                {
+                    direction = IncrementDirection.Higher;
+                }
+            }
+
+            IncrementAmount amount = isControlDown ? IncrementAmount.Large : IncrementAmount.Small;
+
+            HsvColor hsvColor = HsvColor;
+            UpdateColor(ColorHelpers.IncrementColorComponent(
+                new Hsv(hsvColor),
+                incrementComponent,
+                direction,
+                amount,
+                shouldWrap: true,
+                minBound,
+                maxBound));
+
+            e.Handled = true;
+
+            return;
+        }
+
+        /// <inheritdoc/>
+        protected override void OnGotFocus(GotFocusEventArgs e)
+        {
+            // We only want to bother with the color name tool tip if we can provide color names.
+            if (_colorNameToolTip != null &&
+                ColorHelpers.ToDisplayNameExists)
+            {
+                ToolTip.SetIsOpen(_colorNameToolTip, true);
+            }
+
+            UpdatePseudoClasses();
+        }
+
+        /// <inheritdoc/>
+        protected override void OnLostFocus(RoutedEventArgs e)
+        {
+            // We only want to bother with the color name tool tip if we can provide color names.
+            if (_colorNameToolTip != null &&
+                ColorHelpers.ToDisplayNameExists)
+            {
+                ToolTip.SetIsOpen(_colorNameToolTip, false);
+            }
+
+            UpdatePseudoClasses();
+        }
+
+        /// <inheritdoc/>
+        protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+        {
+            if (change.Property == ColorProperty)
+            {
+                // If we're in the process of internally updating the color,
+                // then we don't want to respond to the Color property changing.
+                if (!_updatingColor)
+                {
+                    Color color = Color;
+
+                    _updatingHsvColor = true;
+                    Hsv newHsv = (new Rgb(color)).ToHsv();
+                    HsvColor = newHsv.ToHsvColor(color.A / 255.0);
+                    _updatingHsvColor = false;
+
+                    UpdateEllipse();
+                    UpdateBitmapSources();
+                }
+
+                _oldColor = change.GetOldValue<Color>();
+            }
+            else if (change.Property == HsvColorProperty)
+            {
+                // If we're in the process of internally updating the HSV color,
+                // then we don't want to respond to the HsvColor property changing.
+                if (!_updatingHsvColor)
+                {
+                    SetColor();
+                }
+
+                _oldHsvColor = change.GetOldValue<HsvColor>();
+            }
+            else if (change.Property == MinHueProperty ||
+                     change.Property == MaxHueProperty)
+            {
+                int minHue = MinHue;
+                int maxHue = MaxHue;
+
+                if (minHue < 0 || minHue > 359)
+                {
+                    throw new ArgumentException("MinHue must be between 0 and 359.");
+                }
+                else if (maxHue < 0 || maxHue > 359)
+                {
+                    throw new ArgumentException("MaxHue must be between 0 and 359.");
+                }
+
+                ColorSpectrumComponents components = Components;
+
+                // If hue is one of the axes in the spectrum bitmap, then we'll need to regenerate it
+                // if the maximum or minimum value has changed.
+                if (components != ColorSpectrumComponents.SaturationValue &&
+                    components != ColorSpectrumComponents.ValueSaturation)
+                {
+                    CreateBitmapsAndColorMap();
+                }
+            }
+            else if (change.Property == MinSaturationProperty ||
+                     change.Property == MaxSaturationProperty)
+            {
+                int minSaturation = MinSaturation;
+                int maxSaturation = MaxSaturation;
+
+                if (minSaturation < 0 || minSaturation > 100)
+                {
+                    throw new ArgumentException("MinSaturation must be between 0 and 100.");
+                }
+                else if (maxSaturation < 0 || maxSaturation > 100)
+                {
+                    throw new ArgumentException("MaxSaturation must be between 0 and 100.");
+                }
+
+                ColorSpectrumComponents components = Components;
+
+                // If value is one of the axes in the spectrum bitmap, then we'll need to regenerate it
+                // if the maximum or minimum value has changed.
+                if (components != ColorSpectrumComponents.HueValue &&
+                    components != ColorSpectrumComponents.ValueHue)
+                {
+                    CreateBitmapsAndColorMap();
+                }
+            }
+            else if (change.Property == MinValueProperty ||
+                     change.Property == MaxValueProperty)
+            {
+                int minValue = MinValue;
+                int maxValue = MaxValue;
+
+                if (minValue < 0 || minValue > 100)
+                {
+                    throw new ArgumentException("MinValue must be between 0 and 100.");
+                }
+                else if (maxValue < 0 || maxValue > 100)
+                {
+                    throw new ArgumentException("MaxValue must be between 0 and 100.");
+                }
+
+                ColorSpectrumComponents components = Components;
+
+                // If value is one of the axes in the spectrum bitmap, then we'll need to regenerate it
+                // if the maximum or minimum value has changed.
+                if (components != ColorSpectrumComponents.HueSaturation &&
+                    components != ColorSpectrumComponents.SaturationHue)
+                {
+                    CreateBitmapsAndColorMap();
+                }
+            }
+            else if (change.Property == ShapeProperty)
+            {
+                CreateBitmapsAndColorMap();
+            }
+            else if (change.Property == ComponentsProperty)
+            {
+                CreateBitmapsAndColorMap();
+            }
+
+            base.OnPropertyChanged(change);
+        }
+
+        private void SetColor()
+        {
+            HsvColor hsvColor = HsvColor;
+
+            _updatingColor = true;
+            Rgb newRgb = (new Hsv(hsvColor)).ToRgb();
+
+            Color = newRgb.ToColor(hsvColor.A);
+
+            _updatingColor = false;
+
+            UpdateEllipse();
+            UpdateBitmapSources();
+            RaiseColorChanged();
+        }
+
+        public void RaiseColorChanged()
+        {
+            Color newColor = Color;
+
+            bool colorChanged =
+                _oldColor.A != newColor.A ||
+                _oldColor.R != newColor.R ||
+                _oldColor.G != newColor.G ||
+                _oldColor.B != newColor.B;
+
+            bool areBothColorsBlack =
+                (_oldColor.R == newColor.R && newColor.R == 0) ||
+                (_oldColor.G == newColor.G && newColor.G == 0) ||
+                (_oldColor.B == newColor.B && newColor.B == 0);
+
+            if (colorChanged || areBothColorsBlack)
+            {
+                var colorChangedEventArgs = new ColorChangedEventArgs(_oldColor, newColor);
+                ColorChanged?.Invoke(this, colorChangedEventArgs);
+
+                if (ColorHelpers.ToDisplayNameExists)
+                {
+                    if (_colorNameToolTip != null)
+                    {
+                        _colorNameToolTip.Content = ColorHelpers.ToDisplayName(newColor);
+                    }
+                }
+            }
+        }
+
+        /// <summary>
+        /// Updates the visual state of the control by applying latest PseudoClasses.
+        /// </summary>
+        private void UpdatePseudoClasses()
+        {
+            PseudoClasses.Set(pcPressed, _isPointerPressed);
+            // Note: The ":pointerover" pseudo class is set in the base Control
+
+            if (_isPointerPressed)
+            {
+                PseudoClasses.Set(pcLargeSelector, _shouldShowLargeSelection);
+            }
+            else
+            {
+                PseudoClasses.Set(pcLargeSelector, false);
+            }
+
+            PseudoClasses.Set(pcLightSelector, SelectionEllipseShouldBeLight());
+        }
+
+        private void UpdateColor(Hsv newHsv)
+        {
+            _updatingColor = true;
+            _updatingHsvColor = true;
+
+            Rgb newRgb = newHsv.ToRgb();
+            double alpha = HsvColor.A;
+
+            Color = newRgb.ToColor(alpha);
+            HsvColor = newHsv.ToHsvColor(alpha);
+
+            UpdateEllipse();
+            UpdatePseudoClasses();
+
+            _updatingHsvColor = false;
+            _updatingColor = false;
+
+            RaiseColorChanged();
+        }
+
+        private void UpdateColorFromPoint(PointerPoint point)
+        {
+            // If we haven't initialized our HSV value array yet, then we should just ignore any user input -
+            // we don't yet know what to do with it.
+            if (_hsvValues.Count == 0)
+            {
+                return;
+            }
+
+            double xPosition = point.Position.X;
+            double yPosition = point.Position.Y;
+            double radius = Math.Min(_imageWidthFromLastBitmapCreation, _imageHeightFromLastBitmapCreation) / 2;
+            double distanceFromRadius = Math.Sqrt(Math.Pow(xPosition - radius, 2) + Math.Pow(yPosition - radius, 2));
+
+            var shape = Shape;
+
+            // If the point is outside the circle, we should bring it back into the circle.
+            if (distanceFromRadius > radius && shape == ColorSpectrumShape.Ring)
+            {
+                xPosition = (radius / distanceFromRadius) * (xPosition - radius) + radius;
+                yPosition = (radius / distanceFromRadius) * (yPosition - radius) + radius;
+            }
+
+            // Now we need to find the index into the array of HSL values at each point in the spectrum m_image.
+            int x = (int)Math.Round(xPosition);
+            int y = (int)Math.Round(yPosition);
+            int width = (int)Math.Round(_imageWidthFromLastBitmapCreation);
+
+            if (x < 0)
+            {
+                x = 0;
+            }
+            else if (x >= _imageWidthFromLastBitmapCreation)
+            {
+                x = (int)Math.Round(_imageWidthFromLastBitmapCreation) - 1;
+            }
+
+            if (y < 0)
+            {
+                y = 0;
+            }
+            else if (y >= _imageHeightFromLastBitmapCreation)
+            {
+                y = (int)Math.Round(_imageHeightFromLastBitmapCreation) - 1;
+            }
+
+            // The gradient image contains two dimensions of HSL information, but not the third.
+            // We should keep the third where it already was.
+            // Note: This can sometimes cause a crash -- possibly due to differences in c# rounding. Therefore, index is now clamped.
+            Hsv hsvAtPoint = _hsvValues[MathUtilities.Clamp((y * width + x), 0, _hsvValues.Count - 1)];
+
+            var hsvColor = HsvColor;
+
+            switch (Components)
+            {
+                case ColorSpectrumComponents.HueValue:
+                case ColorSpectrumComponents.ValueHue:
+                    hsvAtPoint.S = hsvColor.S;
+                    break;
+
+                case ColorSpectrumComponents.HueSaturation:
+                case ColorSpectrumComponents.SaturationHue:
+                    hsvAtPoint.V = hsvColor.V;
+                    break;
+
+                case ColorSpectrumComponents.ValueSaturation:
+                case ColorSpectrumComponents.SaturationValue:
+                    hsvAtPoint.H = hsvColor.H;
+                    break;
+            }
+
+            UpdateColor(hsvAtPoint);
+        }
+
+        private void UpdateEllipse()
+        {
+            if (_selectionEllipsePanel == null)
+            {
+                return;
+            }
+
+            // If we don't have an image size yet, we shouldn't be showing the ellipse.
+            if (_imageWidthFromLastBitmapCreation == 0 ||
+                _imageHeightFromLastBitmapCreation == 0)
+            {
+                _selectionEllipsePanel.IsVisible = false;
+                return;
+            }
+            else
+            {
+                _selectionEllipsePanel.IsVisible = true;
+            }
+
+            double xPosition;
+            double yPosition;
+
+            Hsv hsvColor = new Hsv(HsvColor);
+
+            hsvColor.H = MathUtilities.Clamp(hsvColor.H, (double)_minHueFromLastBitmapCreation, (double)_maxHueFromLastBitmapCreation);
+            hsvColor.S = MathUtilities.Clamp(hsvColor.S, _minSaturationFromLastBitmapCreation / 100.0, _maxSaturationFromLastBitmapCreation / 100.0);
+            hsvColor.V = MathUtilities.Clamp(hsvColor.V, _minValueFromLastBitmapCreation / 100.0, _maxValueFromLastBitmapCreation / 100.0);
+
+            if (_shapeFromLastBitmapCreation == ColorSpectrumShape.Box)
+            {
+                double xPercent = 0;
+                double yPercent = 0;
+
+                double hPercent = (hsvColor.H - _minHueFromLastBitmapCreation) / (_maxHueFromLastBitmapCreation - _minHueFromLastBitmapCreation);
+                double sPercent = (hsvColor.S * 100.0 - _minSaturationFromLastBitmapCreation) / (_maxSaturationFromLastBitmapCreation - _minSaturationFromLastBitmapCreation);
+                double vPercent = (hsvColor.V * 100.0 - _minValueFromLastBitmapCreation) / (_maxValueFromLastBitmapCreation - _minValueFromLastBitmapCreation);
+
+                // In the case where saturation was an axis in the spectrum with hue, or value is an axis, full stop,
+                // we inverted the direction of that axis in order to put more hue on the outside of the ring,
+                // so we need to do similarly here when positioning the ellipse.
+                if (_componentsFromLastBitmapCreation == ColorSpectrumComponents.HueSaturation ||
+                    _componentsFromLastBitmapCreation == ColorSpectrumComponents.SaturationHue)
+                {
+                    sPercent = 1 - sPercent;
+                }
+                else
+                {
+                    vPercent = 1 - vPercent;
+                }
+
+                switch (_componentsFromLastBitmapCreation)
+                {
+                    case ColorSpectrumComponents.HueValue:
+                        xPercent = hPercent;
+                        yPercent = vPercent;
+                        break;
+
+                    case ColorSpectrumComponents.HueSaturation:
+                        xPercent = hPercent;
+                        yPercent = sPercent;
+                        break;
+
+                    case ColorSpectrumComponents.ValueHue:
+                        xPercent = vPercent;
+                        yPercent = hPercent;
+                        break;
+
+                    case ColorSpectrumComponents.ValueSaturation:
+                        xPercent = vPercent;
+                        yPercent = sPercent;
+                        break;
+
+                    case ColorSpectrumComponents.SaturationHue:
+                        xPercent = sPercent;
+                        yPercent = hPercent;
+                        break;
+
+                    case ColorSpectrumComponents.SaturationValue:
+                        xPercent = sPercent;
+                        yPercent = vPercent;
+                        break;
+                }
+
+                xPosition = _imageWidthFromLastBitmapCreation * xPercent;
+                yPosition = _imageHeightFromLastBitmapCreation * yPercent;
+            }
+            else
+            {
+                double thetaValue = 0;
+                double rValue = 0;
+
+                double hThetaValue =
+                    _maxHueFromLastBitmapCreation != _minHueFromLastBitmapCreation ?
+                    360 * (hsvColor.H - _minHueFromLastBitmapCreation) / (_maxHueFromLastBitmapCreation - _minHueFromLastBitmapCreation) :
+                    0;
+                double sThetaValue =
+                    _maxSaturationFromLastBitmapCreation != _minSaturationFromLastBitmapCreation ?
+                    360 * (hsvColor.S * 100.0 - _minSaturationFromLastBitmapCreation) / (_maxSaturationFromLastBitmapCreation - _minSaturationFromLastBitmapCreation) :
+                    0;
+                double vThetaValue =
+                    _maxValueFromLastBitmapCreation != _minValueFromLastBitmapCreation ?
+                    360 * (hsvColor.V * 100.0 - _minValueFromLastBitmapCreation) / (_maxValueFromLastBitmapCreation - _minValueFromLastBitmapCreation) :
+                    0;
+                double hRValue = _maxHueFromLastBitmapCreation != _minHueFromLastBitmapCreation ?
+                    (hsvColor.H - _minHueFromLastBitmapCreation) / (_maxHueFromLastBitmapCreation - _minHueFromLastBitmapCreation) - 1 :
+                    0;
+                double sRValue = _maxSaturationFromLastBitmapCreation != _minSaturationFromLastBitmapCreation ?
+                    (hsvColor.S * 100.0 - _minSaturationFromLastBitmapCreation) / (_maxSaturationFromLastBitmapCreation - _minSaturationFromLastBitmapCreation) - 1 :
+                    0;
+                double vRValue = _maxValueFromLastBitmapCreation != _minValueFromLastBitmapCreation ?
+                    (hsvColor.V * 100.0 - _minValueFromLastBitmapCreation) / (_maxValueFromLastBitmapCreation - _minValueFromLastBitmapCreation) - 1 :
+                    0;
+
+                // In the case where saturation was an axis in the spectrum with hue, or value is an axis, full stop,
+                // we inverted the direction of that axis in order to put more hue on the outside of the ring,
+                // so we need to do similarly here when positioning the ellipse.
+                if (_componentsFromLastBitmapCreation == ColorSpectrumComponents.HueSaturation ||
+                    _componentsFromLastBitmapCreation == ColorSpectrumComponents.SaturationHue)
+                {
+                    sThetaValue = 360 - sThetaValue;
+                    sRValue = -sRValue - 1;
+                }
+                else
+                {
+                    vThetaValue = 360 - vThetaValue;
+                    vRValue = -vRValue - 1;
+                }
+
+                switch (_componentsFromLastBitmapCreation)
+                {
+                    case ColorSpectrumComponents.HueValue:
+                        thetaValue = hThetaValue;
+                        rValue = vRValue;
+                        break;
+
+                    case ColorSpectrumComponents.HueSaturation:
+                        thetaValue = hThetaValue;
+                        rValue = sRValue;
+                        break;
+
+                    case ColorSpectrumComponents.ValueHue:
+                        thetaValue = vThetaValue;
+                        rValue = hRValue;
+                        break;
+
+                    case ColorSpectrumComponents.ValueSaturation:
+                        thetaValue = vThetaValue;
+                        rValue = sRValue;
+                        break;
+
+                    case ColorSpectrumComponents.SaturationHue:
+                        thetaValue = sThetaValue;
+                        rValue = hRValue;
+                        break;
+
+                    case ColorSpectrumComponents.SaturationValue:
+                        thetaValue = sThetaValue;
+                        rValue = vRValue;
+                        break;
+                }
+
+                double radius = Math.Min(_imageWidthFromLastBitmapCreation, _imageHeightFromLastBitmapCreation) / 2;
+
+                xPosition = (Math.Cos((thetaValue * Math.PI / 180.0) + Math.PI) * radius * rValue) + radius;
+                yPosition = (Math.Sin((thetaValue * Math.PI / 180.0) + Math.PI) * radius * rValue) + radius;
+            }
+
+            Canvas.SetLeft(_selectionEllipsePanel, xPosition - (_selectionEllipsePanel.Width / 2));
+            Canvas.SetTop(_selectionEllipsePanel, yPosition - (_selectionEllipsePanel.Height / 2));
+
+            // We only want to bother with the color name tool tip if we can provide color names.
+            if (ColorHelpers.ToDisplayNameExists)
+            {
+                if (_colorNameToolTip != null)
+                {
+                    // ToolTip doesn't currently provide any way to re-run its placement logic if its placement target moves,
+                    // so toggling IsEnabled induces it to do that without incurring any visual glitches.
+                    _colorNameToolTip.IsEnabled = false;
+                    _colorNameToolTip.IsEnabled = true;
+                }
+            }
+
+            UpdatePseudoClasses();
+        }
+
+        /// <inheritdoc cref="InputElement.PointerEnter"/>
+        private void InputTarget_PointerEnter(object? sender, PointerEventArgs args)
+        {
+            _isPointerOver = true;
+            UpdatePseudoClasses();
+            args.Handled = true;
+        }
+
+        /// <inheritdoc cref="InputElement.PointerLeave"/>
+        private void InputTarget_PointerLeave(object? sender, PointerEventArgs args)
+        {
+            _isPointerOver = false;
+            UpdatePseudoClasses();
+            args.Handled = true;
+        }
+
+        /// <inheritdoc cref="InputElement.PointerPressed"/>
+        private void InputTarget_PointerPressed(object? sender, PointerPressedEventArgs args)
+        {
+            var inputTarget = _inputTarget;
+
+            Focus();
+
+            _isPointerPressed = true;
+            _shouldShowLargeSelection =
+                // TODO: After Pen PR is merged: https://github.com/AvaloniaUI/Avalonia/pull/7412
+                // args.Pointer.Type == PointerType.Pen ||
+                args.Pointer.Type == PointerType.Touch;
+
+            args.Pointer.Capture(inputTarget);
+            UpdateColorFromPoint(args.GetCurrentPoint(inputTarget));
+            UpdatePseudoClasses();
+            UpdateEllipse();
+
+            args.Handled = true;
+        }
+
+        /// <inheritdoc cref="InputElement.PointerMoved"/>
+        private void InputTarget_PointerMoved(object? sender, PointerEventArgs args)
+        {
+            if (!_isPointerPressed)
+            {
+                return;
+            }
+
+            UpdateColorFromPoint(args.GetCurrentPoint(_inputTarget));
+            args.Handled = true;
+        }
+
+        /// <inheritdoc cref="InputElement.PointerReleased"/>
+        private void InputTarget_PointerReleased(object? sender, PointerReleasedEventArgs args)
+        {
+            _isPointerPressed = false;
+            _shouldShowLargeSelection = false;
+
+            args.Pointer.Capture(null);
+            UpdatePseudoClasses();
+            UpdateEllipse();
+
+            args.Handled = true;
+        }
+
+        private async void CreateBitmapsAndColorMap()
+        {
+            if (_layoutRoot == null ||
+                _sizingPanel == null ||
+                _inputTarget == null ||
+                _spectrumRectangle == null ||
+                _spectrumEllipse == null ||
+                _spectrumOverlayRectangle == null ||
+                _spectrumOverlayEllipse == null
+                /*|| SharedHelpers.IsInDesignMode*/)
+            {
+                return;
+            }
+
+            // We want ColorSpectrum to always be a square, so we'll take the smaller of the dimensions
+            // and size the sizing panel to that.
+            double minDimension = Math.Min(_layoutRoot.Bounds.Width, _layoutRoot.Bounds.Height);
+
+            if (minDimension == 0)
+            {
+                return;
+            }
+
+            _sizingPanel.Width = minDimension;
+            _sizingPanel.Height = minDimension;
+            _inputTarget.Width = minDimension;
+            _inputTarget.Height = minDimension;
+            _spectrumRectangle.Width = minDimension;
+            _spectrumRectangle.Height = minDimension;
+            _spectrumEllipse.Width = minDimension;
+            _spectrumEllipse.Height = minDimension;
+            _spectrumOverlayRectangle.Width = minDimension;
+            _spectrumOverlayRectangle.Height = minDimension;
+            _spectrumOverlayEllipse.Width = minDimension;
+            _spectrumOverlayEllipse.Height = minDimension;
+
+            HsvColor hsvColor = HsvColor;
+            int minHue = MinHue;
+            int maxHue = MaxHue;
+            int minSaturation = MinSaturation;
+            int maxSaturation = MaxSaturation;
+            int minValue = MinValue;
+            int maxValue = MaxValue;
+            ColorSpectrumShape shape = Shape;
+            ColorSpectrumComponents components = Components;
+
+            // If min >= max, then by convention, min is the only number that a property can have.
+            if (minHue >= maxHue)
+            {
+                maxHue = minHue;
+            }
+
+            if (minSaturation >= maxSaturation)
+            {
+                maxSaturation = minSaturation;
+            }
+
+            if (minValue >= maxValue)
+            {
+                maxValue = minValue;
+            }
+
+            Hsv hsv = new Hsv(hsvColor);
+
+            // The middle 4 are only needed and used in the case of hue as the third dimension.
+            // Saturation and luminosity need only a min and max.
+            List<byte> bgraMinPixelData = new List<byte>();
+            List<byte> bgraMiddle1PixelData = new List<byte>();
+            List<byte> bgraMiddle2PixelData = new List<byte>();
+            List<byte> bgraMiddle3PixelData = new List<byte>();
+            List<byte> bgraMiddle4PixelData = new List<byte>();
+            List<byte> bgraMaxPixelData = new List<byte>();
+            List<Hsv> newHsvValues = new List<Hsv>();
+
+            var pixelCount = (int)(Math.Round(minDimension) * Math.Round(minDimension));
+            var pixelDataSize = pixelCount * 4;
+            bgraMinPixelData.Capacity = pixelDataSize;
+
+            // We'll only save pixel data for the middle bitmaps if our third dimension is hue.
+            if (components == ColorSpectrumComponents.ValueSaturation ||
+                components == ColorSpectrumComponents.SaturationValue)
+            {
+                bgraMiddle1PixelData.Capacity = pixelDataSize;
+                bgraMiddle2PixelData.Capacity = pixelDataSize;
+                bgraMiddle3PixelData.Capacity = pixelDataSize;
+                bgraMiddle4PixelData.Capacity = pixelDataSize;
+            }
+
+            bgraMaxPixelData.Capacity = pixelDataSize;
+            newHsvValues.Capacity = pixelCount;
+
+            int minDimensionInt = (int)Math.Round(minDimension);
+
+            await Task.Run(() =>
+            {
+                // As the user perceives it, every time the third dimension not represented in the ColorSpectrum changes,
+                // the ColorSpectrum will visually change to accommodate that value.  For example, if the ColorSpectrum handles hue and luminosity,
+                // and the saturation externally goes from 1.0 to 0.5, then the ColorSpectrum will visually change to look more washed out
+                // to represent that third dimension's new value.
+                // Internally, however, we don't want to regenerate the ColorSpectrum bitmap every single time this happens, since that's very expensive.
+                // In order to make it so that we don't have to, we implement an optimization where, rather than having only one bitmap,
+                // we instead have multiple that we blend together using opacity to create the effect that we want.
+                // In the case where the third dimension is saturation or luminosity, we only need two: one bitmap at the minimum value
+                // of the third dimension, and one bitmap at the maximum.  Then we set the second's opacity at whatever the value of
+                // the third dimension is - e.g., a saturation of 0.5 implies an opacity of 50%.
+                // In the case where the third dimension is hue, we need six: one bitmap corresponding to red, yellow, green, cyan, blue, and purple.
+                // We'll then blend between whichever colors our hue exists between - e.g., an orange color would use red and yellow with an opacity of 50%.
+                // This optimization does incur slightly more startup time initially since we have to generate multiple bitmaps at once instead of only one,
+                // but the running time savings after that are *huge* when we can just set an opacity instead of generating a brand new bitmap.
+                if (shape == ColorSpectrumShape.Box)
+                {
+                    for (int x = minDimensionInt - 1; x >= 0; --x)
+                    {
+                        for (int y = minDimensionInt - 1; y >= 0; --y)
+                        {
+                            FillPixelForBox(
+                                x, y, hsv, minDimensionInt, components, minHue, maxHue, minSaturation, maxSaturation, minValue, maxValue,
+                                bgraMinPixelData, bgraMiddle1PixelData, bgraMiddle2PixelData, bgraMiddle3PixelData, bgraMiddle4PixelData, bgraMaxPixelData,
+                                newHsvValues);
+                        }
+                    }
+                }
+                else
+                {
+                    for (int y = 0; y < minDimensionInt; ++y)
+                    {
+                        for (int x = 0; x < minDimensionInt; ++x)
+                        {
+                            FillPixelForRing(
+                                x, y, minDimensionInt / 2.0, hsv, components, minHue, maxHue, minSaturation, maxSaturation, minValue, maxValue,
+                                bgraMinPixelData, bgraMiddle1PixelData, bgraMiddle2PixelData, bgraMiddle3PixelData, bgraMiddle4PixelData, bgraMaxPixelData,
+                                newHsvValues);
+                        }
+                    }
+                }
+            });
+
+            Dispatcher.UIThread.Post(() =>
+            {
+                int pixelWidth = (int)Math.Round(minDimension);
+                int pixelHeight = (int)Math.Round(minDimension);
+
+                ColorSpectrumComponents components2 = Components;
+
+                WriteableBitmap minBitmap = ColorHelpers.CreateBitmapFromPixelData(pixelWidth, pixelHeight, bgraMinPixelData);
+                WriteableBitmap maxBitmap = ColorHelpers.CreateBitmapFromPixelData(pixelWidth, pixelHeight, bgraMaxPixelData);
+
+                switch (components2)
+                {
+                    case ColorSpectrumComponents.HueValue:
+                    case ColorSpectrumComponents.ValueHue:
+                        _saturationMinimumBitmap = minBitmap;
+                        _saturationMaximumBitmap = maxBitmap;
+                        break;
+                    case ColorSpectrumComponents.HueSaturation:
+                    case ColorSpectrumComponents.SaturationHue:
+                        _valueBitmap = maxBitmap;
+                        break;
+                    case ColorSpectrumComponents.ValueSaturation:
+                    case ColorSpectrumComponents.SaturationValue:
+                        _hueRedBitmap = minBitmap;
+                        _hueYellowBitmap = ColorHelpers.CreateBitmapFromPixelData(pixelWidth, pixelHeight, bgraMiddle1PixelData);
+                        _hueGreenBitmap = ColorHelpers.CreateBitmapFromPixelData(pixelWidth, pixelHeight, bgraMiddle2PixelData);
+                        _hueCyanBitmap = ColorHelpers.CreateBitmapFromPixelData(pixelWidth, pixelHeight, bgraMiddle3PixelData);
+                        _hueBlueBitmap = ColorHelpers.CreateBitmapFromPixelData(pixelWidth, pixelHeight, bgraMiddle4PixelData);
+                        _huePurpleBitmap = maxBitmap;
+                        break;
+                }
+
+                _shapeFromLastBitmapCreation = Shape;
+                _componentsFromLastBitmapCreation = Components;
+                _imageWidthFromLastBitmapCreation = minDimension;
+                _imageHeightFromLastBitmapCreation = minDimension;
+                _minHueFromLastBitmapCreation = MinHue;
+                _maxHueFromLastBitmapCreation = MaxHue;
+                _minSaturationFromLastBitmapCreation = MinSaturation;
+                _maxSaturationFromLastBitmapCreation = MaxSaturation;
+                _minValueFromLastBitmapCreation = MinValue;
+                _maxValueFromLastBitmapCreation = MaxValue;
+
+                _hsvValues = newHsvValues;
+
+                UpdateBitmapSources();
+                UpdateEllipse();
+            });
+        }
+
+        private void FillPixelForBox(
+            double x,
+            double y,
+            Hsv baseHsv,
+            double minDimension,
+            ColorSpectrumComponents components,
+            double minHue,
+            double maxHue,
+            double minSaturation,
+            double maxSaturation,
+            double minValue,
+            double maxValue,
+            List<byte> bgraMinPixelData,
+            List<byte> bgraMiddle1PixelData,
+            List<byte> bgraMiddle2PixelData,
+            List<byte> bgraMiddle3PixelData,
+            List<byte> bgraMiddle4PixelData,
+            List<byte> bgraMaxPixelData,
+            List<Hsv> newHsvValues)
+        {
+            double hMin = minHue;
+            double hMax = maxHue;
+            double sMin = minSaturation / 100.0;
+            double sMax = maxSaturation / 100.0;
+            double vMin = minValue / 100.0;
+            double vMax = maxValue / 100.0;
+
+            Hsv hsvMin = baseHsv;
+            Hsv hsvMiddle1 = baseHsv;
+            Hsv hsvMiddle2 = baseHsv;
+            Hsv hsvMiddle3 = baseHsv;
+            Hsv hsvMiddle4 = baseHsv;
+            Hsv hsvMax = baseHsv;
+
+            double xPercent = (minDimension - 1 - x) / (minDimension - 1);
+            double yPercent = (minDimension - 1 - y) / (minDimension - 1);
+
+            switch (components)
+            {
+                case ColorSpectrumComponents.HueValue:
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + yPercent * (hMax - hMin);
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + xPercent * (vMax - vMin);
+                    hsvMin.S = 0;
+                    hsvMax.S = 1;
+                    break;
+
+                case ColorSpectrumComponents.HueSaturation:
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + yPercent * (hMax - hMin);
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + xPercent * (sMax - sMin);
+                    hsvMin.V = 0;
+                    hsvMax.V = 1;
+                    break;
+
+                case ColorSpectrumComponents.ValueHue:
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + yPercent * (vMax - vMin);
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + xPercent * (hMax - hMin);
+                    hsvMin.S = 0;
+                    hsvMax.S = 1;
+                    break;
+
+                case ColorSpectrumComponents.ValueSaturation:
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + yPercent * (vMax - vMin);
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + xPercent * (sMax - sMin);
+                    hsvMin.H = 0;
+                    hsvMiddle1.H = 60;
+                    hsvMiddle2.H = 120;
+                    hsvMiddle3.H = 180;
+                    hsvMiddle4.H = 240;
+                    hsvMax.H = 300;
+                    break;
+
+                case ColorSpectrumComponents.SaturationHue:
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + yPercent * (sMax - sMin);
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + xPercent * (hMax - hMin);
+                    hsvMin.V = 0;
+                    hsvMax.V = 1;
+                    break;
+
+                case ColorSpectrumComponents.SaturationValue:
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + yPercent * (sMax - sMin);
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + xPercent * (vMax - vMin);
+                    hsvMin.H = 0;
+                    hsvMiddle1.H = 60;
+                    hsvMiddle2.H = 120;
+                    hsvMiddle3.H = 180;
+                    hsvMiddle4.H = 240;
+                    hsvMax.H = 300;
+                    break;
+            }
+
+            // If saturation is an axis in the spectrum with hue, or value is an axis, then we want
+            // that axis to go from maximum at the top to minimum at the bottom,
+            // or maximum at the outside to minimum at the inside in the case of the ring configuration,
+            // so we'll invert the number before assigning the HSL value to the array.
+            // Otherwise, we'll have a very narrow section in the middle that actually has meaningful hue
+            // in the case of the ring configuration.
+            if (components == ColorSpectrumComponents.HueSaturation ||
+                components == ColorSpectrumComponents.SaturationHue)
+            {
+                hsvMin.S = sMax - hsvMin.S + sMin;
+                hsvMiddle1.S = sMax - hsvMiddle1.S + sMin;
+                hsvMiddle2.S = sMax - hsvMiddle2.S + sMin;
+                hsvMiddle3.S = sMax - hsvMiddle3.S + sMin;
+                hsvMiddle4.S = sMax - hsvMiddle4.S + sMin;
+                hsvMax.S = sMax - hsvMax.S + sMin;
+            }
+            else
+            {
+                hsvMin.V = vMax - hsvMin.V + vMin;
+                hsvMiddle1.V = vMax - hsvMiddle1.V + vMin;
+                hsvMiddle2.V = vMax - hsvMiddle2.V + vMin;
+                hsvMiddle3.V = vMax - hsvMiddle3.V + vMin;
+                hsvMiddle4.V = vMax - hsvMiddle4.V + vMin;
+                hsvMax.V = vMax - hsvMax.V + vMin;
+            }
+
+            newHsvValues.Add(hsvMin);
+
+            Rgb rgbMin = hsvMin.ToRgb();
+            bgraMinPixelData.Add((byte)Math.Round(rgbMin.B * 255.0)); // b
+            bgraMinPixelData.Add((byte)Math.Round(rgbMin.G * 255.0)); // g
+            bgraMinPixelData.Add((byte)Math.Round(rgbMin.R * 255.0)); // r
+            bgraMinPixelData.Add(255); // a - ignored
+
+            // We'll only save pixel data for the middle bitmaps if our third dimension is hue.
+            if (components == ColorSpectrumComponents.ValueSaturation ||
+                components == ColorSpectrumComponents.SaturationValue)
+            {
+                Rgb rgbMiddle1 = hsvMiddle1.ToRgb();
+                bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.B * 255.0)); // b
+                bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.G * 255.0)); // g
+                bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.R * 255.0)); // r
+                bgraMiddle1PixelData.Add(255); // a - ignored
+
+                Rgb rgbMiddle2 = hsvMiddle2.ToRgb();
+                bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.B * 255.0)); // b
+                bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.G * 255.0)); // g
+                bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.R * 255.0)); // r
+                bgraMiddle2PixelData.Add(255); // a - ignored
+
+                Rgb rgbMiddle3 = hsvMiddle3.ToRgb();
+                bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.B * 255.0)); // b
+                bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.G * 255.0)); // g
+                bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.R * 255.0)); // r
+                bgraMiddle3PixelData.Add(255); // a - ignored
+
+                Rgb rgbMiddle4 = hsvMiddle4.ToRgb();
+                bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.B * 255.0)); // b
+                bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.G * 255.0)); // g
+                bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.R * 255.0)); // r
+                bgraMiddle4PixelData.Add(255); // a - ignored
+            }
+
+            Rgb rgbMax = hsvMax.ToRgb();
+            bgraMaxPixelData.Add((byte)Math.Round(rgbMax.B * 255.0)); // b
+            bgraMaxPixelData.Add((byte)Math.Round(rgbMax.G * 255.0)); // g
+            bgraMaxPixelData.Add((byte)Math.Round(rgbMax.R * 255.0)); // r
+            bgraMaxPixelData.Add(255); // a - ignored
+        }
+
+        private void FillPixelForRing(
+            double x,
+            double y,
+            double radius,
+            Hsv baseHsv,
+            ColorSpectrumComponents components,
+            double minHue,
+            double maxHue,
+            double minSaturation,
+            double maxSaturation,
+            double minValue,
+            double maxValue,
+            List<byte> bgraMinPixelData,
+            List<byte> bgraMiddle1PixelData,
+            List<byte> bgraMiddle2PixelData,
+            List<byte> bgraMiddle3PixelData,
+            List<byte> bgraMiddle4PixelData,
+            List<byte> bgraMaxPixelData,
+            List<Hsv> newHsvValues)
+        {
+            double hMin = minHue;
+            double hMax = maxHue;
+            double sMin = minSaturation / 100.0;
+            double sMax = maxSaturation / 100.0;
+            double vMin = minValue / 100.0;
+            double vMax = maxValue / 100.0;
+
+            double distanceFromRadius = Math.Sqrt(Math.Pow(x - radius, 2) + Math.Pow(y - radius, 2));
+
+            double xToUse = x;
+            double yToUse = y;
+
+            // If we're outside the ring, then we want the pixel to appear as blank.
+            // However, to avoid issues with rounding errors, we'll act as though this point
+            // is on the edge of the ring for the purposes of returning an HSL value.
+            // That way, hit testing on the edges will always return the correct value.
+            if (distanceFromRadius > radius)
+            {
+                xToUse = (radius / distanceFromRadius) * (x - radius) + radius;
+                yToUse = (radius / distanceFromRadius) * (y - radius) + radius;
+                distanceFromRadius = radius;
+            }
+
+            Hsv hsvMin = baseHsv;
+            Hsv hsvMiddle1 = baseHsv;
+            Hsv hsvMiddle2 = baseHsv;
+            Hsv hsvMiddle3 = baseHsv;
+            Hsv hsvMiddle4 = baseHsv;
+            Hsv hsvMax = baseHsv;
+
+            double r = 1 - distanceFromRadius / radius;
+
+            double theta = Math.Atan2((radius - yToUse), (radius - xToUse)) * 180.0 / Math.PI;
+            theta += 180.0;
+            theta = Math.Floor(theta);
+
+            while (theta > 360)
+            {
+                theta -= 360;
+            }
+
+            double thetaPercent = theta / 360;
+
+            switch (components)
+            {
+                case ColorSpectrumComponents.HueValue:
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + thetaPercent * (hMax - hMin);
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + r * (vMax - vMin);
+                    hsvMin.S = 0;
+                    hsvMax.S = 1;
+                    break;
+
+                case ColorSpectrumComponents.HueSaturation:
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + thetaPercent * (hMax - hMin);
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + r * (sMax - sMin);
+                    hsvMin.V = 0;
+                    hsvMax.V = 1;
+                    break;
+
+                case ColorSpectrumComponents.ValueHue:
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + thetaPercent * (vMax - vMin);
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + r * (hMax - hMin);
+                    hsvMin.S = 0;
+                    hsvMax.S = 1;
+                    break;
+
+                case ColorSpectrumComponents.ValueSaturation:
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + thetaPercent * (vMax - vMin);
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + r * (sMax - sMin);
+                    hsvMin.H = 0;
+                    hsvMiddle1.H = 60;
+                    hsvMiddle2.H = 120;
+                    hsvMiddle3.H = 180;
+                    hsvMiddle4.H = 240;
+                    hsvMax.H = 300;
+                    break;
+
+                case ColorSpectrumComponents.SaturationHue:
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + thetaPercent * (sMax - sMin);
+                    hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + r * (hMax - hMin);
+                    hsvMin.V = 0;
+                    hsvMax.V = 1;
+                    break;
+
+                case ColorSpectrumComponents.SaturationValue:
+                    hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + thetaPercent * (sMax - sMin);
+                    hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + r * (vMax - vMin);
+                    hsvMin.H = 0;
+                    hsvMiddle1.H = 60;
+                    hsvMiddle2.H = 120;
+                    hsvMiddle3.H = 180;
+                    hsvMiddle4.H = 240;
+                    hsvMax.H = 300;
+                    break;
+            }
+
+            // If saturation is an axis in the spectrum with hue, or value is an axis, then we want
+            // that axis to go from maximum at the top to minimum at the bottom,
+            // or maximum at the outside to minimum at the inside in the case of the ring configuration,
+            // so we'll invert the number before assigning the HSL value to the array.
+            // Otherwise, we'll have a very narrow section in the middle that actually has meaningful hue
+            // in the case of the ring configuration.
+            if (components == ColorSpectrumComponents.HueSaturation ||
+                components == ColorSpectrumComponents.SaturationHue)
+            {
+                hsvMin.S = sMax - hsvMin.S + sMin;
+                hsvMiddle1.S = sMax - hsvMiddle1.S + sMin;
+                hsvMiddle2.S = sMax - hsvMiddle2.S + sMin;
+                hsvMiddle3.S = sMax - hsvMiddle3.S + sMin;
+                hsvMiddle4.S = sMax - hsvMiddle4.S + sMin;
+                hsvMax.S = sMax - hsvMax.S + sMin;
+            }
+            else
+            {
+                hsvMin.V = vMax - hsvMin.V + vMin;
+                hsvMiddle1.V = vMax - hsvMiddle1.V + vMin;
+                hsvMiddle2.V = vMax - hsvMiddle2.V + vMin;
+                hsvMiddle3.V = vMax - hsvMiddle3.V + vMin;
+                hsvMiddle4.V = vMax - hsvMiddle4.V + vMin;
+                hsvMax.V = vMax - hsvMax.V + vMin;
+            }
+
+            newHsvValues.Add(hsvMin);
+
+            Rgb rgbMin = hsvMin.ToRgb();
+            bgraMinPixelData.Add((byte)Math.Round(rgbMin.B * 255)); // b
+            bgraMinPixelData.Add((byte)Math.Round(rgbMin.G * 255)); // g
+            bgraMinPixelData.Add((byte)Math.Round(rgbMin.R * 255)); // r
+            bgraMinPixelData.Add(255); // a
+
+            // We'll only save pixel data for the middle bitmaps if our third dimension is hue.
+            if (components == ColorSpectrumComponents.ValueSaturation ||
+                components == ColorSpectrumComponents.SaturationValue)
+            {
+                Rgb rgbMiddle1 = hsvMiddle1.ToRgb();
+                bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.B * 255)); // b
+                bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.G * 255)); // g
+                bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.R * 255)); // r
+                bgraMiddle1PixelData.Add(255); // a
+
+                Rgb rgbMiddle2 = hsvMiddle2.ToRgb();
+                bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.B * 255)); // b
+                bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.G * 255)); // g
+                bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.R * 255)); // r
+                bgraMiddle2PixelData.Add(255); // a
+
+                Rgb rgbMiddle3 = hsvMiddle3.ToRgb();
+                bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.B * 255)); // b
+                bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.G * 255)); // g
+                bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.R * 255)); // r
+                bgraMiddle3PixelData.Add(255); // a
+
+                Rgb rgbMiddle4 = hsvMiddle4.ToRgb();
+                bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.B * 255)); // b
+                bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.G * 255)); // g
+                bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.R * 255)); // r
+                bgraMiddle4PixelData.Add(255); // a
+            }
+
+            Rgb rgbMax = hsvMax.ToRgb();
+            bgraMaxPixelData.Add((byte)Math.Round(rgbMax.B * 255)); // b
+            bgraMaxPixelData.Add((byte)Math.Round(rgbMax.G * 255)); // g
+            bgraMaxPixelData.Add((byte)Math.Round(rgbMax.R * 255)); // r
+            bgraMaxPixelData.Add(255); // a
+        }
+
+        private void UpdateBitmapSources()
+        {
+            if (_spectrumOverlayRectangle == null ||
+                _spectrumOverlayEllipse == null ||
+                _spectrumRectangle == null ||
+                _spectrumEllipse == null)
+            {
+                return;
+            }
+
+            HsvColor hsvColor = HsvColor;
+            ColorSpectrumComponents components = Components;
+
+            // We'll set the base image and the overlay image based on which component is our third dimension.
+            // If it's saturation or luminosity, then the base image is that dimension at its minimum value,
+            // while the overlay image is that dimension at its maximum value.
+            // If it's hue, then we'll figure out where in the color wheel we are, and then use the two
+            // colors on either side of our position as our base image and overlay image.
+            // For example, if our hue is orange, then the base image would be red and the overlay image yellow.
+            switch (components)
+            {
+                case ColorSpectrumComponents.HueValue:
+                case ColorSpectrumComponents.ValueHue:
+                    {
+                        if (_saturationMinimumBitmap == null ||
+                            _saturationMaximumBitmap == null)
+                        {
+                            return;
+                        }
+
+                        ImageBrush spectrumBrush = new ImageBrush(_saturationMinimumBitmap);
+                        ImageBrush spectrumOverlayBrush = new ImageBrush(_saturationMaximumBitmap);
+
+                        _spectrumOverlayRectangle.Opacity = hsvColor.S;
+                        _spectrumOverlayEllipse.Opacity = hsvColor.S;
+                        _spectrumRectangle.Fill = spectrumBrush;
+                        _spectrumEllipse.Fill = spectrumBrush;
+                        _spectrumOverlayRectangle.Fill = spectrumOverlayBrush;
+                        _spectrumOverlayRectangle.Fill = spectrumOverlayBrush;
+                    }
+                    break;
+
+                case ColorSpectrumComponents.HueSaturation:
+                case ColorSpectrumComponents.SaturationHue:
+                    {
+                        if (_valueBitmap == null)
+                        {
+                            return;
+                        }
+
+                        ImageBrush spectrumBrush = new ImageBrush(_valueBitmap);
+                        ImageBrush spectrumOverlayBrush = new ImageBrush(_valueBitmap);
+
+                        _spectrumOverlayRectangle.Opacity = 1.0;
+                        _spectrumOverlayEllipse.Opacity = 1.0;
+                        _spectrumRectangle.Fill = spectrumBrush;
+                        _spectrumEllipse.Fill = spectrumBrush;
+                        _spectrumOverlayRectangle.Fill = spectrumOverlayBrush;
+                        _spectrumOverlayRectangle.Fill = spectrumOverlayBrush;
+                    }
+                    break;
+
+                case ColorSpectrumComponents.ValueSaturation:
+                case ColorSpectrumComponents.SaturationValue:
+                    {
+                        if (_hueRedBitmap == null ||
+                            _hueYellowBitmap == null ||
+                            _hueGreenBitmap == null ||
+                            _hueCyanBitmap == null ||
+                            _hueBlueBitmap == null ||
+                            _huePurpleBitmap == null)
+                        {
+                            return;
+                        }
+
+                        ImageBrush spectrumBrush;
+                        ImageBrush spectrumOverlayBrush;
+
+                        double sextant = hsvColor.H / 60.0;
+
+                        if (sextant < 1)
+                        {
+                            spectrumBrush = new ImageBrush(_hueRedBitmap);
+                            spectrumOverlayBrush = new ImageBrush(_hueYellowBitmap);
+                        }
+                        else if (sextant >= 1 && sextant < 2)
+                        {
+                            spectrumBrush = new ImageBrush(_hueYellowBitmap);
+                            spectrumOverlayBrush = new ImageBrush(_hueGreenBitmap);
+                        }
+                        else if (sextant >= 2 && sextant < 3)
+                        {
+                            spectrumBrush = new ImageBrush(_hueGreenBitmap);
+                            spectrumOverlayBrush = new ImageBrush(_hueCyanBitmap);
+                        }
+                        else if (sextant >= 3 && sextant < 4)
+                        {
+                            spectrumBrush = new ImageBrush(_hueCyanBitmap);
+                            spectrumOverlayBrush = new ImageBrush(_hueBlueBitmap);
+                        }
+                        else if (sextant >= 4 && sextant < 5)
+                        {
+                            spectrumBrush = new ImageBrush(_hueBlueBitmap);
+                            spectrumOverlayBrush = new ImageBrush(_huePurpleBitmap);
+                        }
+                        else
+                        {
+                            spectrumBrush = new ImageBrush(_huePurpleBitmap);
+                            spectrumOverlayBrush = new ImageBrush(_hueRedBitmap);
+                        }
+
+                        _spectrumOverlayRectangle.Opacity = sextant - (int)sextant;
+                        _spectrumOverlayEllipse.Opacity = sextant - (int)sextant;
+                        _spectrumRectangle.Fill = spectrumBrush;
+                        _spectrumEllipse.Fill = spectrumBrush;
+                        _spectrumOverlayRectangle.Fill = spectrumOverlayBrush;
+                        _spectrumOverlayRectangle.Fill = spectrumOverlayBrush;
+                    }
+                    break;
+            }
+        }
+
+        /// <summary>
+        /// Determines whether the selection ellipse should be light based on the relative
+        /// luminance of the selected color.
+        /// </summary>
+        private bool SelectionEllipseShouldBeLight()
+        {
+            // 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.
+            //
+            // If the third component 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.
+            Color displayedColor;
+
+            if (Components == ColorSpectrumComponents.HueSaturation ||
+                Components == ColorSpectrumComponents.SaturationHue)
+            {
+                HsvColor hsvColor = HsvColor;
+                Rgb color = (new Hsv(hsvColor.H, hsvColor.S, 1.0)).ToRgb();
+                displayedColor = color.ToColor(hsvColor.A);
+            }
+            else
+            {
+                displayedColor = Color;
+            }
+
+            var lum = ColorHelpers.GetRelativeLuminance(displayedColor);
+
+            return lum <= 0.5;
+        }
+    }
+}

+ 86 - 0
src/Avalonia.Controls.ColorPicker/ColorSpectrum/Hsv.cs

@@ -0,0 +1,86 @@
+// 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 Avalonia.Media;
+
+namespace Avalonia.Controls.Primitives
+{
+    /// <summary>
+    /// Contains and allows modification of Hue, Saturation and Value components.
+    /// </summary>
+    /// <remarks>
+    ///   The is a specialized struct optimized for permanence and memory:
+    ///   <list type="bullet">
+    ///     <item>This is not a read-only struct like <see cref="HsvColor"/> and allows editing the fields</item>
+    ///     <item>Removes the alpha component unnecessary in core calculations</item>
+    ///     <item>No component bounds checks or clamping is done.</item>
+    ///   </list>
+    /// </remarks>
+    internal struct Hsv
+    {
+        /// <summary>
+        /// The Hue component in the range from 0..359.
+        /// </summary>
+        public double H;
+
+        /// <summary>
+        /// The Saturation component in the range from 0..1.
+        /// </summary>
+        public double S;
+
+        /// <summary>
+        /// The Value component in the range from 0..1.
+        /// </summary>
+        public double V;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="Hsv"/> struct.
+        /// </summary>
+        /// <param name="h">The Hue component in the range from 0..360.</param>
+        /// <param name="s">The Saturation component in the range from 0..1.</param>
+        /// <param name="v">The Value component in the range from 0..1.</param>
+        public Hsv(double h, double s, double v)
+        {
+            H = h;
+            S = s;
+            V = v;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="Hsv"/> struct.
+        /// </summary>
+        /// <param name="hsvColor">An existing <see cref="HsvColor"/> to convert to <see cref="Hsv"/>.</param>
+        public Hsv(HsvColor hsvColor)
+        {
+            H = hsvColor.H;
+            S = hsvColor.S;
+            V = hsvColor.V;
+        }
+
+        /// <summary>
+        /// Converts this <see cref="Hsv"/> struct into a standard <see cref="HsvColor"/>.
+        /// </summary>
+        /// <param name="alpha">The Alpha component in the range from 0..1.</param>
+        /// <returns>A new <see cref="HsvColor"/> representing this <see cref="Hsv"/> struct.</returns>
+        public HsvColor ToHsvColor(double alpha = 1.0)
+        {
+            // Clamping is done automatically in the constructor
+            return HsvColor.FromAhsv(alpha, H, S, V);
+        }
+
+        /// <summary>
+        /// Returns the <see cref="Rgb"/> color model equivalent of this <see cref="Hsv"/> color.
+        /// </summary>
+        /// <returns>The <see cref="Rgb"/> equivalent color.</returns>
+        public Rgb ToRgb()
+        {
+            // Instantiating a Color is unfortunately necessary to use existing conversions
+            // Clamping is done internally in the conversion method
+            Color color = HsvColor.ToRgb(H, S, V);
+
+            return new Rgb(color);
+        }
+    }
+}

+ 23 - 0
src/Avalonia.Controls.ColorPicker/ColorSpectrum/IncrementAmount.cs

@@ -0,0 +1,23 @@
+// 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.
+
+namespace Avalonia.Controls.Primitives
+{
+    /// <summary>
+    /// Defines a relative amount that a color component should be incremented.
+    /// </summary>
+    internal enum IncrementAmount
+    {
+        /// <summary>
+        /// A smaller change in value.
+        /// </summary>
+        Small,
+
+        /// <summary>
+        /// A larger change in value.
+        /// </summary>
+        Large,
+    };
+}

+ 23 - 0
src/Avalonia.Controls.ColorPicker/ColorSpectrum/IncrementDirection.cs

@@ -0,0 +1,23 @@
+// 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.
+
+namespace Avalonia.Controls.Primitives
+{
+    /// <summary>
+    /// Defines the direction a color component should be incremented.
+    /// </summary>
+    internal enum IncrementDirection
+    {
+        /// <summary>
+        /// Decreasing in value towards zero.
+        /// </summary>
+        Lower,
+
+        /// <summary>
+        /// Increasing in value towards positive infinity.
+        /// </summary>
+        Higher,
+    };
+}

+ 94 - 0
src/Avalonia.Controls.ColorPicker/ColorSpectrum/Rgb.cs

@@ -0,0 +1,94 @@
+// 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 Avalonia.Media;
+using Avalonia.Utilities;
+
+namespace Avalonia.Controls.Primitives
+{
+    /// <summary>
+    /// Contains and allows modification of Red, Green and Blue components.
+    /// </summary>
+    /// <remarks>
+    ///   The is a specialized struct optimized for permanence and memory:
+    ///   <list type="bullet">
+    ///     <item>This is not a read-only struct like <see cref="Color"/> and allows editing the fields</item>
+    ///     <item>Removes the alpha component unnecessary in core calculations</item>
+    ///     <item>Normalizes RGB components in the range of 0..1 to simplify calculations.</item>
+    ///     <item>No component bounds checks or clamping is done.</item>
+    ///   </list>
+    /// </remarks>
+    internal struct Rgb
+    {
+        /// <summary>
+        /// The Red component in the range from 0..1.
+        /// </summary>
+        public double R;
+
+        /// <summary>
+        /// The Green component in the range from 0..1.
+        /// </summary>
+        public double G;
+
+        /// <summary>
+        /// The Blue component in the range from 0..1.
+        /// </summary>
+        public double B;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="Rgb"/> struct.
+        /// </summary>
+        /// <param name="r">The Red component in the range from 0..1.</param>
+        /// <param name="g">The Green component in the range from 0..1.</param>
+        /// <param name="b">The Blue component in the range from 0..1.</param>
+        public Rgb(double r, double g, double b)
+        {
+            R = r;
+            G = g;
+            B = b;
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="Rgb"/> struct.
+        /// </summary>
+        /// <param name="color">An existing <see cref="Color"/> to convert to <see cref="Rgb"/>.</param>
+        public Rgb(Color color)
+        {
+            R = color.R / 255.0;
+            G = color.G / 255.0;
+            B = color.B / 255.0;
+        }
+
+        /// <summary>
+        /// Converts this <see cref="Rgb"/> struct into a standard <see cref="Color"/>.
+        /// </summary>
+        /// <param name="alpha">The Alpha component in the range from 0..1.</param>
+        /// <returns>A new <see cref="Color"/> representing this <see cref="Rgb"/> struct.</returns>
+        public Color ToColor(double alpha = 1.0)
+        {
+            return Color.FromArgb(
+                (byte)MathUtilities.Clamp(alpha * 255.0, 0x00, 0xFF),
+                (byte)MathUtilities.Clamp(R * 255.0, 0x00, 0xFF),
+                (byte)MathUtilities.Clamp(G * 255.0, 0x00, 0xFF),
+                (byte)MathUtilities.Clamp(B * 255.0, 0x00, 0xFF));
+        }
+
+        /// <summary>
+        /// Returns the <see cref="Hsv"/> color model equivalent of this <see cref="Rgb"/> color.
+        /// </summary>
+        /// <returns>The <see cref="Hsv"/> equivalent color.</returns>
+        public Hsv ToHsv()
+        {
+            // Instantiating an HsvColor is unfortunately necessary to use existing conversions
+            // Clamping must be done here as it isn't done in the conversion method (internal-use only)
+            HsvColor hsvColor = Color.ToHsv(
+                MathUtilities.Clamp(R, 0.0, 1.0),
+                MathUtilities.Clamp(G, 0.0, 1.0),
+                MathUtilities.Clamp(B, 0.0, 1.0));
+
+            return new Hsv(hsvColor);
+        }
+    }
+}

+ 73 - 0
src/Avalonia.Controls.ColorPicker/ColorSpectrumComponents.cs

@@ -0,0 +1,73 @@
+// 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.Controls.Primitives;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Defines the two HSV color components displayed by a <see cref="ColorSpectrum"/>.
+    /// </summary>
+    /// <remarks>
+    /// Order of the color components is important and correspond with an X/Y axis in Box
+    /// shape or a degree/radius in Ring shape.
+    /// </remarks>
+    public enum ColorSpectrumComponents
+    {
+        /// <summary>
+        /// The Hue and Value components.
+        /// </summary>
+        /// <remarks>
+        /// In Box shape, Hue is mapped to the X-axis and Value is mapped to the Y-axis.
+        /// In Ring shape, Hue is mapped to degrees and Value is mapped to radius.
+        /// </remarks>
+        HueValue,
+
+        /// <summary>
+        /// The Value and Hue components.
+        /// </summary>
+        /// <remarks>
+        /// In Box shape, Value is mapped to the X-axis and Hue is mapped to the Y-axis.
+        /// In Ring shape, Value is mapped to degrees and Hue is mapped to radius.
+        /// </remarks>
+        ValueHue,
+
+        /// <summary>
+        /// The Hue and Saturation components.
+        /// </summary>
+        /// <remarks>
+        /// In Box shape, Hue is mapped to the X-axis and Saturation is mapped to the Y-axis.
+        /// In Ring shape, Hue is mapped to degrees and Saturation is mapped to radius.
+        /// </remarks>
+        HueSaturation,
+
+        /// <summary>
+        /// The Saturation and Hue components.
+        /// </summary>
+        /// <remarks>
+        /// In Box shape, Saturation is mapped to the X-axis and Hue is mapped to the Y-axis.
+        /// In Ring shape, Saturation is mapped to degrees and Hue is mapped to radius.
+        /// </remarks>
+        SaturationHue,
+
+        /// <summary>
+        /// The Saturation and Value components.
+        /// </summary>
+        /// <remarks>
+        /// In Box shape, Saturation is mapped to the X-axis and Value is mapped to the Y-axis.
+        /// In Ring shape, Saturation is mapped to degrees and Value is mapped to radius.
+        /// </remarks>
+        SaturationValue,
+
+        /// <summary>
+        /// The Value and Saturation components.
+        /// </summary>
+        /// <remarks>
+        /// In Box shape, Value is mapped to the X-axis and Saturation is mapped to the Y-axis.
+        /// In Ring shape, Value is mapped to degrees and Saturation is mapped to radius.
+        /// </remarks>
+        ValueSaturation,
+    };
+}

+ 26 - 0
src/Avalonia.Controls.ColorPicker/ColorSpectrumShape.cs

@@ -0,0 +1,26 @@
+// 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.Controls.Primitives;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Defines the shape of a <see cref="ColorSpectrum"/>.
+    /// </summary>
+    public enum ColorSpectrumShape
+    {
+        /// <summary>
+        /// The spectrum is in the shape of a rectangular or square box.
+        /// Note that more colors are visible to the user in Box shape.
+        /// </summary>
+        Box,
+
+        /// <summary>
+        /// The spectrum is in the shape of an ellipse or circle.
+        /// </summary>
+        Ring,
+    };
+}

+ 47 - 0
src/Avalonia.Controls.ColorPicker/HsvComponent.cs

@@ -0,0 +1,47 @@
+// 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
+{
+    /// <summary>
+    /// Defines a specific component in the HSV color model.
+    /// </summary>
+    public enum HsvComponent
+    {
+        /// <summary>
+        /// The Hue component.
+        /// </summary>
+        /// <remarks>
+        /// Also see: <see cref="HsvColor.H"/>
+        /// </remarks>
+        Hue,
+
+        /// <summary>
+        /// The Saturation component.
+        /// </summary>
+        /// <remarks>
+        /// Also see: <see cref="HsvColor.S"/>
+        /// </remarks>
+        Saturation,
+
+        /// <summary>
+        /// The Value component.
+        /// </summary>
+        /// <remarks>
+        /// Also see: <see cref="HsvColor.V"/>
+        /// </remarks>
+        Value,
+
+        /// <summary>
+        /// The Alpha component.
+        /// </summary>
+        /// <remarks>
+        /// Also see: <see cref="HsvColor.A"/>
+        /// </remarks>
+        Alpha
+    };
+}

+ 8 - 0
src/Avalonia.Controls.ColorPicker/Properties/AssemblyInfo.cs

@@ -0,0 +1,8 @@
+using System.Runtime.CompilerServices;
+using Avalonia.Metadata;
+
+[assembly: InternalsVisibleTo("Avalonia.DesignerSupport, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")]
+
+[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls")]
+[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls.Collections")]
+[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls.Primitives")]

+ 134 - 0
src/Avalonia.Controls.ColorPicker/Themes/Default.xaml

@@ -0,0 +1,134 @@
+<Styles xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:CompileBindings="True"
+        xmlns:converters="using:Avalonia.Controls.Converters">
+
+  <Styles.Resources>
+    <converters:EnumValueEqualsConverter x:Key="EnumValueEquals" />
+    <converters:CornerRadiusToDoubleConverter x:Key="TopLeftCornerRadius" Corner="TopLeft" />
+    <converters:CornerRadiusToDoubleConverter x:Key="BottomRightCornerRadius" Corner="BottomRight" />
+  </Styles.Resources>
+
+  <Style Selector="ColorSpectrum">
+    <Setter Property="Template">
+      <Setter.Value>
+        <ControlTemplate>
+          <Panel x:Name="PART_LayoutRoot"
+                 HorizontalAlignment="Stretch"
+                 VerticalAlignment="Stretch">
+            <Panel x:Name="PART_SizingPanel"
+                   HorizontalAlignment="Center"
+                   VerticalAlignment="Center"
+                   ClipToBounds="True">
+              <Rectangle x:Name="PART_SpectrumRectangle"
+                         IsHitTestVisible="False"
+                         HorizontalAlignment="Stretch"
+                         VerticalAlignment="Stretch"
+                         IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Box'}"
+                         RadiusX="{TemplateBinding CornerRadius, Converter={StaticResource TopLeftCornerRadius}}"
+                         RadiusY="{TemplateBinding CornerRadius, Converter={StaticResource BottomRightCornerRadius}}" />
+              <Rectangle x:Name="PART_SpectrumOverlayRectangle"
+                         IsHitTestVisible="False"
+                         HorizontalAlignment="Stretch"
+                         VerticalAlignment="Stretch"
+                         IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Box'}"
+                         RadiusX="{TemplateBinding CornerRadius, Converter={StaticResource TopLeftCornerRadius}}"
+                         RadiusY="{TemplateBinding CornerRadius, Converter={StaticResource BottomRightCornerRadius}}" />
+              <Ellipse x:Name="PART_SpectrumEllipse"
+                       IsHitTestVisible="False"
+                       HorizontalAlignment="Stretch"
+                       VerticalAlignment="Stretch"
+                       IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Ring'}" />
+              <Ellipse x:Name="PART_SpectrumOverlayEllipse"
+                       IsHitTestVisible="False"
+                       HorizontalAlignment="Stretch"
+                       VerticalAlignment="Stretch"
+                       IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Ring'}" />
+              <Canvas x:Name="PART_InputTarget"
+                      Background="Transparent"
+                      HorizontalAlignment="Stretch"
+                      VerticalAlignment="Stretch">
+                <Panel x:Name="PART_SelectionEllipsePanel">
+                  <Ellipse x:Name="FocusEllipse"
+                           Margin="-2"
+                           StrokeThickness="2"
+                           IsHitTestVisible="False"
+                           HorizontalAlignment="Stretch"
+                           VerticalAlignment="Stretch" />
+                  <Ellipse x:Name="SelectionEllipse"
+                           StrokeThickness="2"
+                           IsHitTestVisible="False"
+                           HorizontalAlignment="Stretch"
+                           VerticalAlignment="Stretch"
+                           ToolTip.VerticalOffset="-20"
+                           ToolTip.Placement="Top">
+                    <ToolTip.Tip>
+                      <ToolTip x:Name="PART_ColorNameToolTip" />
+                    </ToolTip.Tip>
+                  </Ellipse>
+                </Panel>
+              </Canvas>
+              <Rectangle x:Name="BorderRectangle"
+                         IsHitTestVisible="False"
+                         HorizontalAlignment="Stretch"
+                         VerticalAlignment="Stretch"
+                         IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Box'}"
+                         RadiusX="{TemplateBinding CornerRadius, Converter={StaticResource TopLeftCornerRadius}}"
+                         RadiusY="{TemplateBinding CornerRadius, Converter={StaticResource BottomRightCornerRadius}}" />
+              <Ellipse x:Name="BorderEllipse"
+                       IsHitTestVisible="False"
+                       HorizontalAlignment="Stretch"
+                       VerticalAlignment="Stretch"
+                       IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Ring'}" />
+            </Panel>
+          </Panel>
+        </ControlTemplate>
+      </Setter.Value>
+    </Setter>
+  </Style>
+
+  <!-- Normal -->
+  <!-- Separating this allows easier customization in applications -->
+  <Style Selector="ColorSpectrum /template/ Ellipse#BorderEllipse,
+                   ColorSpectrum /template/ Rectangle#BorderRectangle">
+    <Setter Property="Stroke" Value="{DynamicResource ThemeBorderLowBrush}" />
+    <Setter Property="StrokeThickness" Value="1" />
+  </Style>
+
+  <!-- Focus -->
+  <Style Selector="ColorSpectrum /template/ Ellipse#FocusEllipse">
+    <Setter Property="IsVisible" Value="False" />
+  </Style>
+  <Style Selector="ColorSpectrum:focus-visible /template/ Ellipse#FocusEllipse">
+    <Setter Property="IsVisible" Value="True" />
+  </Style>
+
+  <!-- Selector Color -->
+  <Style Selector="ColorSpectrum /template/ Ellipse#FocusEllipse">
+    <Setter Property="Stroke" Value="White" />
+  </Style>
+  <Style Selector="ColorSpectrum /template/ Ellipse#SelectionEllipse">
+    <Setter Property="Stroke" Value="Black" />
+  </Style>
+  <Style Selector="ColorSpectrum:light-selector /template/ Ellipse#FocusEllipse">
+    <Setter Property="Stroke" Value="Black" />
+  </Style>
+  <Style Selector="ColorSpectrum:light-selector /template/ Ellipse#SelectionEllipse">
+    <Setter Property="Stroke" Value="White" />
+  </Style>
+
+  <Style Selector="ColorSpectrum:pointerover /template/ Ellipse#SelectionEllipse">
+    <Setter Property="Opacity" Value="0.8" />
+  </Style>
+
+  <!-- Selector Size -->
+  <Style Selector="ColorSpectrum /template/ Panel#PART_SelectionEllipsePanel">
+    <Setter Property="Width" Value="16" />
+    <Setter Property="Height" Value="16" />
+  </Style>
+  <Style Selector="ColorSpectrum:large-selector /template/ Panel#PART_SelectionEllipsePanel">
+    <Setter Property="Width" Value="48" />
+    <Setter Property="Height" Value="48" />
+  </Style>
+
+</Styles>

+ 134 - 0
src/Avalonia.Controls.ColorPicker/Themes/Fluent.xaml

@@ -0,0 +1,134 @@
+<Styles xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        x:CompileBindings="True"
+        xmlns:converters="using:Avalonia.Controls.Converters">
+
+  <Styles.Resources>
+    <converters:EnumValueEqualsConverter x:Key="EnumValueEquals" />
+    <converters:CornerRadiusToDoubleConverter x:Key="TopLeftCornerRadius" Corner="TopLeft" />
+    <converters:CornerRadiusToDoubleConverter x:Key="BottomRightCornerRadius" Corner="BottomRight" />
+  </Styles.Resources>
+
+  <Style Selector="ColorSpectrum">
+    <Setter Property="Template">
+      <Setter.Value>
+        <ControlTemplate>
+          <Panel x:Name="PART_LayoutRoot"
+                 HorizontalAlignment="Stretch"
+                 VerticalAlignment="Stretch">
+            <Panel x:Name="PART_SizingPanel"
+                   HorizontalAlignment="Center"
+                   VerticalAlignment="Center"
+                   ClipToBounds="True">
+              <Rectangle x:Name="PART_SpectrumRectangle"
+                         IsHitTestVisible="False"
+                         HorizontalAlignment="Stretch"
+                         VerticalAlignment="Stretch"
+                         IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Box'}"
+                         RadiusX="{TemplateBinding CornerRadius, Converter={StaticResource TopLeftCornerRadius}}"
+                         RadiusY="{TemplateBinding CornerRadius, Converter={StaticResource BottomRightCornerRadius}}" />
+              <Rectangle x:Name="PART_SpectrumOverlayRectangle"
+                         IsHitTestVisible="False"
+                         HorizontalAlignment="Stretch"
+                         VerticalAlignment="Stretch"
+                         IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Box'}"
+                         RadiusX="{TemplateBinding CornerRadius, Converter={StaticResource TopLeftCornerRadius}}"
+                         RadiusY="{TemplateBinding CornerRadius, Converter={StaticResource BottomRightCornerRadius}}" />
+              <Ellipse x:Name="PART_SpectrumEllipse"
+                       IsHitTestVisible="False"
+                       HorizontalAlignment="Stretch"
+                       VerticalAlignment="Stretch"
+                       IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Ring'}" />
+              <Ellipse x:Name="PART_SpectrumOverlayEllipse"
+                       IsHitTestVisible="False"
+                       HorizontalAlignment="Stretch"
+                       VerticalAlignment="Stretch"
+                       IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Ring'}" />
+              <Canvas x:Name="PART_InputTarget"
+                      Background="Transparent"
+                      HorizontalAlignment="Stretch"
+                      VerticalAlignment="Stretch">
+                <Panel x:Name="PART_SelectionEllipsePanel">
+                  <Ellipse x:Name="FocusEllipse"
+                           Margin="-2"
+                           StrokeThickness="2"
+                           IsHitTestVisible="False"
+                           HorizontalAlignment="Stretch"
+                           VerticalAlignment="Stretch" />
+                  <Ellipse x:Name="SelectionEllipse"
+                           StrokeThickness="2"
+                           IsHitTestVisible="False"
+                           HorizontalAlignment="Stretch"
+                           VerticalAlignment="Stretch"
+                           ToolTip.VerticalOffset="-20"
+                           ToolTip.Placement="Top">
+                    <ToolTip.Tip>
+                      <ToolTip x:Name="PART_ColorNameToolTip" />
+                    </ToolTip.Tip>
+                  </Ellipse>
+                </Panel>
+              </Canvas>
+              <Rectangle x:Name="BorderRectangle"
+                         IsHitTestVisible="False"
+                         HorizontalAlignment="Stretch"
+                         VerticalAlignment="Stretch"
+                         IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Box'}"
+                         RadiusX="{TemplateBinding CornerRadius, Converter={StaticResource TopLeftCornerRadius}}"
+                         RadiusY="{TemplateBinding CornerRadius, Converter={StaticResource BottomRightCornerRadius}}" />
+              <Ellipse x:Name="BorderEllipse"
+                       IsHitTestVisible="False"
+                       HorizontalAlignment="Stretch"
+                       VerticalAlignment="Stretch"
+                       IsVisible="{TemplateBinding Shape, Converter={StaticResource EnumValueEquals}, ConverterParameter='Ring'}" />
+            </Panel>
+          </Panel>
+        </ControlTemplate>
+      </Setter.Value>
+    </Setter>
+  </Style>
+
+  <!-- Normal -->
+  <!-- Separating this allows easier customization in applications -->
+  <Style Selector="ColorSpectrum /template/ Ellipse#BorderEllipse,
+                   ColorSpectrum /template/ Rectangle#BorderRectangle">
+    <Setter Property="Stroke" Value="{DynamicResource SystemControlForegroundListLowBrush}" />
+    <Setter Property="StrokeThickness" Value="1" />
+  </Style>
+
+  <!-- Focus -->
+  <Style Selector="ColorSpectrum /template/ Ellipse#FocusEllipse">
+    <Setter Property="IsVisible" Value="False" />
+  </Style>
+  <Style Selector="ColorSpectrum:focus-visible /template/ Ellipse#FocusEllipse">
+    <Setter Property="IsVisible" Value="True" />
+  </Style>
+
+  <!-- Selector Color -->
+  <Style Selector="ColorSpectrum /template/ Ellipse#FocusEllipse">
+    <Setter Property="Stroke" Value="{DynamicResource SystemControlBackgroundChromeWhiteBrush}" />
+  </Style>
+  <Style Selector="ColorSpectrum /template/ Ellipse#SelectionEllipse">
+    <Setter Property="Stroke" Value="{DynamicResource SystemControlBackgroundChromeBlackHighBrush}" />
+  </Style>
+  <Style Selector="ColorSpectrum:light-selector /template/ Ellipse#FocusEllipse">
+    <Setter Property="Stroke" Value="{DynamicResource SystemControlBackgroundChromeBlackHighBrush}" />
+  </Style>
+  <Style Selector="ColorSpectrum:light-selector /template/ Ellipse#SelectionEllipse">
+    <Setter Property="Stroke" Value="{DynamicResource SystemControlBackgroundChromeWhiteBrush}" />
+  </Style>
+
+  <Style Selector="ColorSpectrum:pointerover /template/ Ellipse#SelectionEllipse">
+    <Setter Property="Opacity" Value="0.8" />
+  </Style>
+
+  <!-- Selector Size -->
+  <Style Selector="ColorSpectrum /template/ Panel#PART_SelectionEllipsePanel">
+    <Setter Property="Width" Value="16" />
+    <Setter Property="Height" Value="16" />
+  </Style>
+  <Style Selector="ColorSpectrum:large-selector /template/ Panel#PART_SelectionEllipsePanel">
+    <Setter Property="Width" Value="48" />
+    <Setter Property="Height" Value="48" />
+  </Style>
+
+</Styles>

+ 9 - 8
src/Avalonia.Controls/Converters/CornerRadiusFilterConverter.cs

@@ -7,17 +7,18 @@ namespace Avalonia.Controls.Converters
 {
     /// <summary>
     /// Converts an existing CornerRadius struct to a new CornerRadius struct,
-    /// with filters applied to extract only the specified fields, leaving the others set to 0.
+    /// with filters applied to extract only the specified corners, leaving the others set to 0.
     /// </summary>
     public class CornerRadiusFilterConverter : IValueConverter
     {
         /// <summary>
-        /// Gets or sets the type of the filter applied to the <see cref="CornerRadiusFilterConverter"/>.
+        /// Gets or sets the corners to filter by.
+        /// Only the specified corners will be included in the converted <see cref="CornerRadius"/>.
         /// </summary>
-        public CornerRadiusFilterKinds Filter { get; set; }
+        public Corners Filter { get; set; }
 
         /// <summary>
-        /// Gets or sets the scale multiplier applied to the <see cref="CornerRadiusFilterConverter"/>.
+        /// Gets or sets the scale multiplier applied uniformly to each corner.
         /// </summary>
         public double Scale { get; set; } = 1;
 
@@ -29,10 +30,10 @@ namespace Avalonia.Controls.Converters
             }
 
             return new CornerRadius(
-                Filter.HasAllFlags(CornerRadiusFilterKinds.TopLeft) ? radius.TopLeft * Scale : 0,
-                Filter.HasAllFlags(CornerRadiusFilterKinds.TopRight) ? radius.TopRight * Scale : 0,
-                Filter.HasAllFlags(CornerRadiusFilterKinds.BottomRight) ? radius.BottomRight * Scale : 0,
-                Filter.HasAllFlags(CornerRadiusFilterKinds.BottomLeft) ? radius.BottomLeft * Scale : 0);
+                Filter.HasAllFlags(Corners.TopLeft) ? radius.TopLeft * Scale : 0,
+                Filter.HasAllFlags(Corners.TopRight) ? radius.TopRight * Scale : 0,
+                Filter.HasAllFlags(Corners.BottomRight) ? radius.BottomRight * Scale : 0,
+                Filter.HasAllFlags(Corners.BottomLeft) ? radius.BottomLeft * Scale : 0);
         }
 
         public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

+ 46 - 0
src/Avalonia.Controls/Converters/CornerRadiusToDoubleConverter.cs

@@ -0,0 +1,46 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace Avalonia.Controls.Converters
+{
+    /// <summary>
+    /// Converts one corner of a <see cref="CornerRadius"/> to its double value.
+    /// </summary>
+    public class CornerRadiusToDoubleConverter : IValueConverter
+    {
+        /// <summary>
+        /// Gets or sets the specific corner of the <see cref="CornerRadius"/> to convert to double.
+        /// </summary>
+        public Corners Corner { get; set; }
+
+        /// <inheritdoc/>
+        public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+        {
+            if (!(value is CornerRadius cornerRadius))
+            {
+                return AvaloniaProperty.UnsetValue;
+            }
+
+            switch (Corner)
+            {
+                case Corners.TopLeft:
+                    return cornerRadius.TopLeft;
+                case Corners.TopRight:
+                    return cornerRadius.TopRight;
+                case Corners.BottomRight:
+                    return cornerRadius.BottomRight;
+                case Corners.BottomLeft:
+                    return cornerRadius.BottomLeft;
+                default:
+                    return 0.0;
+            }
+        }
+
+        /// <inheritdoc/>
+        public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+        {
+            throw new NotImplementedException();
+        }
+    }
+}

+ 11 - 7
src/Avalonia.Controls/Converters/CornerRadiusFilterKind.cs → src/Avalonia.Controls/Converters/Corners.cs

@@ -3,29 +3,33 @@
 namespace Avalonia.Controls.Converters
 {
     /// <summary>
-    /// Defines constants that specify the filter type for a <see cref="CornerRadiusFilterConverter"/> instance.
+    /// Defines constants that specify one or more corners of a <see cref="CornerRadius"/>.
     /// </summary>
     [Flags]
-    public enum CornerRadiusFilterKinds
+    public enum Corners
     {
         /// <summary>
-        /// No filter applied.
+        /// No corner.
         /// </summary>
         None,
+
         /// <summary>
-        /// Filters TopLeft value.
+        /// The TopLeft corner.
         /// </summary>
         TopLeft = 1,
+
         /// <summary>
-        /// Filters TopRight value.
+        /// The TopRight corner.
         /// </summary>
         TopRight = 2,
+
         /// <summary>
-        /// Filters BottomLeft value.
+        /// The BottomLeft corner.
         /// </summary>
         BottomLeft = 4,
+
         /// <summary>
-        /// Filters BottomRight value.
+        /// The BottomRight corner.
         /// </summary>
         BottomRight = 8
     }

+ 54 - 0
src/Avalonia.Controls/Converters/EnumValueEqualsConverter.cs

@@ -0,0 +1,54 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace Avalonia.Controls.Converters
+{
+    /// <summary>
+    /// Converter that checks if an enum value is equal to the given parameter enum value.
+    /// </summary>
+    public class EnumValueEqualsConverter : IValueConverter
+    {
+        /// <inheritdoc/>
+        public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+        {
+            // Note: Unlike string comparisons, null/empty is not supported
+            // Both 'value' and 'parameter' must exist and if both are missing they are not considered equal
+            if (value != null &&
+                parameter != null)
+            {
+                Type type = value.GetType();
+
+                if (type.IsEnum)
+                {
+                    var valueStr = value?.ToString();
+                    var paramStr = parameter?.ToString();
+
+                    if (string.Equals(valueStr, paramStr, StringComparison.OrdinalIgnoreCase))
+                    {
+                        return true;
+                    }
+                }
+
+                /*
+                // TODO: When .net Standard 2.0 is no longer supported the code can be changed to below
+                // This is a little more type safe
+                if (type.IsEnum &&
+                    Enum.TryParse(type, value?.ToString(), true, out object? valueEnum) &&
+                    Enum.TryParse(type, parameter?.ToString(), true, out object? paramEnum))
+                {
+                    return valueEnum == paramEnum;
+                }
+                */
+            }
+
+            return false;
+        }
+
+        /// <inheritdoc/>
+        public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+        {
+            throw new System.NotImplementedException();
+        }
+    }
+}

+ 0 - 1
src/Avalonia.Controls/Converters/MarginMultiplierConverter.cs

@@ -35,7 +35,6 @@ namespace Avalonia.Controls.Converters
                     Bottom ? Indent * thicknessDepth.Bottom : 0);
             }
             return new Thickness(0);
-            
         }
 
         public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

+ 2 - 0
src/Avalonia.Controls/SplitButton/SplitButton.cs

@@ -228,6 +228,8 @@ namespace Avalonia.Controls
         /// <inheritdoc/>
         protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
         {
+            base.OnApplyTemplate(e);
+
             UnregisterEvents();
             UnregisterFlyoutEvents(Flyout);
 

+ 1 - 0
src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj

@@ -13,6 +13,7 @@
     <Compile Include="..\Avalonia.Base\Metadata\NullableAttributes.cs" Link="NullableAttributes.cs" />
   </ItemGroup>
   <ItemGroup>
+    <ProjectReference Include="..\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" />
     <ProjectReference Include="..\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj" />
     <ProjectReference Include="..\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />
     <ProjectReference Include="..\Markup\Avalonia.Markup\Avalonia.Markup.csproj" />

+ 2 - 2
src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml

@@ -61,8 +61,8 @@
   <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml"/>
   <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/SplitButton.xaml" />
   <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/SplitView.xaml"/>
-  <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/DatePicker.xaml"/>  
-  <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/TimePicker.xaml"/>  
+  <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/DatePicker.xaml"/>
+  <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/TimePicker.xaml"/>
   <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml"/>
   <StyleInclude Source="avares://Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml"/>
   <!-- ManagedFileChooser comes last because it uses (and overrides) styles for a multitude of other controls...the dialogs were originally UserControls, after all-->

+ 1 - 0
tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj

@@ -9,6 +9,7 @@
   <Import Project="..\..\build\NetFX.props" />
   <Import Project="..\..\build\SharedVersion.props" />
   <ItemGroup>
+    <ProjectReference Include="..\..\src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj" />
     <ProjectReference Include="..\..\src\Markup\Avalonia.Markup.Xaml\Avalonia.Markup.Xaml.csproj" />
     <ProjectReference Include="..\..\src\Markup\Avalonia.Markup\Avalonia.Markup.csproj" />