The first sweep matched a character class that swallowed the digit, so a bare B1 slipped through while B1-2 was caught. Searching the whole A-Z space instead of guessing prefixes turned up 130-odd more: B0 through B6, C2, C3, D1, H2, M6, P7, P8, T2, W2, plus GP-04, KB-01, OD-1, PM-1, PM-3, SEC-01, TR-4, TR-7, UI-11, UI-12, XC-8 and API-3. Kept deliberately: 41 B4 01 is a byte signature, "N0" a format string, #L119-L128 a source anchor, LS4/LS6 are linkshells, and A=FF B=0C G=41 R=C2 explains a colour-channel order. Those look like codes and are not. Also translated the eight German comments left in the theme files and ImGuiUtil. Seven of them described what a palette does to which channel, which is worth reading -- just not in a second language in an otherwise English codebase.
255 lines
9.7 KiB
C#
Executable File
255 lines
9.7 KiB
C#
Executable File
using System.Buffers.Binary;
|
|
using System.Numerics;
|
|
|
|
namespace HellionChat.Util;
|
|
|
|
internal static class ColourUtil
|
|
{
|
|
private static (byte r, byte g, byte b) RgbaToRgbComponents(uint rgba)
|
|
{
|
|
var r = (byte)((rgba & 0xFF000000) >> 24);
|
|
var g = (byte)((rgba & 0xFF0000) >> 16);
|
|
var b = (byte)((rgba & 0xFF00) >> 8);
|
|
return (r, g, b);
|
|
}
|
|
|
|
internal static uint RgbaToAbgr(uint rgba) => BinaryPrimitives.ReverseEndianness(rgba);
|
|
|
|
internal static Vector3 RgbaToVector3(uint rgba)
|
|
{
|
|
var (r, g, b) = RgbaToRgbComponents(rgba);
|
|
return new Vector3((float)r / 255, (float)g / 255, (float)b / 255);
|
|
}
|
|
|
|
internal static uint Vector3ToRgba(Vector3 col)
|
|
{
|
|
return ComponentsToRgba(
|
|
(byte)Math.Round(col.X * 255),
|
|
(byte)Math.Round(col.Y * 255),
|
|
(byte)Math.Round(col.Z * 255)
|
|
);
|
|
}
|
|
|
|
internal static Vector4 RgbaToVector4(uint rgba)
|
|
{
|
|
var (r, g, b) = RgbaToRgbComponents(rgba);
|
|
var a = (byte)(rgba & 0xFFu);
|
|
return new Vector4(r / 255f, g / 255f, b / 255f, a / 255f);
|
|
}
|
|
|
|
internal static uint Vector4ToRgba(Vector4 col)
|
|
{
|
|
// Clamp guards against future ImGuiColorEditFlags.HDR feeding out-of-range
|
|
// components: a raw byte-cast would wrap (e.g. (byte)Math.Round(2.0f*255)=254).
|
|
// Mirrors the ApplyAlpha clamping pattern in this file.
|
|
var r = Math.Clamp(col.X, 0f, 1f);
|
|
var g = Math.Clamp(col.Y, 0f, 1f);
|
|
var b = Math.Clamp(col.Z, 0f, 1f);
|
|
var a = Math.Clamp(col.W, 0f, 1f);
|
|
return ComponentsToRgba(
|
|
(byte)Math.Round(r * 255),
|
|
(byte)Math.Round(g * 255),
|
|
(byte)Math.Round(b * 255),
|
|
(byte)Math.Round(a * 255)
|
|
);
|
|
}
|
|
|
|
internal static uint Vector4ToAbgr(Vector4 col)
|
|
{
|
|
return RgbaToAbgr(
|
|
ComponentsToRgba(
|
|
(byte)Math.Round(col.X * 255),
|
|
(byte)Math.Round(col.Y * 255),
|
|
(byte)Math.Round(col.Z * 255),
|
|
(byte)Math.Round(col.W * 255)
|
|
)
|
|
);
|
|
}
|
|
|
|
public static unsafe uint ArgbToRgba(uint x)
|
|
{
|
|
var buf = (byte*)&x;
|
|
(buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]);
|
|
return x;
|
|
}
|
|
|
|
internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF) =>
|
|
alpha | (uint)(red << 24) | (uint)(green << 16) | (uint)(blue << 8);
|
|
|
|
internal static uint AdjustBrightness(uint abgr, float factor)
|
|
{
|
|
var a = (byte)((abgr & 0xFF000000) >> 24);
|
|
var b = (byte)((abgr & 0x00FF0000) >> 16);
|
|
var g = (byte)((abgr & 0x0000FF00) >> 8);
|
|
var r = (byte)(abgr & 0x000000FF);
|
|
|
|
var nr = (byte)Math.Clamp(r * factor, 0f, 255f);
|
|
var ng = (byte)Math.Clamp(g * factor, 0f, 255f);
|
|
var nb = (byte)Math.Clamp(b * factor, 0f, 255f);
|
|
|
|
return ((uint)a << 24) | ((uint)nb << 16) | ((uint)ng << 8) | nr;
|
|
}
|
|
|
|
// Modulates the alpha byte of an ABGR color by a factor in [0, 1].
|
|
// RGB stays intact. Used by the hover-lerp path where each
|
|
// frame produces a fractional alpha value but the colour itself
|
|
// must not shift.
|
|
internal static uint ApplyAlpha(uint abgr, float alphaFactor)
|
|
{
|
|
alphaFactor = Math.Clamp(alphaFactor, 0f, 1f);
|
|
var origAlpha = (byte)((abgr >> 24) & 0xFFu);
|
|
var newAlpha = (byte)Math.Round(origAlpha * alphaFactor);
|
|
return (abgr & 0x00FFFFFFu) | ((uint)newAlpha << 24);
|
|
}
|
|
|
|
// Mixes an ABGR colour's RGB channels toward white (0xFF) by factor t in
|
|
// [0, 1]; the alpha byte is left untouched. Hover-sheen accent tint:
|
|
// a low factor nudges the sweep toward the element's accent hue without
|
|
// 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 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)
|
|
{
|
|
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.
|
|
// Themes range from near-black to pastel, so a fixed text colour on an accent
|
|
// fill is legible in some and invisible in others.
|
|
internal static uint OnColour(uint background, uint light, uint dark) =>
|
|
Luminance(background) > 0.5f ? dark : light;
|
|
|
|
// Mixes an ABGR colour's RGB channels toward black by factor t, alpha
|
|
// untouched. The counterpart to LerpTowardWhite, and the reason both exist
|
|
// rather than AdjustBrightness: this plugin's surfaces sit near black, where
|
|
// a multiplier has almost nothing to scale. 12 * 1.15 is still 13.
|
|
internal static uint LerpTowardBlack(uint abgr, float t)
|
|
{
|
|
t = Math.Clamp(t, 0f, 1f);
|
|
var a = (byte)((abgr >> 24) & 0xFFu);
|
|
var b = (byte)Math.Round(((abgr >> 16) & 0xFFu) * (1f - t));
|
|
var g = (byte)Math.Round(((abgr >> 8) & 0xFFu) * (1f - t));
|
|
var r = (byte)Math.Round((abgr & 0xFFu) * (1f - t));
|
|
return ((uint)a << 24) | ((uint)b << 16) | ((uint)g << 8) | r;
|
|
}
|
|
|
|
internal static uint LerpTowardWhite(uint abgr, float t)
|
|
{
|
|
t = Math.Clamp(t, 0f, 1f);
|
|
var a = (byte)((abgr >> 24) & 0xFFu);
|
|
var b = (byte)((abgr >> 16) & 0xFFu);
|
|
var g = (byte)((abgr >> 8) & 0xFFu);
|
|
var r = (byte)(abgr & 0xFFu);
|
|
|
|
var nr = (byte)Math.Round(r + (0xFF - r) * t);
|
|
var ng = (byte)Math.Round(g + (0xFF - g) * t);
|
|
var nb = (byte)Math.Round(b + (0xFF - b) * t);
|
|
|
|
return ((uint)a << 24) | ((uint)nb << 16) | ((uint)ng << 8) | nr;
|
|
}
|
|
|
|
// Mixes two ABGR colours channel by channel, alpha included. Used where a
|
|
// widget crossfades between two theme slots rather than toward a constant.
|
|
internal static uint Lerp(uint fromAbgr, uint toAbgr, float t)
|
|
{
|
|
t = Math.Clamp(t, 0f, 1f);
|
|
uint Mix(int shift)
|
|
{
|
|
var a = (byte)((fromAbgr >> shift) & 0xFFu);
|
|
var b = (byte)((toAbgr >> shift) & 0xFFu);
|
|
return (uint)Math.Round(a + (b - a) * t) & 0xFFu;
|
|
}
|
|
|
|
return (Mix(24) << 24) | (Mix(16) << 16) | (Mix(8) << 8) | Mix(0);
|
|
}
|
|
|
|
public static uint HexToRgba(string hex)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(hex);
|
|
var s = hex.StartsWith('#') ? hex[1..] : hex;
|
|
if (s.Length != 6 && s.Length != 8)
|
|
throw new FormatException(
|
|
$"Hex colour must be 6 or 8 hex digits, got {s.Length}: '{hex}'"
|
|
);
|
|
|
|
if (
|
|
!uint.TryParse(
|
|
s,
|
|
System.Globalization.NumberStyles.HexNumber,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out var value
|
|
)
|
|
)
|
|
throw new FormatException($"Hex colour '{hex}' is not a valid hexadecimal value");
|
|
|
|
if (s.Length == 6)
|
|
value = (value << 8) | 0xFFu; // RRGGBB → RRGGBBFF
|
|
|
|
return value;
|
|
}
|
|
}
|