feat(style): pick foreground colours by measured contrast
The gradient was turned down because text on the lit edge became hard to read, which fixed the symptom and lost the effect. The real problem is that a theme picks one text colour while the same text lands on a base surface, a lit edge and an accent fill, and a value that reads on one can vanish on another. White iconography on a pale violet accent was the reported case. So contrast is computed rather than assumed. ColourUtil gains WCAG relative luminance, the contrast ratio, and EnsureContrast, which walks a foreground away from its background until it clears a threshold -- 4.5:1 for text, 3:1 for icons -- and stops at the first step that does, so a colour keeps as much of its hue as the ratio allows. Luminance is gamma-corrected now. sRGB is gamma-encoded, so averaging raw bytes overstates dark colours badly, and nearly every surface here is dark. Mid grey is 0.216 relative luminance, not 0.5. The tests caught a real error in the first version: direction was chosen by whether the background measured below 0.5 luminance. Pale violet sits at 0.39, counts as dark by that rule, and the function tried to make white whiter. It now picks whichever end reaches further from the background. With foregrounds that follow, the gradient goes back up past where it was. Applied to the sidebar icons and labels and to the segmented control's labels. The remaining call sites are a polish pass of their own -- this is the machinery plus the two places that were reported.
This commit is contained in:
@@ -65,6 +65,7 @@ internal sealed class TabSidebar
|
||||
TextMuted = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.TextMuted, colors)),
|
||||
Accent = accent,
|
||||
Hover = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceHover, colors)),
|
||||
Surface = ColourUtil.RgbaToAbgr(_resolver.Resolve(Token.SurfaceBase, colors)),
|
||||
IconFont = iconFont,
|
||||
IconSize = iconSize,
|
||||
};
|
||||
@@ -84,6 +85,10 @@ internal sealed class TabSidebar
|
||||
public required uint TextMuted { get; init; }
|
||||
public required uint Accent { get; init; }
|
||||
public required uint Hover { get; init; }
|
||||
|
||||
// What the row is actually drawn on, which is what a foreground has to
|
||||
// clear -- not the theme's nominal background.
|
||||
public required uint Surface { get; init; }
|
||||
public required ImFontPtr IconFont { get; init; }
|
||||
public required float IconSize { get; init; }
|
||||
}
|
||||
@@ -134,12 +139,20 @@ internal sealed class TabSidebar
|
||||
|
||||
var contentColour = selected ? p.Text : ColourUtil.Lerp(p.TextMuted, p.Text, hoverAmount);
|
||||
|
||||
// The active row is filled with the accent, so the glyph on it has to
|
||||
// clear that fill rather than the pane behind it. White on a pale violet
|
||||
// accent measures 2.4:1, well under the 3:1 floor for icons, and that is
|
||||
// exactly the case that was reported as unreadable.
|
||||
var iconColour = selected
|
||||
? ColourUtil.EnsureContrast(p.Accent, p.Surface, 3f)
|
||||
: ColourUtil.EnsureContrast(contentColour, p.Surface, 3f);
|
||||
|
||||
var iconX = origin.X + 12f * scale;
|
||||
dl.AddText(
|
||||
p.IconFont,
|
||||
p.IconSize,
|
||||
new Vector2(iconX, origin.Y + MetricsMath.Center(height, p.IconSize)),
|
||||
selected ? p.Accent : contentColour,
|
||||
iconColour,
|
||||
icon.ToIconString()
|
||||
);
|
||||
|
||||
@@ -149,7 +162,7 @@ internal sealed class TabSidebar
|
||||
iconX + p.IconSize + 9f * scale,
|
||||
origin.Y + MetricsMath.Center(height, labelSize.Y)
|
||||
),
|
||||
contentColour,
|
||||
ColourUtil.EnsureContrast(contentColour, p.Surface, 4.5f),
|
||||
label
|
||||
);
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ internal sealed class SurfaceBackdrop
|
||||
// the full height is exactly the case that bands, because its handful of
|
||||
// alpha steps each cover twenty-odd pixels.
|
||||
var fade = MathF.Min(190f * Metrics.Scale, (max.Y - min.Y) * 0.45f);
|
||||
dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x10 * strength * opacity) << 24), fade);
|
||||
dl.DrawEdgeTint(min, max, (uint)(0x38 * strength * opacity) << 24, fade, fromBottom: true);
|
||||
dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x26 * strength * opacity) << 24), fade);
|
||||
dl.DrawEdgeTint(min, max, (uint)(0x44 * strength * opacity) << 24, fade, fromBottom: true);
|
||||
|
||||
if (darken > 0f)
|
||||
dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24);
|
||||
@@ -69,8 +69,8 @@ internal sealed class SurfaceBackdrop
|
||||
dl.AddRectFilledMultiColor(
|
||||
min,
|
||||
new Vector2(max.X, min.Y + (max.Y - min.Y) * accentWashHeight),
|
||||
ColourUtil.ApplyAlpha(accent, 0.045f * opacity),
|
||||
ColourUtil.ApplyAlpha(accent, 0.02f * opacity),
|
||||
ColourUtil.ApplyAlpha(accent, 0.075f * opacity),
|
||||
ColourUtil.ApplyAlpha(accent, 0.035f * opacity),
|
||||
accent & 0x00FFFFFFu,
|
||||
accent & 0x00FFFFFFu
|
||||
);
|
||||
|
||||
@@ -143,15 +143,17 @@ internal static class SegmentedControl
|
||||
MetricsMath.Center(height, textSize.Y)
|
||||
),
|
||||
ColourUtil.ApplyAlpha(
|
||||
// Measured against the surface each label actually sits on:
|
||||
// the accent fill for the selected one, the track for the
|
||||
// rest. A light/dark guess was not enough -- an accent can
|
||||
// be mid-luminance and still fail against both.
|
||||
isSelected
|
||||
// On the accent fill, so the readable colour depends on
|
||||
// how light that accent is in the active theme.
|
||||
? ColourUtil.OnColour(
|
||||
colors.SelectedAbgr,
|
||||
? ColourUtil.EnsureContrast(
|
||||
colors.SelectedLabelAbgr,
|
||||
colors.TrackAbgr
|
||||
colors.SelectedAbgr,
|
||||
4.5f
|
||||
)
|
||||
: colors.LabelAbgr,
|
||||
: ColourUtil.EnsureContrast(colors.LabelAbgr, colors.TrackAbgr, 4.5f),
|
||||
alpha
|
||||
),
|
||||
label
|
||||
|
||||
@@ -108,15 +108,73 @@ internal static class ColourUtil
|
||||
// going fully saturated (effect level stays "subtle"). RGB-only on
|
||||
// purpose -- DrawHoverSheen owns the alpha falloff.
|
||||
// TEST-MIRROR: ../../../Hellion Build test/Util/ColourUtilTintTests.cs
|
||||
// Relative luminance of an ABGR colour, 0..1, using the sRGB coefficients.
|
||||
// Alpha is ignored: this answers "is this surface light or dark", and a
|
||||
// translucent light surface still reads light against the window behind it.
|
||||
// Relative luminance per WCAG 2.1, 0..1. Channels are linearised first:
|
||||
// sRGB is gamma-encoded, so averaging the raw bytes overstates the
|
||||
// brightness of dark colours badly -- and almost every surface in this
|
||||
// plugin is a dark colour.
|
||||
//
|
||||
// Alpha is ignored. This answers "is this light or dark", and a translucent
|
||||
// light surface still reads light against what is behind it.
|
||||
internal static float Luminance(uint abgr)
|
||||
{
|
||||
var r = (abgr & 0xFFu) / 255f;
|
||||
var g = ((abgr >> 8) & 0xFFu) / 255f;
|
||||
var b = ((abgr >> 16) & 0xFFu) / 255f;
|
||||
return 0.2126f * r + 0.7152f * g + 0.0722f * b;
|
||||
static float Linear(uint channel)
|
||||
{
|
||||
var c = channel / 255f;
|
||||
return c <= 0.04045f ? c / 12.92f : MathF.Pow((c + 0.055f) / 1.055f, 2.4f);
|
||||
}
|
||||
|
||||
return 0.2126f * Linear(abgr & 0xFFu)
|
||||
+ 0.7152f * Linear((abgr >> 8) & 0xFFu)
|
||||
+ 0.0722f * Linear((abgr >> 16) & 0xFFu);
|
||||
}
|
||||
|
||||
// WCAG contrast ratio between two colours, 1.0 (identical) to 21.0 (black
|
||||
// on white). 4.5 is the readability floor for body text, 3.0 for large text
|
||||
// and for icons and other non-text marks.
|
||||
internal static float ContrastRatio(uint a, uint b)
|
||||
{
|
||||
var la = Luminance(a);
|
||||
var lb = Luminance(b);
|
||||
var (hi, lo) = la > lb ? (la, lb) : (lb, la);
|
||||
return (hi + 0.05f) / (lo + 0.05f);
|
||||
}
|
||||
|
||||
// Pushes a foreground away from its background until it clears the ratio,
|
||||
// moving whichever direction the background is not.
|
||||
//
|
||||
// This is what a fixed palette cannot do. A theme picks one text colour, but
|
||||
// the same text lands on a base surface, on a lit top edge and on an accent
|
||||
// fill, and a value that reads on one of those can vanish on another. White
|
||||
// on pale violet was the reported case.
|
||||
internal static uint EnsureContrast(uint foregroundAbgr, uint backgroundAbgr, float minRatio)
|
||||
{
|
||||
if (ContrastRatio(foregroundAbgr, backgroundAbgr) >= minRatio)
|
||||
return foregroundAbgr;
|
||||
|
||||
// Direction is chosen by which end actually reaches further, not by
|
||||
// whether the background counts as dark. A mid-luminance background can
|
||||
// sit below 0.5 and still be far too light for white text: pale violet
|
||||
// measures 0.39, so a "background is dark, brighten it" rule tried to
|
||||
// make white whiter and got nowhere.
|
||||
var towardWhite =
|
||||
ContrastRatio(0xFFFFFFFFu, backgroundAbgr) > ContrastRatio(0xFF000000u, backgroundAbgr);
|
||||
var best = foregroundAbgr;
|
||||
|
||||
// Sixteen steps to full white or full black. Stops at the first value
|
||||
// that clears, so a colour only travels as far as it has to and keeps
|
||||
// as much of its hue as the ratio allows.
|
||||
for (var i = 1; i <= 16; i++)
|
||||
{
|
||||
var t = i / 16f;
|
||||
best = towardWhite
|
||||
? LerpTowardWhite(foregroundAbgr, t)
|
||||
: LerpTowardBlack(foregroundAbgr, t);
|
||||
|
||||
if (ContrastRatio(best, backgroundAbgr) >= minRatio)
|
||||
return best;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// Picks whichever of two candidates stands further from the background.
|
||||
|
||||
Reference in New Issue
Block a user