From 0069e37adb284fb847e300d83623a9d50fe63cce Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 19 Mar 2026 21:22:33 +1100
Subject: [PATCH 1/2] fix: disable perceptive mode on ColorSpectrum
third-component slider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ColorSpectrum's companion slider should show what each hue/saturation/value
looks like at the actual 2D selection point — not at hardcoded S=1, V=1.
IsPerceptive="True" was forcing non-varying HSV components to 1.0, producing
vibrant gradients regardless of the current selection. This is correct for
standalone sliders (pedagogical mode) but wrong for the spectrum's third axis,
where accuracy is the contract.
Fixes #20925
---
.../Themes/Fluent/ColorPicker.xaml | 2 +-
src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml | 2 +-
.../Themes/Simple/ColorPicker.xaml | 2 +-
src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorView.xaml | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml
index b74e4e59aa..b2d404d6f3 100644
--- a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml
+++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml
@@ -122,7 +122,7 @@
AutomationProperties.Name="Third Component"
Grid.Column="0"
IsAlphaVisible="False"
- IsPerceptive="True"
+ IsPerceptive="False"
Orientation="Vertical"
ColorModel="Hsva"
ColorComponent="{Binding ThirdComponent, ElementName=ColorSpectrum}"
diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml
index ad517e8e80..68de2f2644 100644
--- a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml
+++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml
@@ -366,7 +366,7 @@
AutomationProperties.Name="Third Component"
Grid.Column="0"
IsAlphaVisible="False"
- IsPerceptive="True"
+ IsPerceptive="False"
Orientation="Vertical"
ColorModel="Hsva"
ColorComponent="{Binding ThirdComponent, ElementName=ColorSpectrum}"
diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorPicker.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorPicker.xaml
index c2535086fa..69370ebc7e 100644
--- a/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorPicker.xaml
+++ b/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorPicker.xaml
@@ -123,7 +123,7 @@
AutomationProperties.Name="Third Component"
Grid.Column="0"
IsAlphaVisible="False"
- IsPerceptive="True"
+ IsPerceptive="False"
Orientation="Vertical"
ColorModel="Hsva"
ColorComponent="{Binding ThirdComponent, ElementName=ColorSpectrum}"
diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorView.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorView.xaml
index 095c82a176..ef3e69b927 100644
--- a/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorView.xaml
+++ b/src/Avalonia.Controls.ColorPicker/Themes/Simple/ColorView.xaml
@@ -329,7 +329,7 @@
AutomationProperties.Name="Third Component"
Grid.Column="0"
IsAlphaVisible="False"
- IsPerceptive="True"
+ IsPerceptive="False"
Orientation="Vertical"
ColorModel="Hsva"
ColorComponent="{Binding ThirdComponent, ElementName=ColorSpectrum}"
From 78bcdaa4b86627f0079e5a18b5e4fa32da0d675a Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 19 Mar 2026 21:22:45 +1100
Subject: [PATCH 2/2] test: add ColorPickerHelpers bitmap accuracy tests
Verify that CreateComponentBitmapAsync with isPerceptive=false produces
pixel output that reflects the actual HSV selection values. Covers all
three component types (Hue, Saturation, Value) with assertions derived
from HSV color math invariants.
Also adds InternalsVisibleTo and project reference to enable testing
the internal ColorPickerHelpers from Avalonia.Controls.UnitTests.
---
.../Avalonia.Controls.ColorPicker.csproj | 1 +
.../Avalonia.Controls.UnitTests.csproj | 1 +
.../ColorPickerHelpersTests.cs | 110 ++++++++++++++++++
3 files changed, 112 insertions(+)
create mode 100644 tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs
diff --git a/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj
index 473190fe59..b6aa1fc9a4 100644
--- a/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj
+++ b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj
@@ -23,6 +23,7 @@
+
diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj
index a92ba0dc83..948c413c82 100644
--- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj
+++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj
@@ -23,6 +23,7 @@
+
diff --git a/tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs b/tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs
new file mode 100644
index 0000000000..605c48caf6
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Collections.Pooled;
+using Avalonia.Controls.Primitives;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.ColorPickerTests;
+
+///
+/// Tests that produces
+/// accurate pixel output when isPerceptive=false — the mode the ColorSpectrum's
+/// third-component slider should use.
+///
+/// Regression tests for https://github.com/AvaloniaUI/Avalonia/issues/20925
+///
+public class ColorPickerHelpersTests : ScopedTestBase
+{
+ ///
+ /// When sweeping Hue at a fixed desaturated selection (S=0.2, V=0.5),
+ /// every pixel's brightest channel should be bounded by V — not boosted to 255.
+ ///
+ [Fact]
+ public async Task Hue_Slider_Bitmap_Reflects_Actual_Saturation_And_Value()
+ {
+ var selection = new HsvColor(alpha: 1.0, hue: 180, saturation: 0.2, value: 0.5);
+
+ using var pixels = await GenerateSliderBitmap(ColorComponent.Component1, selection, height: 360);
+
+ // Sample mid-strip. In HSV, max RGB channel = V for any hue.
+ var (r, g, b) = ReadPixel(pixels, row: 180);
+ var maxChannel = Math.Max(r, Math.Max(g, b));
+ double expectedMax = selection.V * 255;
+
+ Assert.InRange(maxChannel, expectedMax - 10, expectedMax + 10);
+ }
+
+ ///
+ /// When sweeping Saturation, the brightest channel at full saturation is bounded
+ /// by the current Value — not forced to 255 by perceptive V=1.0 override.
+ ///
+ [Theory]
+ [InlineData(0.1)]
+ [InlineData(0.5)]
+ [InlineData(0.9)]
+ public async Task Saturation_Slider_Bitmap_Respects_Current_Value(double currentValue)
+ {
+ var selection = new HsvColor(alpha: 1.0, hue: 200, saturation: 0.8, value: currentValue);
+
+ using var pixels = await GenerateSliderBitmap(ColorComponent.Component2, selection);
+
+ // Row 0 = highest saturation (bitmap sweeps high→low top-to-bottom).
+ var (r, g, b) = ReadPixel(pixels, row: 0);
+ var maxChannel = Math.Max(r, Math.Max(g, b));
+ double expectedMax = currentValue * 255;
+
+ Assert.InRange(maxChannel, expectedMax - 20, expectedMax + 20);
+ }
+
+ ///
+ /// When sweeping Value at low Saturation (S=0.1), the top pixel should be
+ /// near-grey (low chroma) — not vivid from a perceptive S=1.0 override.
+ ///
+ [Fact]
+ public async Task Value_Slider_Bitmap_Respects_Current_Saturation()
+ {
+ var selection = new HsvColor(alpha: 1.0, hue: 30, saturation: 0.1, value: 0.8);
+
+ using var pixels = await GenerateSliderBitmap(ColorComponent.Component3, selection);
+
+ // Row 0 = highest Value. In HSV, chroma = V * S * 255 ≤ S * 255.
+ var (r, g, b) = ReadPixel(pixels, row: 0);
+ int chroma = Math.Max(r, Math.Max(g, b)) - Math.Min(r, Math.Min(g, b));
+
+ // At S=0.1 the max possible chroma is 25.5 — near-grey for any V.
+ // With perceptive S=1.0 override, chroma would be ~252.
+ Assert.InRange(chroma, 0, selection.S * 255 + 10);
+ }
+
+ private static async Task> GenerateSliderBitmap(
+ ColorComponent component,
+ HsvColor baseColor,
+ int height = 100)
+ {
+ const int width = 1;
+ var buffer = new PooledList(width * height * 4, ClearMode.Never, sizeToCapacity: true);
+
+ await ColorPickerHelpers.CreateComponentBitmapAsync(
+ buffer, width, height,
+ Orientation.Vertical,
+ ColorModel.Hsva,
+ component,
+ baseColor,
+ isAlphaVisible: false,
+ isPerceptive: false);
+
+ return buffer;
+ }
+
+ ///
+ /// Reads a pixel from a 1-pixel-wide vertical BGRA bitmap at the given row.
+ ///
+ private static (byte R, byte G, byte B) ReadPixel(PooledList bgraData, int row)
+ {
+ int offset = row * 4;
+ return (R: bgraData[offset + 2], G: bgraData[offset + 1], B: bgraData[offset]);
+ }
+}