A header only C++20 library. One seed colour gives a complete Material 3 style scheme, light or dark, with the tones, roles and contrast guarantees of the baseline scheme, computed in OKLCH as a light weight stand in for HCT. The seed colour and the light or dark preference come from a chain of sources: an environment override, the XDG desktop portal (GNOME 47+ and KDE Plasma 6), kdeglobals on KDE, gsettings on GNOME, GTK settings, and a fallback that is always appended and cannot be left out. Desktop specific sources are only asked on their own desktop, so a stale gsettings value on KDE never wins. Programs can put sources of their own in front. The tests run without a desktop through a fake Context. They cover colour conversions, tone accuracy, the contrast of every role pair over 300 random seeds in both modes, every parser against real portal output, the order of the sources per desktop, the fallback and external program timeouts. The test suite was checked by breaking the code 22 ways, all are caught. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
173 lines
5.5 KiB
C++
173 lines
5.5 KiB
C++
#pragma once
|
|
|
|
// Colour spaces and tone solving. No dependencies beyond the standard library.
|
|
//
|
|
// Colours are handled in OKLab/OKLCH (Björn Ottosson, 2020) and a "tone" is the
|
|
// CIE L* of the result, like Material 3's HCT tone. This is a light weight
|
|
// stand in for HCT: it keeps the idea (a hue, a chroma and a tone that really
|
|
// is perceptual lightness, so the same tone gives the same contrast whatever
|
|
// the hue) without the CAM16 machinery. It is not bit for bit what Material's
|
|
// own libraries produce.
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <string>
|
|
|
|
namespace monet {
|
|
|
|
struct Rgb {
|
|
std::uint8_t r = 0;
|
|
std::uint8_t g = 0;
|
|
std::uint8_t b = 0;
|
|
friend bool operator==(const Rgb &, const Rgb &) = default;
|
|
};
|
|
|
|
struct Oklch {
|
|
double L = 0; // 0..1
|
|
double C = 0; // chroma, 0 for greys, about 0.37 at most in sRGB
|
|
double h = 0; // hue in degrees, [0, 360)
|
|
};
|
|
|
|
namespace detail {
|
|
|
|
struct Linear {
|
|
double r, g, b;
|
|
};
|
|
|
|
constexpr double kPi = 3.14159265358979323846;
|
|
|
|
inline double toLinear(double c) {
|
|
return c <= 0.04045 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4);
|
|
}
|
|
inline double fromLinear(double c) {
|
|
c = std::clamp(c, 0.0, 1.0);
|
|
return c <= 0.0031308 ? c * 12.92 : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055;
|
|
}
|
|
|
|
inline Linear linearOf(Rgb c) {
|
|
return {toLinear(c.r / 255.0), toLinear(c.g / 255.0), toLinear(c.b / 255.0)};
|
|
}
|
|
|
|
inline Rgb rgbOf(Linear c) {
|
|
auto q = [](double v) {
|
|
return static_cast<std::uint8_t>(std::lround(std::clamp(fromLinear(v), 0.0, 1.0) * 255.0));
|
|
};
|
|
return {q(c.r), q(c.g), q(c.b)};
|
|
}
|
|
|
|
inline double luminanceOf(Linear c) { return 0.2126729 * c.r + 0.7151522 * c.g + 0.0721750 * c.b; }
|
|
|
|
inline Linear linearFromOklab(double L, double a, double b) {
|
|
const double l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
const double m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
const double s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
const double l = l_ * l_ * l_, m = m_ * m_ * m_, s = s_ * s_ * s_;
|
|
return {+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s};
|
|
}
|
|
|
|
inline Linear linearFromOklch(Oklch c) {
|
|
const double rad = c.h * kPi / 180.0;
|
|
return linearFromOklab(c.L, c.C * std::cos(rad), c.C * std::sin(rad));
|
|
}
|
|
|
|
inline bool inGamut(Linear c) {
|
|
constexpr double eps = 1e-5;
|
|
return c.r >= -eps && c.r <= 1 + eps && c.g >= -eps && c.g <= 1 + eps && c.b >= -eps &&
|
|
c.b <= 1 + eps;
|
|
}
|
|
|
|
// Lowers the chroma until the colour fits in sRGB, keeping lightness and hue.
|
|
inline Linear gamutMapped(double L, double C, double h) {
|
|
Linear full = linearFromOklch({L, C, h});
|
|
if (inGamut(full)) {
|
|
return full;
|
|
}
|
|
double lo = 0, hi = C;
|
|
for (int i = 0; i < 28; i++) {
|
|
const double mid = (lo + hi) / 2;
|
|
if (inGamut(linearFromOklch({L, mid, h}))) {
|
|
lo = mid;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
return linearFromOklch({L, lo, h});
|
|
}
|
|
|
|
} // namespace detail
|
|
|
|
inline Oklch toOklch(Rgb c) {
|
|
const auto lin = detail::linearOf(c);
|
|
const double l = std::cbrt(0.4122214708 * lin.r + 0.5363325363 * lin.g + 0.0514459929 * lin.b);
|
|
const double m = std::cbrt(0.2119034982 * lin.r + 0.6806995451 * lin.g + 0.1073969566 * lin.b);
|
|
const double s = std::cbrt(0.0883024619 * lin.r + 0.2817188376 * lin.g + 0.6299787005 * lin.b);
|
|
const double L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
|
|
const double a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
|
|
const double b = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
|
|
double h = std::atan2(b, a) * 180.0 / detail::kPi;
|
|
if (h < 0) {
|
|
h += 360.0;
|
|
}
|
|
return {L, std::hypot(a, b), h};
|
|
}
|
|
|
|
inline Rgb toRgb(Oklch c) { return detail::rgbOf(detail::gamutMapped(c.L, c.C, c.h)); }
|
|
|
|
// WCAG relative luminance, 0..1
|
|
inline double luminance(Rgb c) { return detail::luminanceOf(detail::linearOf(c)); }
|
|
|
|
// CIE L*, 0..100
|
|
inline double toneOf(Rgb c) {
|
|
const double y = luminance(c);
|
|
return y > 216.0 / 24389.0 ? 116.0 * std::cbrt(y) - 16.0 : y * 24389.0 / 27.0;
|
|
}
|
|
|
|
inline double luminanceOfTone(double tone) {
|
|
tone = std::clamp(tone, 0.0, 100.0);
|
|
return tone > 8.0 ? std::pow((tone + 16.0) / 116.0, 3.0) : tone * 27.0 / 24389.0;
|
|
}
|
|
|
|
// WCAG contrast ratio, 1..21
|
|
inline double contrast(Rgb a, Rgb b) {
|
|
const double la = luminance(a), lb = luminance(b);
|
|
return (std::max(la, lb) + 0.05) / (std::min(la, lb) + 0.05);
|
|
}
|
|
|
|
// The colour with the given hue, at most the given chroma, and CIE L* equal to
|
|
// `tone`. If the chroma is not available at that tone it is reduced, which is
|
|
// what makes the extremes (tone near 0 or 100) grey.
|
|
inline Rgb toneColor(double hue, double chroma, double tone) {
|
|
if (tone <= 0.0) {
|
|
return {0, 0, 0};
|
|
}
|
|
if (tone >= 100.0) {
|
|
return {255, 255, 255};
|
|
}
|
|
const double target = luminanceOfTone(tone);
|
|
double lo = 0.0, hi = 1.0;
|
|
for (int i = 0; i < 42; i++) {
|
|
const double mid = (lo + hi) / 2;
|
|
if (detail::luminanceOf(detail::gamutMapped(mid, chroma, hue)) < target) {
|
|
lo = mid;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
return detail::rgbOf(detail::gamutMapped((lo + hi) / 2, chroma, hue));
|
|
}
|
|
|
|
inline std::string hex(Rgb c) {
|
|
static const char *digits = "0123456789ABCDEF";
|
|
std::string out = "#";
|
|
for (std::uint8_t v : {c.r, c.g, c.b}) {
|
|
out += digits[v >> 4];
|
|
out += digits[v & 15];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace monet
|