Skip to content

Commit 8cb46a0

Browse files
committed
Fixed callback subscription edge cases
1 parent 6cbdcb5 commit 8cb46a0

File tree

2 files changed

+111
-19
lines changed

2 files changed

+111
-19
lines changed

components/ColorAnalyzer/src/Contrast/ContrastHelper.Properties.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@ namespace CommunityToolkit.WinUI.Helpers;
88

99
public partial class ContrastHelper
1010
{
11+
// TODO: Handle gradient brushes?
1112
/// <summary>
1213
/// An attached property that defines the color to compare against.
1314
/// </summary>
15+
/// <remarks>
16+
/// This property can be attached to any <see cref="TextBlock"/> or <see cref="Control"/>
17+
/// to update their <see cref="TextBlock.Foreground"/> or <see cref="Control.Foreground"/>.
18+
/// If the original Foreground is not a <see cref="SolidColorBrush"/>, it will always apply contrast.
19+
/// It can also be attached to any <see cref="SolidColorBrush"/> to update the <see cref="SolidColorBrush.Color"/>.
20+
/// </remarks>
1421
public static readonly DependencyProperty OpponentProperty =
1522
DependencyProperty.RegisterAttached(
1623
"Opponent",
@@ -41,7 +48,15 @@ public partial class ContrastHelper
4148
typeof(ContrastHelper),
4249
new PropertyMetadata(Colors.Transparent));
4350

44-
// Tracks the callback on the original brush being updated.
51+
// Tracks the SolidColorBrush we're monitoring for changes
52+
private static readonly DependencyProperty CallbackObjectProperty =
53+
DependencyProperty.RegisterAttached(
54+
"CallbackObject",
55+
typeof(DependencyObject),
56+
typeof(ContrastHelper),
57+
new PropertyMetadata(null));
58+
59+
// Tracks the callback token from the SolidColorBrush we are monitoring
4560
private static readonly DependencyProperty CallbackProperty =
4661
DependencyProperty.RegisterAttached(
4762
"Callback",
@@ -77,6 +92,10 @@ public partial class ContrastHelper
7792

7893
private static void SetOriginal(DependencyObject obj, Color color) => obj.SetValue(OriginalColorProperty, color);
7994

95+
private static DependencyObject GetCallbackObject(DependencyObject obj) => (DependencyObject)obj.GetValue(CallbackObjectProperty);
96+
97+
private static void SetCallbackObject(DependencyObject obj, DependencyObject dp) => obj.SetValue(CallbackObjectProperty, dp);
98+
8099
private static long GetCallback(DependencyObject obj) => (long)obj.GetValue(CallbackProperty);
81100

82101
private static void SetCallback(DependencyObject obj, long value) => obj.SetValue(CallbackProperty, value);

components/ColorAnalyzer/src/Contrast/ContrastHelper.cs

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,29 @@ namespace CommunityToolkit.WinUI.Helpers;
1111
/// </summary>
1212
public partial class ContrastHelper
1313
{
14+
// When the helper is updating the color, this flag is set to avoid feedback loops
15+
// It has no threading issues since all updates are on the UI thread
1416
private static bool _selfUpdate = false;
1517

1618
private static void OnOpponentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
1719
{
18-
// Subscribe to brush updates
19-
if (GetCallback(d) is 0)
20+
// Subscribe to brush updates if not already
21+
if (GetCallbackObject(d) is null)
2022
{
2123
SubscribeToUpdates(d);
2224
}
2325

26+
// Update the actual color to ensure contrast
2427
ApplyContrastCheck(d);
2528
}
2629

2730
private static void OnMinRatioChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
2831
{
32+
// No opponent has been set, nothing to do
33+
if (GetCallback(d) is 0)
34+
return;
35+
36+
// Update the actual color to ensure contrast
2937
ApplyContrastCheck(d);
3038
}
3139

@@ -57,49 +65,97 @@ private static void ApplyContrastCheck(DependencyObject d)
5765

5866
private static void SubscribeToUpdates(DependencyObject d)
5967
{
60-
var brush = FindBrush(d, out var dp);
61-
if (brush is null)
62-
return;
68+
// Get the original color from the brush and the property to monitor.
69+
// Use Transparent as a sentinel value if the brush is not a SolidColorBrush
70+
var solidColorBrush = FindBrush(d, out var dp);
71+
var color = solidColorBrush?.Color ?? Colors.Transparent;
72+
73+
// Record the original color
74+
SetOriginal(d, color);
6375

64-
// Apply initial update
65-
SetOriginal(d, brush.Color);
76+
// Rhetortical Question: Why don't we return if the solidColorBrush is null?
77+
// Just because the brush is not a SolidColorBrush doesn't mean we can't monitor the
78+
// Foreground property. We just can't monitor the brush's Color property
6679

80+
// If the original is not a SolidColorBrush, we need to monitor the Foreground property
6781
if (d is not SolidColorBrush)
6882
{
69-
// Subscribe to updates from the source Foreground and Brush
70-
d.RegisterPropertyChangedCallback(dp, (sender, prop) =>
83+
// Subscribe to updates from the source Foreground
84+
_ = d.RegisterPropertyChangedCallback(dp, (sender, prop) =>
7185
{
7286
OnOriginalChangedFromSource(d, sender, prop);
7387
});
7488
}
7589

90+
// Subscribe to updates from the source SolidColorBrush
91+
// If solidColorBrush is null, this is a no-op
92+
SubscribeToBrushUpdates(d, solidColorBrush);
93+
}
94+
95+
private static void SubscribeToBrushUpdates(DependencyObject d, SolidColorBrush? brush)
96+
{
97+
// No brush, nothing to do
98+
if (brush is null)
99+
return;
100+
101+
// Unsubscribe from previous brush if any
102+
var oldBrush = GetCallbackObject(d);
103+
var oldCallback = GetCallback(d);
104+
oldBrush?.UnregisterPropertyChangedCallback(SolidColorBrush.ColorProperty, oldCallback);
105+
106+
// Subscribe to updates from the source SolidColorBrush
76107
var callback = brush.RegisterPropertyChangedCallback(SolidColorBrush.ColorProperty, (sender, prop) =>
77108
{
78109
OnOriginalChangedFromSource(d, sender, prop);
79110
});
80111

112+
// Track the callback so we don't double subscribe and can unsubscribe if needed
113+
SetCallbackObject(d, brush);
81114
SetCallback(d, callback);
82115
}
83116

84117
private static void OnOriginalChangedFromSource(DependencyObject obj, DependencyObject sender, DependencyProperty prop)
85118
{
86-
// The contrast helper is updating the color.
119+
// The contrast helper is updating the color
87120
// Ignore the assignment.
88121
if (_selfUpdate)
89122
return;
90123

91-
// Get brush
124+
// Get the original color from the brush.
125+
// We use the sender, not the obj, because the sender is the object that changed.
126+
// Use Transparent as a sentinel value if the brush is not a SolidColorBrush
92127
var brush = FindBrush(sender, out _);
93-
if (brush is null)
94-
return;
128+
var color = brush?.Color ?? Colors.Transparent;
95129

96130
// Update original color
97-
SetOriginal(obj, brush.Color);
131+
SetOriginal(obj, color);
132+
133+
// The sender is the Foreground property, not the brush itself.
134+
// This means the brush changed and our callback on the brush is dead.
135+
// We need to subscribe to the new brush if it's a SolidColorBrush.
136+
if (sender is not SolidColorBrush)
137+
{
138+
// Subscribe to the new brush
139+
// Notice we're finding the brush on the object, not the sender this time.
140+
// We may not find a SolidColorBrush, and that's ok.
141+
var solidColorBrush = FindBrush(obj, out _);
142+
SubscribeToBrushUpdates(obj, solidColorBrush);
143+
}
98144

99145
// Apply contrast correction
100146
ApplyContrastCheck(obj);
101147
}
102148

149+
/// <summary>
150+
/// Finds the <see cref="SolidColorBrush"/> and its associated <see cref="DependencyProperty"/>
151+
/// from <paramref name="d"/>..
152+
/// </summary>
153+
/// <param name="d">The attached <see cref="DependencyObject"/>.</param>
154+
/// <param name="dp">
155+
/// The <see cref="DependencyProperty"/> associated with the <see cref="SolidColorBrush"/>
156+
/// belonging to <paramref name="d"/>.
157+
/// </param>
158+
/// <returns>The <see cref="SolidColorBrush"/> for <paramref name="d"/>.</returns>
103159
private static SolidColorBrush? FindBrush(DependencyObject d, out DependencyProperty? dp)
104160
{
105161
(SolidColorBrush? brush, dp) = d switch
@@ -131,6 +187,7 @@ private static void AssignColor(DependencyObject d, Color color)
131187
break;
132188
}
133189

190+
// Unlock the original color updates
134191
_selfUpdate = false;
135192
}
136193

@@ -151,8 +208,24 @@ private static double CalculateWCAGContrastRatio(Color color1, Color color2)
151208
return (lighter + 0.05f) / (darker + 0.05f);
152209
}
153210

154-
private static double CalculatePerceivedLuminance(Color color) =>
155-
// Using the formula for perceived luminance
156-
// Source WCAG guidelines: https://www.w3.org/TR/AERT/#color-contrast
157-
(0.299f * color.R + 0.587f * color.G + 0.114f * color.B) / 255f;
211+
private static double CalculatePerceivedLuminance(Color color)
212+
{
213+
// Color theory is a massive iceberg. Here's a peek at the tippy top:
214+
215+
// There's two (main) standards for calculating luminance from RGB values.
216+
// ITU Rec. 709: Y = 0.2126 R + 0.7152 G + 0.0722 B
217+
// ITU Rec. 601: Y = 0.299 R + 0.587 G + 0.114 B
218+
219+
// They're based on the standard ability of the human eye to perceive brightness,
220+
// from different colors, as well as the average monitor's ability to produce them.
221+
// Both standards produce similar results, but Rec. 709 is more accurate for modern displays.
222+
223+
// NOTE: If we for whatrever reason we ever need to optimize this code,
224+
// we can make approximations using integer math instead of floating point math.
225+
// The precise values are not critical, as long as the relative luminance is accurate.
226+
// Like so: return (2 * color.R + 7 * color.G + color.B);
227+
228+
// TLDR: We use ITU Rec. 709 standard formula for perceived luminance.
229+
return (0.2126f * color.R + 0.7152f * color.G + 0.0722 * color.B) / 255;
230+
}
158231
}

0 commit comments

Comments
 (0)