From 225404e0ca84ee3c9bebed51f4ed6bc448dfda93 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Tue, 7 Oct 2025 05:55:16 +0300 Subject: [PATCH 01/13] ContrastAnalyzer for Foreground adjustments against opponent --- .../samples/AccentAnalyzerSample.xaml | 12 +-- .../ColorAnalyzer/src/AccentAnalyzer.cs | 2 +- .../ContrastAnalyzer.AttachedProperties.cs | 86 +++++++++++++++++++ 3 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs diff --git a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml index 3bcdb91fa..6df221aa9 100644 --- a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml +++ b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml @@ -1,4 +1,4 @@ - + - @@ -74,7 +74,7 @@ - @@ -86,7 +86,7 @@ - @@ -97,7 +97,7 @@ - @@ -108,7 +108,7 @@ - diff --git a/components/ColorAnalyzer/src/AccentAnalyzer.cs b/components/ColorAnalyzer/src/AccentAnalyzer.cs index a58cf90b0..d5a22f2a1 100644 --- a/components/ColorAnalyzer/src/AccentAnalyzer.cs +++ b/components/ColorAnalyzer/src/AccentAnalyzer.cs @@ -17,7 +17,7 @@ namespace CommunityToolkit.WinUI.Helpers; /// -/// A resource that can be used to extract color palettes out of any UIElement. +/// A resource that can be used to extract color palettes out of any . /// public partial class AccentAnalyzer : DependencyObject { diff --git a/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs b/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs new file mode 100644 index 000000000..b9fc24c1a --- /dev/null +++ b/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.UI; + +namespace CommunityToolkit.WinUI.Helpers; + +/// +/// A class that provides attached properties for contrast analysis and adjustment. +/// +public partial class ContrastAnalyzer +{ + /// + /// An attached property that defines the color to compare against. + /// + public static readonly DependencyProperty OpponentProperty = + DependencyProperty.RegisterAttached( + "Opponent", + typeof(Color), + typeof(ContrastAnalyzer), + new PropertyMetadata(Colors.Transparent, OnOpponentChanged)); + + /// + /// Get the opponent color to compare against. + /// + /// The opponent color. + public static Color GetOpponent(DependencyObject obj) + { + return (Color)obj.GetValue(OpponentProperty); + } + + /// + /// Set the opponent color to compare against. + /// + public static void SetOpponent(DependencyObject obj, Color value) + { + obj.SetValue(OpponentProperty, value); + } + + private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + SolidColorBrush? brush = d switch + { + SolidColorBrush b => b, + TextBlock t => t.Foreground as SolidColorBrush, + Control c => c.Foreground as SolidColorBrush, + _ => null, + }; + + // Could not find a brush to modify. + if (brush is null) + return; + + Color @base = brush.Color; + Color opponent = (Color)e.NewValue; + + // TODO: Cache original color + // TODO: Adjust only if contrast is insufficient + + // In the meantime, adjust by percieved luminance regardless + var luminance = CalculatePerceivedLuminance(opponent); + var contrastingColor = luminance < 0.5f ? Colors.White : Colors.Black; + + // Assign back + switch (d) + { + case SolidColorBrush b: + b.Color = contrastingColor; + break; + case TextBlock t: + t.Foreground = new SolidColorBrush(contrastingColor); + break; + case Control c: + c.Foreground = new SolidColorBrush(contrastingColor); + break; + } + } + + private static float CalculatePerceivedLuminance(Color color) + { + // Using the formula for perceived luminance + // Source WCAG guidelines: https://www.w3.org/TR/AERT/#color-contrast + return (0.299f * color.R + 0.587f * color.G + 0.114f * color.B) / 255f; + } +} From d071fa9fb1bfb7a8291fca2df9524f14fcf2a128 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Tue, 7 Oct 2025 07:40:30 +0300 Subject: [PATCH 02/13] Added MinRatio property for ContrastAnalyzer to specify threshold WCAG rato to enable color override --- .../samples/AccentAnalyzerSample.xaml | 11 ++- .../ContrastAnalyzer.AttachedProperties.cs | 93 ++++++++++++++----- 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml index 6df221aa9..433fa4ecc 100644 --- a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml +++ b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml @@ -1,4 +1,4 @@ - + - + + + + + diff --git a/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs b/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs index b9fc24c1a..5dbfae264 100644 --- a/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs +++ b/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs @@ -21,32 +21,43 @@ public partial class ContrastAnalyzer typeof(ContrastAnalyzer), new PropertyMetadata(Colors.Transparent, OnOpponentChanged)); + /// + /// An attached property that defines the minimum acceptable contrast ratio against the opponent color. + /// + /// + /// Range: 1 to 21 (inclusive). Default is 21 (maximum contrast). + /// + public static readonly DependencyProperty MinRatioProperty = + DependencyProperty.RegisterAttached( + "MinRatio", + typeof(double), + typeof(ContrastAnalyzer), + new PropertyMetadata(21d)); + /// /// Get the opponent color to compare against. /// /// The opponent color. - public static Color GetOpponent(DependencyObject obj) - { - return (Color)obj.GetValue(OpponentProperty); - } + public static Color GetOpponent(DependencyObject obj) => (Color)obj.GetValue(OpponentProperty); /// /// Set the opponent color to compare against. /// - public static void SetOpponent(DependencyObject obj, Color value) - { - obj.SetValue(OpponentProperty, value); - } + public static void SetOpponent(DependencyObject obj, Color value) => obj.SetValue(OpponentProperty, value); + + /// + /// Get the minimum acceptable contrast ratio against the opponent color. + /// + public static double GetMinRatio(DependencyObject obj) => (double)obj.GetValue(MinRatioProperty); + + /// + /// Set the minimum acceptable contrast ratio against the opponent color. + /// + public static void SetMinRatio(DependencyObject obj, double value) => obj.SetValue(MinRatioProperty, value); private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - SolidColorBrush? brush = d switch - { - SolidColorBrush b => b, - TextBlock t => t.Foreground as SolidColorBrush, - Control c => c.Foreground as SolidColorBrush, - _ => null, - }; + var brush = FindBrush(d, out var dp); // Could not find a brush to modify. if (brush is null) @@ -55,32 +66,66 @@ private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChan Color @base = brush.Color; Color opponent = (Color)e.NewValue; - // TODO: Cache original color - // TODO: Adjust only if contrast is insufficient + // If the colors already meet the minimum ratio, no adjustment is needed + if (CalculateWCAGContrastRatio(@base, opponent) >= GetMinRatio(d)) + return; // In the meantime, adjust by percieved luminance regardless var luminance = CalculatePerceivedLuminance(opponent); var contrastingColor = luminance < 0.5f ? Colors.White : Colors.Black; - // Assign back + // Assign color + AssignColor(d, contrastingColor); + } + + private static SolidColorBrush? FindBrush(DependencyObject d, out DependencyProperty? dp) + { + (SolidColorBrush? brush, dp) = d switch + { + SolidColorBrush b => (b, SolidColorBrush.ColorProperty), + TextBlock t => (t.Foreground as SolidColorBrush, TextBlock.ForegroundProperty), + Control c => (c.Foreground as SolidColorBrush, Control.ForegroundProperty), + _ => (null, null), + }; + + return brush; + } + + private static void AssignColor(DependencyObject d, Color color) + { switch (d) { case SolidColorBrush b: - b.Color = contrastingColor; + b.Color = color; break; case TextBlock t: - t.Foreground = new SolidColorBrush(contrastingColor); + t.Foreground = new SolidColorBrush(color); break; case Control c: - c.Foreground = new SolidColorBrush(contrastingColor); + c.Foreground = new SolidColorBrush(color); break; } } - private static float CalculatePerceivedLuminance(Color color) + private static double CalculateWCAGContrastRatio(Color color1, Color color2) { + // Using the formula for contrast ratio + // Source WCAG guidelines: https://www.w3.org/TR/WCAG20/#contrast-ratiodef + + // Calculate perceived luminance for both colors + double luminance1 = CalculatePerceivedLuminance(color1); + double luminance2 = CalculatePerceivedLuminance(color2); + + // Determine lighter and darker luminance + double lighter = Math.Max(luminance1, luminance2); + double darker = Math.Min(luminance1, luminance2); + + // Calculate contrast ratio + return (lighter + 0.05f) / (darker + 0.05f); + } + + private static double CalculatePerceivedLuminance(Color color) => // Using the formula for perceived luminance // Source WCAG guidelines: https://www.w3.org/TR/AERT/#color-contrast - return (0.299f * color.R + 0.587f * color.G + 0.114f * color.B) / 255f; - } + (0.299f * color.R + 0.587f * color.G + 0.114f * color.B) / 255f; } From a7e534bb9785955d8cffdf23ca3ebbeb21de3af0 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Tue, 7 Oct 2025 17:35:02 +0300 Subject: [PATCH 03/13] Renamed ContrastAnalyzer to ContrastHelper and repaired binding issues --- .../ColorAnalyzer/samples/AccentAnalyzer.md | 2 + .../samples/AccentAnalyzerSample.xaml | 14 +- .../samples/ContrastHelperSample.xaml | 75 ++++++++++ .../samples/ContrastHelperSample.xaml.cs | 17 +++ .../src/Contrast/ContrastHelper.Properties.cs | 83 +++++++++++ .../ContrastHelper.cs} | 133 +++++++++++------- 6 files changed, 264 insertions(+), 60 deletions(-) create mode 100644 components/ColorAnalyzer/samples/ContrastHelperSample.xaml create mode 100644 components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs create mode 100644 components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs rename components/ColorAnalyzer/src/{ContrastAnalyzer.AttachedProperties.cs => Contrast/ContrastHelper.cs} (53%) diff --git a/components/ColorAnalyzer/samples/AccentAnalyzer.md b/components/ColorAnalyzer/samples/AccentAnalyzer.md index 352ab7cb8..29591e7b1 100644 --- a/components/ColorAnalyzer/samples/AccentAnalyzer.md +++ b/components/ColorAnalyzer/samples/AccentAnalyzer.md @@ -17,3 +17,5 @@ icon: assets/icon.png The AccentAnalyzer provides a pure XAML way to use the colors extracted from an image as a binding source for any `Color` property. > [!Sample AccentAnalyzerSample] + +> [!Sample ContrastHelperSample] diff --git a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml index 433fa4ecc..b1d6dbd13 100644 --- a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml +++ b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml @@ -1,4 +1,4 @@ - + - @@ -76,8 +76,8 @@ - @@ -91,7 +91,7 @@ - @@ -102,7 +102,7 @@ - @@ -113,7 +113,7 @@ - diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml new file mode 100644 index 000000000..2de7422bf --- /dev/null +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs new file mode 100644 index 000000000..dc2fcc34a --- /dev/null +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace ColorAnalyzerExperiment.Samples; + +/// +/// An example sample page of a custom control inheriting from Panel. +/// +[ToolkitSample(id: nameof(ContrastHelperSample), "ContrastAnalyzer helper", description: $"A sample for showing how the contrast analyzer can be used.")] +public sealed partial class ContrastHelperSample : Page +{ + public ContrastHelperSample() + { + this.InitializeComponent(); + } +} diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs new file mode 100644 index 000000000..568d04cd0 --- /dev/null +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.UI; + +namespace CommunityToolkit.WinUI.Helpers; + +public partial class ContrastHelper +{ + /// + /// An attached property that defines the color to compare against. + /// + public static readonly DependencyProperty OpponentProperty = + DependencyProperty.RegisterAttached( + "Opponent", + typeof(Color), + typeof(ContrastHelper), + new PropertyMetadata(Colors.Transparent, OnOpponentChanged)); + + /// + /// An attached property that defines the minimum acceptable contrast ratio against the opponent color. + /// + /// + /// Range: 1 to 21 (inclusive). Default is 21 (maximum contrast). + /// + public static readonly DependencyProperty MinRatioProperty = + DependencyProperty.RegisterAttached( + "MinRatio", + typeof(double), + typeof(ContrastHelper), + new PropertyMetadata(21d, OnMinRatioChanged)); + + /// + /// An attached property that records the original color before adjusting for contrast. + /// + public static readonly DependencyProperty OriginalColorProperty = + DependencyProperty.RegisterAttached( + "Original", + typeof(Color), + typeof(ContrastHelper), + new PropertyMetadata(Colors.Transparent)); + + // Tracks the callback on the original brush being updated. + private static readonly DependencyProperty CallbackProperty = + DependencyProperty.RegisterAttached( + "Callback", + typeof(long), + typeof(ContrastHelper), + new PropertyMetadata(0L)); + + /// + /// Get the opponent color to compare against. + /// + /// The opponent color. + public static Color GetOpponent(DependencyObject obj) => (Color)obj.GetValue(OpponentProperty); + + /// + /// Set the opponent color to compare against. + /// + public static void SetOpponent(DependencyObject obj, Color value) => obj.SetValue(OpponentProperty, value); + + /// + /// Get the minimum acceptable contrast ratio against the opponent color. + /// + public static double GetMinRatio(DependencyObject obj) => (double)obj.GetValue(MinRatioProperty); + + /// + /// Set the minimum acceptable contrast ratio against the opponent color. + /// + public static void SetMinRatio(DependencyObject obj, double value) => obj.SetValue(MinRatioProperty, value); + + /// + /// Gets the original color before adjustment for contrast. + /// + public static Color GetOriginal(DependencyObject obj) => (Color)obj.GetValue(OriginalColorProperty); + + private static void SetOriginal(DependencyObject obj, Color color) => obj.SetValue(OriginalColorProperty, color); + + private static long GetCallback(DependencyObject obj) => (long)obj.GetValue(CallbackProperty); + + private static void SetCallback(DependencyObject obj, long value) => obj.SetValue(CallbackProperty, value); +} diff --git a/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs similarity index 53% rename from components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs rename to components/ColorAnalyzer/src/Contrast/ContrastHelper.cs index 5dbfae264..dc9898fe2 100644 --- a/components/ColorAnalyzer/src/ContrastAnalyzer.AttachedProperties.cs +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs @@ -9,75 +9,97 @@ namespace CommunityToolkit.WinUI.Helpers; /// /// A class that provides attached properties for contrast analysis and adjustment. /// -public partial class ContrastAnalyzer +public partial class ContrastHelper { - /// - /// An attached property that defines the color to compare against. - /// - public static readonly DependencyProperty OpponentProperty = - DependencyProperty.RegisterAttached( - "Opponent", - typeof(Color), - typeof(ContrastAnalyzer), - new PropertyMetadata(Colors.Transparent, OnOpponentChanged)); - - /// - /// An attached property that defines the minimum acceptable contrast ratio against the opponent color. - /// - /// - /// Range: 1 to 21 (inclusive). Default is 21 (maximum contrast). - /// - public static readonly DependencyProperty MinRatioProperty = - DependencyProperty.RegisterAttached( - "MinRatio", - typeof(double), - typeof(ContrastAnalyzer), - new PropertyMetadata(21d)); - - /// - /// Get the opponent color to compare against. - /// - /// The opponent color. - public static Color GetOpponent(DependencyObject obj) => (Color)obj.GetValue(OpponentProperty); - - /// - /// Set the opponent color to compare against. - /// - public static void SetOpponent(DependencyObject obj, Color value) => obj.SetValue(OpponentProperty, value); - - /// - /// Get the minimum acceptable contrast ratio against the opponent color. - /// - public static double GetMinRatio(DependencyObject obj) => (double)obj.GetValue(MinRatioProperty); - - /// - /// Set the minimum acceptable contrast ratio against the opponent color. - /// - public static void SetMinRatio(DependencyObject obj, double value) => obj.SetValue(MinRatioProperty, value); + private static bool _selfUpdate = false; private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var brush = FindBrush(d, out var dp); + // Subscribe to brush updates + if (GetCallback(d) is 0) + { + SubscribeToUpdates(d); + } + + ApplyContrastCheck(d); + } - // Could not find a brush to modify. + private static void OnMinRatioChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ApplyContrastCheck(d); + } + + private static void ApplyContrastCheck(DependencyObject d) + { + // Grab brush to update + var brush = FindBrush(d, out _); if (brush is null) return; - Color @base = brush.Color; - Color opponent = (Color)e.NewValue; + // Find WCAG contrast ratio + Color @base = GetOriginal(d); + Color opponent = GetOpponent(d); + var ratio = CalculateWCAGContrastRatio(@base, opponent); - // If the colors already meet the minimum ratio, no adjustment is needed - if (CalculateWCAGContrastRatio(@base, opponent) >= GetMinRatio(d)) + // Use original color if the contrast is in the acceptable range + if (ratio >= GetMinRatio(d)) + { + AssignColor(d, @base); return; + } - // In the meantime, adjust by percieved luminance regardless + // Current contrast is too small. + // Select either black or white backed on the opponent luminance var luminance = CalculatePerceivedLuminance(opponent); var contrastingColor = luminance < 0.5f ? Colors.White : Colors.Black; - - // Assign color AssignColor(d, contrastingColor); } + private static void SubscribeToUpdates(DependencyObject d) + { + var brush = FindBrush(d, out var dp); + if (brush is null) + return; + + // Apply initial update + SetOriginal(d, brush.Color); + + if (d is not SolidColorBrush) + { + // Subscribe to updates from the source Foreground and Brush + d.RegisterPropertyChangedCallback(dp, (sender, prop) => + { + OnOriginalChangedFromSource(d, sender, prop); + }); + } + + var callback = brush.RegisterPropertyChangedCallback(SolidColorBrush.ColorProperty, (sender, prop) => + { + OnOriginalChangedFromSource(d, sender, prop); + }); + + SetCallback(d, callback); + } + + private static void OnOriginalChangedFromSource(DependencyObject obj, DependencyObject sender, DependencyProperty prop) + { + // The contrast helper is updating the color. + // Ignore the assignment. + if (_selfUpdate) + return; + + // Get brush + var brush = FindBrush(sender, out _); + if (brush is null) + return; + + // Update original color + SetOriginal(obj, brush.Color); + + // Apply contrast correction + ApplyContrastCheck(obj); + } + private static SolidColorBrush? FindBrush(DependencyObject d, out DependencyProperty? dp) { (SolidColorBrush? brush, dp) = d switch @@ -93,6 +115,9 @@ private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChan private static void AssignColor(DependencyObject d, Color color) { + // Block the original color from updating + _selfUpdate = true; + switch (d) { case SolidColorBrush b: @@ -105,6 +130,8 @@ private static void AssignColor(DependencyObject d, Color color) c.Foreground = new SolidColorBrush(color); break; } + + _selfUpdate = false; } private static double CalculateWCAGContrastRatio(Color color1, Color color2) From fd2fb1863c9b87cc8550fd4a3a933817f02154d9 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Tue, 7 Oct 2025 17:35:47 +0300 Subject: [PATCH 04/13] Applied XAML styling --- .../samples/ContrastHelperSample.xaml | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml index 2de7422bf..b8d95c9c2 100644 --- a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml @@ -1,4 +1,4 @@ - + - - + + - - + + - + - - + + FontSize="24" + Text="Legible text (MinRatio: 5)"> - + - + FontSize="16" + Text="Legible text (MinRatio: 3)"> - + - + - + @@ -56,20 +56,20 @@ - + + Color="Black" /> - - + + IsColorChannelTextInputVisible="False" + IsHexInputVisible="False" /> From 6cbdcb58347ba82c0428197e39fe705ec7a3f27a Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Tue, 7 Oct 2025 17:39:59 +0300 Subject: [PATCH 05/13] Relabeled Foreground ColorPicker as Desired Foreground to improve usage clarity --- components/ColorAnalyzer/samples/ContrastHelperSample.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml index b8d95c9c2..be15a270b 100644 --- a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml @@ -1,4 +1,4 @@ - + - + Date: Thu, 9 Oct 2025 00:08:04 +0300 Subject: [PATCH 06/13] Fixed callback subscription edge cases --- .../src/Contrast/ContrastHelper.Properties.cs | 21 +++- .../src/Contrast/ContrastHelper.cs | 109 +++++++++++++++--- 2 files changed, 111 insertions(+), 19 deletions(-) diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs index 568d04cd0..a43021d4b 100644 --- a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs @@ -8,9 +8,16 @@ namespace CommunityToolkit.WinUI.Helpers; public partial class ContrastHelper { + // TODO: Handle gradient brushes? /// /// An attached property that defines the color to compare against. /// + /// + /// This property can be attached to any or + /// to update their or . + /// If the original Foreground is not a , it will always apply contrast. + /// It can also be attached to any to update the . + /// public static readonly DependencyProperty OpponentProperty = DependencyProperty.RegisterAttached( "Opponent", @@ -41,7 +48,15 @@ public partial class ContrastHelper typeof(ContrastHelper), new PropertyMetadata(Colors.Transparent)); - // Tracks the callback on the original brush being updated. + // Tracks the SolidColorBrush we're monitoring for changes + private static readonly DependencyProperty CallbackObjectProperty = + DependencyProperty.RegisterAttached( + "CallbackObject", + typeof(DependencyObject), + typeof(ContrastHelper), + new PropertyMetadata(null)); + + // Tracks the callback token from the SolidColorBrush we are monitoring private static readonly DependencyProperty CallbackProperty = DependencyProperty.RegisterAttached( "Callback", @@ -77,6 +92,10 @@ public partial class ContrastHelper private static void SetOriginal(DependencyObject obj, Color color) => obj.SetValue(OriginalColorProperty, color); + private static DependencyObject GetCallbackObject(DependencyObject obj) => (DependencyObject)obj.GetValue(CallbackObjectProperty); + + private static void SetCallbackObject(DependencyObject obj, DependencyObject dp) => obj.SetValue(CallbackObjectProperty, dp); + private static long GetCallback(DependencyObject obj) => (long)obj.GetValue(CallbackProperty); private static void SetCallback(DependencyObject obj, long value) => obj.SetValue(CallbackProperty, value); diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs index dc9898fe2..4704a9793 100644 --- a/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs @@ -11,21 +11,29 @@ namespace CommunityToolkit.WinUI.Helpers; /// public partial class ContrastHelper { + // When the helper is updating the color, this flag is set to avoid feedback loops + // It has no threading issues since all updates are on the UI thread private static bool _selfUpdate = false; private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - // Subscribe to brush updates - if (GetCallback(d) is 0) + // Subscribe to brush updates if not already + if (GetCallbackObject(d) is null) { SubscribeToUpdates(d); } + // Update the actual color to ensure contrast ApplyContrastCheck(d); } private static void OnMinRatioChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { + // No opponent has been set, nothing to do + if (GetCallback(d) is 0) + return; + + // Update the actual color to ensure contrast ApplyContrastCheck(d); } @@ -57,49 +65,97 @@ private static void ApplyContrastCheck(DependencyObject d) private static void SubscribeToUpdates(DependencyObject d) { - var brush = FindBrush(d, out var dp); - if (brush is null) - return; + // Get the original color from the brush and the property to monitor. + // Use Transparent as a sentinel value if the brush is not a SolidColorBrush + var solidColorBrush = FindBrush(d, out var dp); + var color = solidColorBrush?.Color ?? Colors.Transparent; + + // Record the original color + SetOriginal(d, color); - // Apply initial update - SetOriginal(d, brush.Color); + // Rhetortical Question: Why don't we return if the solidColorBrush is null? + // Just because the brush is not a SolidColorBrush doesn't mean we can't monitor the + // Foreground property. We just can't monitor the brush's Color property + // If the original is not a SolidColorBrush, we need to monitor the Foreground property if (d is not SolidColorBrush) { - // Subscribe to updates from the source Foreground and Brush - d.RegisterPropertyChangedCallback(dp, (sender, prop) => + // Subscribe to updates from the source Foreground + _ = d.RegisterPropertyChangedCallback(dp, (sender, prop) => { OnOriginalChangedFromSource(d, sender, prop); }); } + // Subscribe to updates from the source SolidColorBrush + // If solidColorBrush is null, this is a no-op + SubscribeToBrushUpdates(d, solidColorBrush); + } + + private static void SubscribeToBrushUpdates(DependencyObject d, SolidColorBrush? brush) + { + // No brush, nothing to do + if (brush is null) + return; + + // Unsubscribe from previous brush if any + var oldBrush = GetCallbackObject(d); + var oldCallback = GetCallback(d); + oldBrush?.UnregisterPropertyChangedCallback(SolidColorBrush.ColorProperty, oldCallback); + + // Subscribe to updates from the source SolidColorBrush var callback = brush.RegisterPropertyChangedCallback(SolidColorBrush.ColorProperty, (sender, prop) => { OnOriginalChangedFromSource(d, sender, prop); }); + // Track the callback so we don't double subscribe and can unsubscribe if needed + SetCallbackObject(d, brush); SetCallback(d, callback); } private static void OnOriginalChangedFromSource(DependencyObject obj, DependencyObject sender, DependencyProperty prop) { - // The contrast helper is updating the color. + // The contrast helper is updating the color // Ignore the assignment. if (_selfUpdate) return; - // Get brush + // Get the original color from the brush. + // We use the sender, not the obj, because the sender is the object that changed. + // Use Transparent as a sentinel value if the brush is not a SolidColorBrush var brush = FindBrush(sender, out _); - if (brush is null) - return; + var color = brush?.Color ?? Colors.Transparent; // Update original color - SetOriginal(obj, brush.Color); + SetOriginal(obj, color); + + // The sender is the Foreground property, not the brush itself. + // This means the brush changed and our callback on the brush is dead. + // We need to subscribe to the new brush if it's a SolidColorBrush. + if (sender is not SolidColorBrush) + { + // Subscribe to the new brush + // Notice we're finding the brush on the object, not the sender this time. + // We may not find a SolidColorBrush, and that's ok. + var solidColorBrush = FindBrush(obj, out _); + SubscribeToBrushUpdates(obj, solidColorBrush); + } // Apply contrast correction ApplyContrastCheck(obj); } + /// + /// Finds the and its associated + /// from .. + /// + /// The attached . + /// + /// The associated with the + /// belonging to . + /// + /// The for . private static SolidColorBrush? FindBrush(DependencyObject d, out DependencyProperty? dp) { (SolidColorBrush? brush, dp) = d switch @@ -131,6 +187,7 @@ private static void AssignColor(DependencyObject d, Color color) break; } + // Unlock the original color updates _selfUpdate = false; } @@ -151,8 +208,24 @@ private static double CalculateWCAGContrastRatio(Color color1, Color color2) return (lighter + 0.05f) / (darker + 0.05f); } - private static double CalculatePerceivedLuminance(Color color) => - // Using the formula for perceived luminance - // Source WCAG guidelines: https://www.w3.org/TR/AERT/#color-contrast - (0.299f * color.R + 0.587f * color.G + 0.114f * color.B) / 255f; + private static double CalculatePerceivedLuminance(Color color) + { + // Color theory is a massive iceberg. Here's a peek at the tippy top: + + // There's two (main) standards for calculating luminance from RGB values. + // ITU Rec. 709: Y = 0.2126 R + 0.7152 G + 0.0722 B + // ITU Rec. 601: Y = 0.299 R + 0.587 G + 0.114 B + + // They're based on the standard ability of the human eye to perceive brightness, + // from different colors, as well as the average monitor's ability to produce them. + // Both standards produce similar results, but Rec. 709 is more accurate for modern displays. + + // NOTE: If we for whatrever reason we ever need to optimize this code, + // we can make approximations using integer math instead of floating point math. + // The precise values are not critical, as long as the relative luminance is accurate. + // Like so: return (2 * color.R + 7 * color.G + color.B); + + // TLDR: We use ITU Rec. 709 standard formula for perceived luminance. + return (0.2126f * color.R + 0.7152f * color.G + 0.0722 * color.B) / 255; + } } From 615d355271fa60aea2955f5db00bd41bb47687a1 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Thu, 9 Oct 2025 21:10:03 +0300 Subject: [PATCH 07/13] Added missing code to handle transparent base color as a sentinel value --- .../src/Contrast/ContrastHelper.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs index 4704a9793..8f4359c74 100644 --- a/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs @@ -44,16 +44,23 @@ private static void ApplyContrastCheck(DependencyObject d) if (brush is null) return; - // Find WCAG contrast ratio + // Retrieve colors to compare Color @base = GetOriginal(d); Color opponent = GetOpponent(d); - var ratio = CalculateWCAGContrastRatio(@base, opponent); - // Use original color if the contrast is in the acceptable range - if (ratio >= GetMinRatio(d)) + // Transparent is a sentinel value to say contrast ensurance should applied + // regardless of contrast ratio + if (@base != Colors.Transparent) { - AssignColor(d, @base); - return; + // Calculate the WCAG contrast ratio + var ratio = CalculateWCAGContrastRatio(@base, opponent); + + // Use original color if the contrast is in the acceptable range + if (ratio >= GetMinRatio(d)) + { + AssignColor(d, @base); + return; + } } // Current contrast is too small. From 9eb4fd862cc828608d609a58f848f32fc856205b Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Thu, 9 Oct 2025 22:58:47 +0300 Subject: [PATCH 08/13] Refactor ContrastHelper and added ContrastRatio and OriginalContrastRatio properties --- .../src/Contrast/ContrastHelper.Callbacks.cs | 126 +++++++++++++++++ .../src/Contrast/ContrastHelper.Properties.cs | 53 ++++++- .../src/Contrast/ContrastHelper.cs | 130 +++--------------- 3 files changed, 190 insertions(+), 119 deletions(-) create mode 100644 components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs new file mode 100644 index 000000000..de2edd488 --- /dev/null +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.WinUI.Helpers; + +public partial class ContrastHelper +{ + /// + /// Entry point upon the updating. + /// + private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + // Subscribe to brush updates if not already + if (GetCallbackObject(d) is null) + { + SubscribeToUpdates(d); + } + + // Update the actual color to ensure contrast + ApplyContrastCheck(d); + } + + /// + /// Entry point upon the updating. + /// + private static void OnMinRatioChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + // No opponent has been set, nothing to do + if (GetCallback(d) is 0) + return; + + // Update the actual color to ensure contrast + ApplyContrastCheck(d); + } + + /// + /// Entry point upon a subscribed , , + /// or updating. + /// + /// The the is attached to. + /// The the belongs to. + /// The property that updated. + private static void OnOriginalChangedFromSource(DependencyObject obj, DependencyObject sender, DependencyProperty prop) + { + // The contrast helper is updating the color + // Ignore the assignment. + if (_selfUpdate) + return; + + // Get the original color from the brush. + // We use the sender, not the obj, because the sender is the object that changed. + // Use Transparent as a sentinel value if the brush is not a SolidColorBrush + var brush = FindBrush(sender, out _); + var color = brush?.Color ?? Colors.Transparent; + + // Update original color + SetOriginalColor(obj, color); + + // The sender is the Foreground property, not the brush itself. + // This means the brush changed and our callback on the brush is dead. + // We need to subscribe to the new brush if it's a SolidColorBrush. + if (sender is not SolidColorBrush) + { + // Subscribe to the new brush + // Notice we're finding the brush on the object, not the sender this time. + // We may not find a SolidColorBrush, and that's ok. + var solidColorBrush = FindBrush(obj, out _); + SubscribeToBrushUpdates(obj, solidColorBrush); + } + + // Apply contrast correction + ApplyContrastCheck(obj); + } + + private static void SubscribeToUpdates(DependencyObject d) + { + // Get the original color from the brush and the property to monitor. + // Use Transparent as a sentinel value if the brush is not a SolidColorBrush + var solidColorBrush = FindBrush(d, out var dp); + var color = solidColorBrush?.Color ?? Colors.Transparent; + + // Record the original color + SetOriginalColor(d, color); + + // Rhetortical Question: Why don't we return if the solidColorBrush is null? + // Just because the brush is not a SolidColorBrush doesn't mean we can't monitor the + // Foreground property. We just can't monitor the brush's Color property + + // If the original is not a SolidColorBrush, we need to monitor the Foreground property + if (d is not SolidColorBrush) + { + // Subscribe to updates from the source Foreground + _ = d.RegisterPropertyChangedCallback(dp, (sender, prop) => + { + OnOriginalChangedFromSource(d, sender, prop); + }); + } + + // Subscribe to updates from the source SolidColorBrush + // If solidColorBrush is null, this is a no-op + SubscribeToBrushUpdates(d, solidColorBrush); + } + + private static void SubscribeToBrushUpdates(DependencyObject d, SolidColorBrush? brush) + { + // No brush, nothing to do + if (brush is null) + return; + + // Unsubscribe from previous brush if any + var oldBrush = GetCallbackObject(d); + var oldCallback = GetCallback(d); + oldBrush?.UnregisterPropertyChangedCallback(SolidColorBrush.ColorProperty, oldCallback); + + // Subscribe to updates from the source SolidColorBrush + var callback = brush.RegisterPropertyChangedCallback(SolidColorBrush.ColorProperty, (sender, prop) => + { + OnOriginalChangedFromSource(d, sender, prop); + }); + + // Track the callback so we don't double subscribe and can unsubscribe if needed + SetCallbackObject(d, brush); + SetCallback(d, callback); + } +} diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs index a43021d4b..c621abf3b 100644 --- a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Windows.Graphics.Printing; using Windows.UI; namespace CommunityToolkit.WinUI.Helpers; @@ -9,6 +10,8 @@ namespace CommunityToolkit.WinUI.Helpers; public partial class ContrastHelper { // TODO: Handle gradient brushes? + // TODO: Handle transparency values besides 0 or 1 + /// /// An attached property that defines the color to compare against. /// @@ -38,16 +41,38 @@ public partial class ContrastHelper typeof(ContrastHelper), new PropertyMetadata(21d, OnMinRatioChanged)); + /// + /// An attached property for binding to the calculated contrast ratio + /// compared to the actual foreground color. + /// + public static readonly DependencyProperty ContrastRatioProperty = + DependencyProperty.RegisterAttached( + "ContrastRatio", + typeof(double), + typeof(ContrastHelper), + new PropertyMetadata(0d)); + /// /// An attached property that records the original color before adjusting for contrast. /// public static readonly DependencyProperty OriginalColorProperty = DependencyProperty.RegisterAttached( - "Original", + "OriginalColor", typeof(Color), typeof(ContrastHelper), new PropertyMetadata(Colors.Transparent)); + /// + /// An attached property for binding to the calculated contrast ratio + /// compared to the original color. + /// + public static readonly DependencyProperty OriginalContrastRatioProperty = + DependencyProperty.RegisterAttached( + "OriginalContrastRatio", + typeof(double), + typeof(ContrastHelper), + new PropertyMetadata(0d)); + // Tracks the SolidColorBrush we're monitoring for changes private static readonly DependencyProperty CallbackObjectProperty = DependencyProperty.RegisterAttached( @@ -65,32 +90,46 @@ public partial class ContrastHelper new PropertyMetadata(0L)); /// - /// Get the opponent color to compare against. + /// Gets the opponent color to compare against. /// /// The opponent color. public static Color GetOpponent(DependencyObject obj) => (Color)obj.GetValue(OpponentProperty); /// - /// Set the opponent color to compare against. + /// Sets the opponent color to compare against. /// public static void SetOpponent(DependencyObject obj, Color value) => obj.SetValue(OpponentProperty, value); /// - /// Get the minimum acceptable contrast ratio against the opponent color. + /// Gets the minimum acceptable contrast ratio against the opponent color. /// public static double GetMinRatio(DependencyObject obj) => (double)obj.GetValue(MinRatioProperty); /// - /// Set the minimum acceptable contrast ratio against the opponent color. + /// Sets the minimum acceptable contrast ratio against the opponent color. /// public static void SetMinRatio(DependencyObject obj, double value) => obj.SetValue(MinRatioProperty, value); + /// + /// Gets the calculated contrast ratio compared to the actual foreground color. + /// + public static double GetContrastRatio(DependencyObject obj) => (double)obj.GetValue(ContrastRatioProperty); + + private static void SetContrastRatio(DependencyObject obj, double value) => obj.SetValue(ContrastRatioProperty, value); + + /// + /// Gets the calculated contrast ratio compared to the original foreground color. + /// + public static double GetOriginalContrastRatio(DependencyObject obj) => (double)obj.GetValue(OriginalContrastRatioProperty); + + private static void SetOriginalContrastRatio(DependencyObject obj, double value) => obj.SetValue(OriginalContrastRatioProperty, value); + /// /// Gets the original color before adjustment for contrast. /// - public static Color GetOriginal(DependencyObject obj) => (Color)obj.GetValue(OriginalColorProperty); + public static Color GetOriginalColor(DependencyObject obj) => (Color)obj.GetValue(OriginalColorProperty); - private static void SetOriginal(DependencyObject obj, Color color) => obj.SetValue(OriginalColorProperty, color); + private static void SetOriginalColor(DependencyObject obj, Color color) => obj.SetValue(OriginalColorProperty, color); private static DependencyObject GetCallbackObject(DependencyObject obj) => (DependencyObject)obj.GetValue(CallbackObjectProperty); diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs index 8f4359c74..323b32c98 100644 --- a/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.cs @@ -15,28 +15,6 @@ public partial class ContrastHelper // It has no threading issues since all updates are on the UI thread private static bool _selfUpdate = false; - private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - // Subscribe to brush updates if not already - if (GetCallbackObject(d) is null) - { - SubscribeToUpdates(d); - } - - // Update the actual color to ensure contrast - ApplyContrastCheck(d); - } - - private static void OnMinRatioChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - // No opponent has been set, nothing to do - if (GetCallback(d) is 0) - return; - - // Update the actual color to ensure contrast - ApplyContrastCheck(d); - } - private static void ApplyContrastCheck(DependencyObject d) { // Grab brush to update @@ -45,7 +23,7 @@ private static void ApplyContrastCheck(DependencyObject d) return; // Retrieve colors to compare - Color @base = GetOriginal(d); + Color @base = GetOriginalColor(d); Color opponent = GetOpponent(d); // Transparent is a sentinel value to say contrast ensurance should applied @@ -54,11 +32,12 @@ private static void ApplyContrastCheck(DependencyObject d) { // Calculate the WCAG contrast ratio var ratio = CalculateWCAGContrastRatio(@base, opponent); + SetOriginalContrastRatio(d, ratio); // Use original color if the contrast is in the acceptable range if (ratio >= GetMinRatio(d)) { - AssignColor(d, @base); + UpdateContrastedProperties(d, @base); return; } } @@ -67,90 +46,7 @@ private static void ApplyContrastCheck(DependencyObject d) // Select either black or white backed on the opponent luminance var luminance = CalculatePerceivedLuminance(opponent); var contrastingColor = luminance < 0.5f ? Colors.White : Colors.Black; - AssignColor(d, contrastingColor); - } - - private static void SubscribeToUpdates(DependencyObject d) - { - // Get the original color from the brush and the property to monitor. - // Use Transparent as a sentinel value if the brush is not a SolidColorBrush - var solidColorBrush = FindBrush(d, out var dp); - var color = solidColorBrush?.Color ?? Colors.Transparent; - - // Record the original color - SetOriginal(d, color); - - // Rhetortical Question: Why don't we return if the solidColorBrush is null? - // Just because the brush is not a SolidColorBrush doesn't mean we can't monitor the - // Foreground property. We just can't monitor the brush's Color property - - // If the original is not a SolidColorBrush, we need to monitor the Foreground property - if (d is not SolidColorBrush) - { - // Subscribe to updates from the source Foreground - _ = d.RegisterPropertyChangedCallback(dp, (sender, prop) => - { - OnOriginalChangedFromSource(d, sender, prop); - }); - } - - // Subscribe to updates from the source SolidColorBrush - // If solidColorBrush is null, this is a no-op - SubscribeToBrushUpdates(d, solidColorBrush); - } - - private static void SubscribeToBrushUpdates(DependencyObject d, SolidColorBrush? brush) - { - // No brush, nothing to do - if (brush is null) - return; - - // Unsubscribe from previous brush if any - var oldBrush = GetCallbackObject(d); - var oldCallback = GetCallback(d); - oldBrush?.UnregisterPropertyChangedCallback(SolidColorBrush.ColorProperty, oldCallback); - - // Subscribe to updates from the source SolidColorBrush - var callback = brush.RegisterPropertyChangedCallback(SolidColorBrush.ColorProperty, (sender, prop) => - { - OnOriginalChangedFromSource(d, sender, prop); - }); - - // Track the callback so we don't double subscribe and can unsubscribe if needed - SetCallbackObject(d, brush); - SetCallback(d, callback); - } - - private static void OnOriginalChangedFromSource(DependencyObject obj, DependencyObject sender, DependencyProperty prop) - { - // The contrast helper is updating the color - // Ignore the assignment. - if (_selfUpdate) - return; - - // Get the original color from the brush. - // We use the sender, not the obj, because the sender is the object that changed. - // Use Transparent as a sentinel value if the brush is not a SolidColorBrush - var brush = FindBrush(sender, out _); - var color = brush?.Color ?? Colors.Transparent; - - // Update original color - SetOriginal(obj, color); - - // The sender is the Foreground property, not the brush itself. - // This means the brush changed and our callback on the brush is dead. - // We need to subscribe to the new brush if it's a SolidColorBrush. - if (sender is not SolidColorBrush) - { - // Subscribe to the new brush - // Notice we're finding the brush on the object, not the sender this time. - // We may not find a SolidColorBrush, and that's ok. - var solidColorBrush = FindBrush(obj, out _); - SubscribeToBrushUpdates(obj, solidColorBrush); - } - - // Apply contrast correction - ApplyContrastCheck(obj); + UpdateContrastedProperties(d, contrastingColor); } /// @@ -176,7 +72,7 @@ private static void OnOriginalChangedFromSource(DependencyObject obj, Dependency return brush; } - private static void AssignColor(DependencyObject d, Color color) + private static void UpdateContrastedProperties(DependencyObject d, Color color) { // Block the original color from updating _selfUpdate = true; @@ -194,6 +90,10 @@ private static void AssignColor(DependencyObject d, Color color) break; } + // Calculate the actual ratio, between the opponent and the actual color + var actualRatio = CalculateWCAGContrastRatio(color, GetOpponent(d)); + SetContrastRatio(d, actualRatio); + // Unlock the original color updates _selfUpdate = false; } @@ -220,14 +120,20 @@ private static double CalculatePerceivedLuminance(Color color) // Color theory is a massive iceberg. Here's a peek at the tippy top: // There's two (main) standards for calculating luminance from RGB values. - // ITU Rec. 709: Y = 0.2126 R + 0.7152 G + 0.0722 B - // ITU Rec. 601: Y = 0.299 R + 0.587 G + 0.114 B + + // + ------------- + ------------------------------------ + ------------------ + ------------------------------------------------------------------------------- + + // | Standard | Formula | Ref. Section | Ref. Link | + // + ------------- + ------------------------------------ + ------------------ + ------------------------------------------------------------------------------- + + // | ITU Rec. 709 | Y = 0.2126 R + 0.7152 G + 0.0722 B | Page 4/Item 3.2 | https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf | + // + ------------- + ------------------------------------ + ------------------ + ------------------------------------------------------------------------------- + + // | ITU Rec. 601 | Y = 0.299 R + 0.587 G + 0.114 B | Page 2/Item 2.5.1 | https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.601-7-201103-I!!PDF-E.pdf | + // + ------------- + ------------------------------------ + ------------------ + ------------------------------------------------------------------------------- + // They're based on the standard ability of the human eye to perceive brightness, // from different colors, as well as the average monitor's ability to produce them. // Both standards produce similar results, but Rec. 709 is more accurate for modern displays. - // NOTE: If we for whatrever reason we ever need to optimize this code, + // NOTE: If we for whatever reason we ever need to optimize this code, // we can make approximations using integer math instead of floating point math. // The precise values are not critical, as long as the relative luminance is accurate. // Like so: return (2 * color.R + 7 * color.G + color.B); From a04eca4fd987e51c95e27a84231f867ab7d5626e Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Thu, 9 Oct 2025 23:01:02 +0300 Subject: [PATCH 09/13] Changes default image on AccentAnalyzerSample to flowers, from the removed image bloom --- .../samples/AccentAnalyzerSample.xaml | 2 +- .../samples/ContrastHelperSample.xaml | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml index b1d6dbd13..3d4f0119b 100644 --- a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml +++ b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml @@ -25,7 +25,7 @@ VerticalAlignment="Center"> diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml index be15a270b..d445bf1be 100644 --- a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml @@ -25,7 +25,7 @@ - + @@ -34,23 +34,30 @@ FontSize="24" Text="Legible text (MinRatio: 5)"> - + - + + + + + From 103ed361bd88ff81e3f665da525824c1bd021182 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Fri, 10 Oct 2025 01:41:12 +0300 Subject: [PATCH 10/13] Improved ContrastHelper samples --- .../samples/ContrastHelperSample.xaml | 97 ++++++------------- .../samples/ContrastHelperSample.xaml.cs | 30 ++++++ .../samples/ContrastOptionsPane.xaml | 23 +++++ .../samples/ContrastOptionsPane.xaml.cs | 59 +++++++++++ .../ColorAnalyzer/samples/Dependencies.props | 2 + .../src/Contrast/ContrastHelper.Callbacks.cs | 4 + 6 files changed, 145 insertions(+), 70 deletions(-) create mode 100644 components/ColorAnalyzer/samples/ContrastOptionsPane.xaml create mode 100644 components/ColorAnalyzer/samples/ContrastOptionsPane.xaml.cs diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml index d445bf1be..e13d51e5e 100644 --- a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml @@ -9,74 +9,31 @@ xmlns:local="using:ColorAnalyzerExperiment.Samples" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs index dc2fcc34a..e65cf3286 100644 --- a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.UI; +using Windows.UI; + namespace ColorAnalyzerExperiment.Samples; /// @@ -10,6 +13,33 @@ namespace ColorAnalyzerExperiment.Samples; [ToolkitSample(id: nameof(ContrastHelperSample), "ContrastAnalyzer helper", description: $"A sample for showing how the contrast analyzer can be used.")] public sealed partial class ContrastHelperSample : Page { + public static readonly DependencyProperty DesiredBackgroundProperty = + DependencyProperty.Register(nameof(DesiredBackground), typeof(Color), typeof(ContrastHelperSample), new PropertyMetadata(Colors.Black)); + + public static readonly DependencyProperty DesiredForegroundProperty = + DependencyProperty.Register(nameof(DesiredForeground), typeof(Color), typeof(ContrastHelperSample), new PropertyMetadata(Colors.White)); + + private static readonly DependencyProperty MinRatioProperty = + DependencyProperty.Register(nameof(MinRatio), typeof(double), typeof(ContrastHelperSample), new PropertyMetadata(0d)); + + public Color DesiredBackground + { + get => (Color)GetValue(DesiredBackgroundProperty); + set => SetValue(DesiredBackgroundProperty, value); + } + + public Color DesiredForeground + { + get => (Color)GetValue(DesiredForegroundProperty); + set => SetValue(DesiredForegroundProperty, value); + } + + public double MinRatio + { + get => (double)GetValue(MinRatioProperty); + set => SetValue(MinRatioProperty, value); + } + public ContrastHelperSample() { this.InitializeComponent(); diff --git a/components/ColorAnalyzer/samples/ContrastOptionsPane.xaml b/components/ColorAnalyzer/samples/ContrastOptionsPane.xaml new file mode 100644 index 000000000..4c760d89a --- /dev/null +++ b/components/ColorAnalyzer/samples/ContrastOptionsPane.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/components/ColorAnalyzer/samples/ContrastOptionsPane.xaml.cs b/components/ColorAnalyzer/samples/ContrastOptionsPane.xaml.cs new file mode 100644 index 000000000..4ba613fcb --- /dev/null +++ b/components/ColorAnalyzer/samples/ContrastOptionsPane.xaml.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if !WINDOWS_UWP +using Microsoft.UI.Xaml.Media.Imaging; +#elif WINDOWS_UWP +using Windows.UI.Xaml.Media.Imaging; +#endif + +namespace ColorAnalyzerExperiment.Samples; + +[ToolkitSampleOptionsPane(nameof(ContrastHelperSample))] +public partial class ContrastOptionsPane : UserControl +{ + private ContrastHelperSample _sample; + private ContrastHelperSample.XamlNamedPropertyRelay _sampleXamlRelay; + + public ContrastOptionsPane(ContrastHelperSample sample) + { + _sample = sample; + _sampleXamlRelay = new ContrastHelperSample.XamlNamedPropertyRelay(sample); + + this.InitializeComponent(); + } + + private void Foreground_ColorChanged(MUXC.ColorPicker sender, MUXC.ColorChangedEventArgs args) + { + // TODO: Disect the colorpicker + if (args.NewColor.A != 255) + return; + + _sample.DesiredForeground = args.NewColor; + } + + private void Background_ColorChanged(MUXC.ColorPicker sender, MUXC.ColorChangedEventArgs args) + { + // TODO: Disect the colorpicker + if (args.NewColor.A != 255) + return; + + _sample.DesiredBackground = args.NewColor; + } + + private void Ratio_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + _sample.MinRatio = (double)e.NewValue; + } + + private void FontSize_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + _sampleXamlRelay.TextSample.FontSize = (double)e.NewValue; + } + + private void Thickness_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + _sampleXamlRelay.ShapeSample.StrokeThickness = (double)e.NewValue; + } +} diff --git a/components/ColorAnalyzer/samples/Dependencies.props b/components/ColorAnalyzer/samples/Dependencies.props index 9c82a5c18..4f3496721 100644 --- a/components/ColorAnalyzer/samples/Dependencies.props +++ b/components/ColorAnalyzer/samples/Dependencies.props @@ -13,10 +13,12 @@ + + diff --git a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs index de2edd488..ee194a19d 100644 --- a/components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs +++ b/components/ColorAnalyzer/src/Contrast/ContrastHelper.Callbacks.cs @@ -2,6 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#if WINUI2 +using Windows.UI; +#endif + namespace CommunityToolkit.WinUI.Helpers; public partial class ContrastHelper From bb3c1c68465951a0ac4f8ece8ed8ecc1ea06723c Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Fri, 10 Oct 2025 01:41:40 +0300 Subject: [PATCH 11/13] Applied XAML styles --- .../samples/ContrastHelperSample.xaml | 8 ++-- .../samples/ContrastOptionsPane.xaml | 38 ++++++++++++++----- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml index e13d51e5e..6da841f01 100644 --- a/components/ColorAnalyzer/samples/ContrastHelperSample.xaml +++ b/components/ColorAnalyzer/samples/ContrastHelperSample.xaml @@ -1,4 +1,4 @@ - + - - - - - - - + + - + + - + - + + + From 7d20d6ac41eded8d40cedcbc2f162ba4a110b53d Mon Sep 17 00:00:00 2001 From: Arlo Date: Thu, 9 Oct 2025 21:28:40 -0500 Subject: [PATCH 12/13] Update components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml --- components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml | 1 + 1 file changed, 1 insertion(+) diff --git a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml index 3d4f0119b..65c99156a 100644 --- a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml +++ b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml @@ -76,6 +76,7 @@ + From 2afc22039384dd848e9623e56ba97fe23ba6cd51 Mon Sep 17 00:00:00 2001 From: Adam Dernis Date: Fri, 10 Oct 2025 05:37:23 +0300 Subject: [PATCH 13/13] Run xaml styler --- components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml index 65c99156a..05628bc19 100644 --- a/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml +++ b/components/ColorAnalyzer/samples/AccentAnalyzerSample.xaml @@ -1,4 +1,4 @@ - + - +