Add libmonet: Material 3 style schemes and where to get the seed from
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>
This commit is contained in:
commit
b97a55ac75
9 files changed
+1358
No files matched your search
@@ -0,0 +1,492 @@
|
||||
#pragma once
|
||||
|
||||
// Where the seed colour and the light or dark preference come from.
|
||||
//
|
||||
// A Source answers whatever it knows. detect() asks the sources in order and
|
||||
// takes, for each of the two values, the first answer. The list always ends in a
|
||||
// fallback source, so there is always a usable answer: a machine with no
|
||||
// desktop, a failed query and an empty list of sources all end up there.
|
||||
//
|
||||
// The built in sources need no library:
|
||||
// override MONET_SEED and MONET_MODE in the environment
|
||||
// portal org.freedesktop.portal.Settings, read with busctl or gdbus. Both
|
||||
// GNOME (47 and later) and KDE Plasma 6 answer it, so this is the
|
||||
// one source that works across desktops.
|
||||
// kde ~/.config/kdeglobals
|
||||
// gnome gsettings
|
||||
// gtk GTK settings.ini, light or dark only
|
||||
// fallback fixed seed and mode, always answers
|
||||
// A program that already talks to D-Bus can add a source of its own in front.
|
||||
|
||||
#include <sys/wait.h>
|
||||
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <spawn.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "scheme.h"
|
||||
|
||||
extern char **environ;
|
||||
|
||||
namespace monet {
|
||||
|
||||
struct Appearance {
|
||||
std::optional<Rgb> accent;
|
||||
std::optional<Mode> mode;
|
||||
std::string accentSource; // filled in by detect(), for logs
|
||||
std::string modeSource;
|
||||
};
|
||||
|
||||
// What sources may look at. Replaceable so that tests, and programs with their
|
||||
// own D-Bus connection, do not have to go through the real system.
|
||||
struct Context {
|
||||
std::function<std::optional<std::string>(const std::string &)> getenv;
|
||||
// Runs a program and returns its standard output, nothing if it could not
|
||||
// be started, did not finish in time or failed.
|
||||
std::function<std::optional<std::string>(const std::vector<std::string> &, int)> run;
|
||||
std::filesystem::path home;
|
||||
std::filesystem::path configHome;
|
||||
|
||||
static Context system();
|
||||
};
|
||||
|
||||
struct Source {
|
||||
std::string name;
|
||||
std::function<Appearance(const Context &)> query;
|
||||
};
|
||||
|
||||
// What is used when no source has an answer: the look the tools had before they
|
||||
// followed the desktop, a pink accent and a dark scheme.
|
||||
inline constexpr Rgb kDefaultSeed{0xFF, 0xB0, 0xC9};
|
||||
inline constexpr Mode kDefaultMode = Mode::Dark;
|
||||
|
||||
// --- parsing, kept separate and exposed so each format can be tested on its own
|
||||
|
||||
namespace parse {
|
||||
|
||||
inline std::string lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
|
||||
inline std::string trim(const std::string &s) {
|
||||
size_t a = 0, b = s.size();
|
||||
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) a++;
|
||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) b--;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
// "#RRGGBB", "RRGGBB" or "#RGB"
|
||||
inline std::optional<Rgb> hexColor(const std::string &text) {
|
||||
std::string s = trim(text);
|
||||
if (!s.empty() && s[0] == '#') s.erase(0, 1);
|
||||
if (s.size() != 6 && s.size() != 3) return std::nullopt;
|
||||
for (char c : s) {
|
||||
if (!std::isxdigit(static_cast<unsigned char>(c))) return std::nullopt;
|
||||
}
|
||||
auto v = [&](size_t i, size_t n) { return static_cast<std::uint8_t>(std::stoi(s.substr(i, n), nullptr, 16)); };
|
||||
if (s.size() == 3) {
|
||||
return Rgb{static_cast<std::uint8_t>(v(0, 1) * 17), static_cast<std::uint8_t>(v(1, 1) * 17),
|
||||
static_cast<std::uint8_t>(v(2, 1) * 17)};
|
||||
}
|
||||
return Rgb{v(0, 2), v(2, 2), v(4, 2)};
|
||||
}
|
||||
|
||||
// KDE writes colours as "61,174,233"
|
||||
inline std::optional<Rgb> kdeRgb(const std::string &text) {
|
||||
int v[3];
|
||||
size_t pos = 0;
|
||||
const std::string s = trim(text);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
size_t used = 0;
|
||||
try {
|
||||
v[i] = std::stoi(s.substr(pos), &used);
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (v[i] < 0 || v[i] > 255) return std::nullopt;
|
||||
pos += used;
|
||||
if (i < 2) {
|
||||
if (pos >= s.size() || s[pos] != ',') return std::nullopt;
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
if (pos != s.size()) return std::nullopt;
|
||||
return Rgb{static_cast<std::uint8_t>(v[0]), static_cast<std::uint8_t>(v[1]), static_cast<std::uint8_t>(v[2])};
|
||||
}
|
||||
|
||||
// gsettings prints strings quoted: 'prefer-dark'
|
||||
inline std::string gsettingsString(const std::string &text) {
|
||||
std::string s = trim(text);
|
||||
if (s.size() >= 2 && (s.front() == '\'' || s.front() == '"') && s.back() == s.front()) {
|
||||
s = s.substr(1, s.size() - 2);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// libadwaita's accent colours, the names GNOME 47 and later use for accent-color
|
||||
inline std::optional<Rgb> gnomeAccent(const std::string &name) {
|
||||
static const std::pair<const char *, Rgb> table[] = {
|
||||
{"blue", {0x35, 0x84, 0xe4}}, {"teal", {0x21, 0x90, 0xa4}}, {"green", {0x3a, 0x94, 0x4a}},
|
||||
{"yellow", {0xc8, 0x88, 0x00}}, {"orange", {0xed, 0x5b, 0x00}}, {"red", {0xe6, 0x2d, 0x42}},
|
||||
{"pink", {0xd5, 0x61, 0x99}}, {"purple", {0x91, 0x41, 0xac}}, {"slate", {0x6f, 0x83, 0x96}}};
|
||||
const std::string n = lower(gsettingsString(name));
|
||||
for (const auto &[key, value] : table) {
|
||||
if (n == key) return value;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
inline std::optional<Mode> gnomeScheme(const std::string &text) {
|
||||
const std::string s = lower(gsettingsString(text));
|
||||
if (s == "prefer-dark") return Mode::Dark;
|
||||
if (s == "prefer-light") return Mode::Light;
|
||||
return std::nullopt; // "default" means no preference
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
// The part of a dictionary dump that belongs to `key`: what comes after the
|
||||
// closing quote of the key, up to the next quote, which opens the next key.
|
||||
// "color-scheme" u 2 "accent-color" (ddd) 0.2 0.5 0.9
|
||||
// 'color-scheme': <uint32 2>, 'accent-color': <(0.2, 0.5, 0.9)>
|
||||
inline std::optional<std::string> valueOf(const std::string &text, const std::string &key) {
|
||||
for (char quote : {'"', '\''}) {
|
||||
const std::string needle = std::string(1, quote) + key + std::string(1, quote);
|
||||
const size_t at = text.find(needle);
|
||||
if (at == std::string::npos) continue;
|
||||
const size_t from = at + needle.size();
|
||||
const size_t next = text.find_first_of("\"'", from);
|
||||
return text.substr(from, next == std::string::npos ? std::string::npos : next - from);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Every number in the text, the type names around them are removed first so
|
||||
// that the 32 of "uint32" and the 3 of "(ddd)" are not read as values.
|
||||
inline std::vector<double> numbersIn(std::string text) {
|
||||
for (const char *word : {"uint32", "(ddd)", "int32", "double"}) {
|
||||
for (size_t at; (at = text.find(word)) != std::string::npos;) text.replace(at, std::string(word).size(), " ");
|
||||
}
|
||||
std::vector<double> out;
|
||||
const char *p = text.c_str();
|
||||
const char *end = p + text.size();
|
||||
while (p < end) {
|
||||
const bool digit = std::isdigit(static_cast<unsigned char>(*p));
|
||||
const bool sign = *p == '-' && p + 1 < end && std::isdigit(static_cast<unsigned char>(p[1]));
|
||||
if (digit || sign) {
|
||||
char *stop = nullptr;
|
||||
out.push_back(std::strtod(p, &stop));
|
||||
p = stop;
|
||||
} else {
|
||||
p++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// 1 is dark, 2 is light, 0 means no preference.
|
||||
inline std::optional<Mode> portalScheme(const std::string &text) {
|
||||
const auto value = detail::valueOf(text, "color-scheme");
|
||||
if (!value) return std::nullopt;
|
||||
const auto n = detail::numbersIn(*value);
|
||||
if (n.size() != 1) return std::nullopt;
|
||||
if (n[0] == 1) return Mode::Dark;
|
||||
if (n[0] == 2) return Mode::Light;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
inline std::optional<Rgb> portalAccent(const std::string &text) {
|
||||
const auto value = detail::valueOf(text, "accent-color");
|
||||
if (!value) return std::nullopt;
|
||||
const auto v = detail::numbersIn(*value);
|
||||
if (v.size() != 3) return std::nullopt;
|
||||
for (double c : v) {
|
||||
// the portal answers with values outside 0..1 when no accent is set
|
||||
if (!(c >= 0.0 && c <= 1.0)) return std::nullopt;
|
||||
}
|
||||
auto q = [](double c) { return static_cast<std::uint8_t>(std::lround(c * 255.0)); };
|
||||
return Rgb{q(v[0]), q(v[1]), q(v[2])};
|
||||
}
|
||||
|
||||
// Light or dark from the colour of a window background: below tone 50 is dark.
|
||||
inline Mode modeOfBackground(Rgb background) { return toneOf(background) < 50.0 ? Mode::Dark : Mode::Light; }
|
||||
|
||||
// kdeglobals: the accent, and light or dark from the window background
|
||||
inline Appearance kdeGlobals(const std::string &text) {
|
||||
Appearance out;
|
||||
std::string section;
|
||||
std::optional<Rgb> accent, selection, window;
|
||||
std::optional<bool> darkName;
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
line = trim(line);
|
||||
if (line.empty() || line[0] == '#' || line[0] == ';') continue;
|
||||
if (line.front() == '[' && line.back() == ']') {
|
||||
section = line.substr(1, line.size() - 2);
|
||||
continue;
|
||||
}
|
||||
const size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
const std::string key = trim(line.substr(0, eq)), value = line.substr(eq + 1);
|
||||
if (section == "General" && key == "AccentColor") accent = kdeRgb(value);
|
||||
if (section == "General" && key == "ColorScheme") darkName = lower(value).find("dark") != std::string::npos;
|
||||
if (section == "Colors:Selection" && key == "BackgroundNormal") selection = kdeRgb(value);
|
||||
if (section == "Colors:Window" && key == "BackgroundNormal") window = kdeRgb(value);
|
||||
}
|
||||
out.accent = accent ? accent : selection;
|
||||
if (window) {
|
||||
out.mode = modeOfBackground(*window);
|
||||
} else if (darkName) {
|
||||
out.mode = *darkName ? Mode::Dark : Mode::Light;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// GTK settings.ini
|
||||
inline std::optional<Mode> gtkSettings(const std::string &text) {
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
std::optional<Mode> preferDark, themeName;
|
||||
while (std::getline(in, line)) {
|
||||
line = trim(line);
|
||||
const size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
const std::string key = trim(line.substr(0, eq)), value = lower(trim(line.substr(eq + 1)));
|
||||
if (key == "gtk-application-prefer-dark-theme") preferDark = (value == "1" || value == "true") ? Mode::Dark : Mode::Light;
|
||||
if (key == "gtk-theme-name") themeName = value.find("dark") != std::string::npos ? Mode::Dark : Mode::Light;
|
||||
}
|
||||
return preferDark == Mode::Dark ? preferDark : (themeName ? themeName : preferDark);
|
||||
}
|
||||
|
||||
} // namespace parse
|
||||
|
||||
// --- running programs
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline std::optional<std::string> runProgram(const std::vector<std::string> &argv, int timeoutMs) {
|
||||
if (argv.empty()) return std::nullopt;
|
||||
int fds[2];
|
||||
if (pipe(fds) != 0) return std::nullopt;
|
||||
posix_spawn_file_actions_t actions;
|
||||
posix_spawn_file_actions_init(&actions);
|
||||
posix_spawn_file_actions_adddup2(&actions, fds[1], 1);
|
||||
posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", 1 /*O_WRONLY*/, 0);
|
||||
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", 0 /*O_RDONLY*/, 0);
|
||||
posix_spawn_file_actions_addclose(&actions, fds[0]);
|
||||
posix_spawn_file_actions_addclose(&actions, fds[1]);
|
||||
std::vector<char *> args;
|
||||
for (const auto &a : argv) args.push_back(const_cast<char *>(a.c_str()));
|
||||
args.push_back(nullptr);
|
||||
pid_t pid = 0;
|
||||
const int rc = posix_spawnp(&pid, args[0], &actions, nullptr, args.data(), environ);
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
close(fds[1]);
|
||||
if (rc != 0) {
|
||||
close(fds[0]);
|
||||
return std::nullopt;
|
||||
}
|
||||
std::string out;
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
|
||||
bool timedOut = false;
|
||||
char buf[4096];
|
||||
while (true) {
|
||||
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - std::chrono::steady_clock::now()).count();
|
||||
if (left <= 0) {
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
pollfd p{fds[0], POLLIN, 0};
|
||||
const int r = poll(&p, 1, static_cast<int>(left));
|
||||
if (r < 0 && errno == EINTR) continue;
|
||||
if (r <= 0) {
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
const ssize_t n = read(fds[0], buf, sizeof buf);
|
||||
if (n > 0) {
|
||||
out.append(buf, static_cast<size_t>(n));
|
||||
if (out.size() > (1u << 20)) { // far more than any answer, something is wrong
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
} else if (n == 0) {
|
||||
break;
|
||||
} else if (errno != EINTR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(fds[0]);
|
||||
if (timedOut) kill(pid, SIGKILL);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
if (timedOut || !WIFEXITED(status) || WEXITSTATUS(status) != 0) return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline Context Context::system() {
|
||||
Context c;
|
||||
c.getenv = [](const std::string &name) -> std::optional<std::string> {
|
||||
const char *v = std::getenv(name.c_str());
|
||||
if (!v || !*v) return std::nullopt;
|
||||
return std::string(v);
|
||||
};
|
||||
c.run = detail::runProgram;
|
||||
const char *home = std::getenv("HOME");
|
||||
c.home = home ? home : "";
|
||||
const char *xdg = std::getenv("XDG_CONFIG_HOME");
|
||||
c.configHome = (xdg && *xdg) ? std::filesystem::path(xdg) : c.home / ".config";
|
||||
return c;
|
||||
}
|
||||
|
||||
// --- the built in sources
|
||||
|
||||
namespace sources {
|
||||
|
||||
inline Source override() {
|
||||
return {"override", [](const Context &ctx) {
|
||||
Appearance a;
|
||||
if (auto s = ctx.getenv("MONET_SEED")) a.accent = parse::hexColor(*s);
|
||||
if (auto m = ctx.getenv("MONET_MODE")) {
|
||||
const std::string v = parse::lower(*m);
|
||||
if (v == "dark") a.mode = Mode::Dark;
|
||||
if (v == "light") a.mode = Mode::Light;
|
||||
}
|
||||
return a;
|
||||
}};
|
||||
}
|
||||
|
||||
inline Source portal() {
|
||||
return {"portal", [](const Context &ctx) {
|
||||
Appearance a;
|
||||
std::optional<std::string> text = ctx.run({"busctl", "--user", "call", "org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings",
|
||||
"ReadAll", "as", "1", "org.freedesktop.appearance"}, 800);
|
||||
if (!text) {
|
||||
text = ctx.run({"gdbus", "call", "--session", "--dest", "org.freedesktop.portal.Desktop", "--object-path",
|
||||
"/org/freedesktop/portal/desktop", "--method", "org.freedesktop.portal.Settings.ReadAll",
|
||||
"['org.freedesktop.appearance']"}, 800);
|
||||
}
|
||||
if (!text) return a;
|
||||
a.accent = parse::portalAccent(*text);
|
||||
a.mode = parse::portalScheme(*text);
|
||||
return a;
|
||||
}};
|
||||
}
|
||||
|
||||
inline Source kde() {
|
||||
return {"kde", [](const Context &ctx) {
|
||||
std::ifstream in(ctx.configHome / "kdeglobals");
|
||||
if (!in) return Appearance{};
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
return parse::kdeGlobals(ss.str());
|
||||
}};
|
||||
}
|
||||
|
||||
inline Source gnome() {
|
||||
return {"gnome", [](const Context &ctx) {
|
||||
Appearance a;
|
||||
if (auto s = ctx.run({"gsettings", "get", "org.gnome.desktop.interface", "color-scheme"}, 500)) a.mode = parse::gnomeScheme(*s);
|
||||
if (auto s = ctx.run({"gsettings", "get", "org.gnome.desktop.interface", "accent-color"}, 500)) a.accent = parse::gnomeAccent(*s);
|
||||
return a;
|
||||
}};
|
||||
}
|
||||
|
||||
inline Source gtk() {
|
||||
return {"gtk", [](const Context &ctx) {
|
||||
Appearance a;
|
||||
for (const char *dir : {"gtk-4.0", "gtk-3.0"}) {
|
||||
std::ifstream in(ctx.configHome / dir / "settings.ini");
|
||||
if (!in) continue;
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
if ((a.mode = parse::gtkSettings(ss.str()))) break;
|
||||
}
|
||||
return a;
|
||||
}};
|
||||
}
|
||||
|
||||
// Always answers. It is added after every other source and cannot be left out.
|
||||
inline Source fallback(Rgb seed = kDefaultSeed, Mode mode = kDefaultMode) {
|
||||
return {"fallback", [seed, mode](const Context &) {
|
||||
Appearance a;
|
||||
a.accent = seed;
|
||||
a.mode = mode;
|
||||
return a;
|
||||
}};
|
||||
}
|
||||
|
||||
} // namespace sources
|
||||
|
||||
// The sources for this machine, most specific answer first. The portal comes
|
||||
// before the desktop specific ones because it is the same call on every desktop
|
||||
// and is what the desktop itself keeps current, a leftover gsettings value on a
|
||||
// KDE machine would otherwise win. The desktop specific source is only used for
|
||||
// the desktop it belongs to.
|
||||
inline std::vector<Source> defaultSources(const Context &ctx) {
|
||||
std::vector<Source> list{sources::override(), sources::portal()};
|
||||
const std::string desktop = parse::lower(ctx.getenv("XDG_CURRENT_DESKTOP").value_or(""));
|
||||
auto has = [&](const char *word) { return desktop.find(word) != std::string::npos; };
|
||||
if (has("kde") || has("plasma")) {
|
||||
list.push_back(sources::kde());
|
||||
} else if (has("gnome") || has("unity") || has("pantheon") || has("budgie") || has("cinnamon") || has("cosmic")) {
|
||||
list.push_back(sources::gnome());
|
||||
}
|
||||
list.push_back(sources::gtk());
|
||||
return list;
|
||||
}
|
||||
|
||||
// Asks the sources in order. The fallback is appended here, so every value is
|
||||
// always set, whatever list was passed.
|
||||
inline Appearance detect(const Context &ctx, const std::vector<Source> &list) {
|
||||
Appearance out;
|
||||
auto ask = [&](const Source &s) {
|
||||
const Appearance a = s.query(ctx);
|
||||
if (!out.accent && a.accent) {
|
||||
out.accent = a.accent;
|
||||
out.accentSource = s.name;
|
||||
}
|
||||
if (!out.mode && a.mode) {
|
||||
out.mode = a.mode;
|
||||
out.modeSource = s.name;
|
||||
}
|
||||
};
|
||||
for (const auto &s : list) {
|
||||
if (out.accent && out.mode) break;
|
||||
ask(s);
|
||||
}
|
||||
if (!out.accent || !out.mode) ask(sources::fallback());
|
||||
return out;
|
||||
}
|
||||
|
||||
// What a program normally wants: the scheme for this desktop. `first` are
|
||||
// sources of the program's own, asked before the built in ones.
|
||||
inline Scheme fromDesktop(const Context &ctx, std::vector<Source> first = {}) {
|
||||
for (auto &s : defaultSources(ctx)) first.push_back(std::move(s));
|
||||
const Appearance a = detect(ctx, first);
|
||||
return generate(*a.accent, *a.mode);
|
||||
}
|
||||
|
||||
inline Scheme fromDesktop() { return fromDesktop(Context::system()); }
|
||||
|
||||
} // namespace monet
|
||||
@@ -0,0 +1,172 @@
|
||||
#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
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include "appearance.h"
|
||||
#include "color.h"
|
||||
#include "scheme.h"
|
||||
@@ -0,0 +1,124 @@
|
||||
#pragma once
|
||||
|
||||
// A Material 3 style colour scheme from one seed colour.
|
||||
//
|
||||
// The roles and the tones they use follow Material 3's baseline scheme: a
|
||||
// primary, secondary, tertiary and error accent each with an "on" colour and a
|
||||
// container, a neutral surface ladder, and the outline and inverse roles. The
|
||||
// palettes differ in chroma and hue the way Material's do (the tertiary hue is
|
||||
// shifted by 60 degrees, the neutrals are almost grey), expressed in OKLCH
|
||||
// chroma, see color.h for what that means for fidelity.
|
||||
|
||||
#include "color.h"
|
||||
|
||||
namespace monet {
|
||||
|
||||
enum class Mode { Light, Dark };
|
||||
|
||||
struct Scheme {
|
||||
Rgb seed;
|
||||
Mode mode = Mode::Dark;
|
||||
|
||||
Rgb primary, onPrimary, primaryContainer, onPrimaryContainer;
|
||||
Rgb secondary, onSecondary, secondaryContainer, onSecondaryContainer;
|
||||
Rgb tertiary, onTertiary, tertiaryContainer, onTertiaryContainer;
|
||||
Rgb error, onError, errorContainer, onErrorContainer;
|
||||
|
||||
Rgb background, onBackground;
|
||||
Rgb surface, onSurface, surfaceVariant, onSurfaceVariant;
|
||||
Rgb surfaceDim, surfaceBright;
|
||||
Rgb surfaceContainerLowest, surfaceContainerLow, surfaceContainer, surfaceContainerHigh,
|
||||
surfaceContainerHighest;
|
||||
|
||||
Rgb outline, outlineVariant;
|
||||
Rgb inverseSurface, inverseOnSurface, inversePrimary;
|
||||
Rgb shadow, scrim;
|
||||
|
||||
bool dark() const { return mode == Mode::Dark; }
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
constexpr double kMinPrimaryChroma = 0.11;
|
||||
constexpr double kGreySeedChroma = 0.03; // below this the seed is read as "no colour"
|
||||
constexpr double kMaxPrimaryChroma = 0.30;
|
||||
constexpr double kSecondaryChroma = 0.04;
|
||||
constexpr double kTertiaryChroma = 0.075;
|
||||
constexpr double kNeutralChroma = 0.008;
|
||||
constexpr double kNeutralVariantChroma = 0.016;
|
||||
constexpr double kErrorHue = 29.0;
|
||||
constexpr double kErrorChroma = 0.19;
|
||||
|
||||
inline double wrapHue(double h) {
|
||||
h = std::fmod(h, 360.0);
|
||||
return h < 0 ? h + 360.0 : h;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline Scheme generate(Rgb seed, Mode mode) {
|
||||
using namespace detail;
|
||||
const Oklch s = toOklch(seed);
|
||||
// A grey seed stays grey, a colourful one is given at least a lively
|
||||
// chroma, a very strong one is held back so the accent stays usable.
|
||||
const double primaryChroma =
|
||||
s.C < kGreySeedChroma ? s.C : std::clamp(s.C, kMinPrimaryChroma, kMaxPrimaryChroma);
|
||||
const double hue = s.h;
|
||||
const double secondaryChroma = s.C < kGreySeedChroma ? s.C : kSecondaryChroma;
|
||||
const double tertiaryChroma = s.C < kGreySeedChroma ? s.C : kTertiaryChroma;
|
||||
const double neutral = s.C < kGreySeedChroma ? 0.0 : kNeutralChroma;
|
||||
const double neutralVariant = s.C < kGreySeedChroma ? 0.0 : kNeutralVariantChroma;
|
||||
|
||||
auto primary = [&](double t) { return toneColor(hue, primaryChroma, t); };
|
||||
auto secondary = [&](double t) { return toneColor(hue, secondaryChroma, t); };
|
||||
auto tertiary = [&](double t) { return toneColor(wrapHue(hue + 60.0), tertiaryChroma, t); };
|
||||
auto error = [&](double t) { return toneColor(kErrorHue, kErrorChroma, t); };
|
||||
auto n = [&](double t) { return toneColor(hue, neutral, t); };
|
||||
auto nv = [&](double t) { return toneColor(hue, neutralVariant, t); };
|
||||
|
||||
Scheme o;
|
||||
o.seed = seed;
|
||||
o.mode = mode;
|
||||
if (mode == Mode::Light) {
|
||||
o.primary = primary(40); o.onPrimary = primary(100);
|
||||
o.primaryContainer = primary(90); o.onPrimaryContainer = primary(10);
|
||||
o.secondary = secondary(40); o.onSecondary = secondary(100);
|
||||
o.secondaryContainer = secondary(90); o.onSecondaryContainer = secondary(10);
|
||||
o.tertiary = tertiary(40); o.onTertiary = tertiary(100);
|
||||
o.tertiaryContainer = tertiary(90); o.onTertiaryContainer = tertiary(10);
|
||||
o.error = error(40); o.onError = error(100);
|
||||
o.errorContainer = error(90); o.onErrorContainer = error(10);
|
||||
o.background = n(98); o.onBackground = n(10);
|
||||
o.surface = n(98); o.onSurface = n(10);
|
||||
o.surfaceVariant = nv(90); o.onSurfaceVariant = nv(30);
|
||||
o.surfaceDim = n(87); o.surfaceBright = n(98);
|
||||
o.surfaceContainerLowest = n(100); o.surfaceContainerLow = n(96);
|
||||
o.surfaceContainer = n(94); o.surfaceContainerHigh = n(92);
|
||||
o.surfaceContainerHighest = n(90);
|
||||
o.outline = nv(50); o.outlineVariant = nv(80);
|
||||
o.inverseSurface = n(20); o.inverseOnSurface = n(95); o.inversePrimary = primary(80);
|
||||
} else {
|
||||
o.primary = primary(80); o.onPrimary = primary(20);
|
||||
o.primaryContainer = primary(30); o.onPrimaryContainer = primary(90);
|
||||
o.secondary = secondary(80); o.onSecondary = secondary(20);
|
||||
o.secondaryContainer = secondary(30); o.onSecondaryContainer = secondary(90);
|
||||
o.tertiary = tertiary(80); o.onTertiary = tertiary(20);
|
||||
o.tertiaryContainer = tertiary(30); o.onTertiaryContainer = tertiary(90);
|
||||
o.error = error(80); o.onError = error(20);
|
||||
o.errorContainer = error(30); o.onErrorContainer = error(90);
|
||||
o.background = n(6); o.onBackground = n(90);
|
||||
o.surface = n(6); o.onSurface = n(90);
|
||||
o.surfaceVariant = nv(30); o.onSurfaceVariant = nv(80);
|
||||
o.surfaceDim = n(6); o.surfaceBright = n(24);
|
||||
o.surfaceContainerLowest = n(4); o.surfaceContainerLow = n(10);
|
||||
o.surfaceContainer = n(12); o.surfaceContainerHigh = n(17);
|
||||
o.surfaceContainerHighest = n(22);
|
||||
o.outline = nv(60); o.outlineVariant = nv(30);
|
||||
o.inverseSurface = n(90); o.inverseOnSurface = n(20); o.inversePrimary = primary(40);
|
||||
}
|
||||
o.shadow = {0, 0, 0};
|
||||
o.scrim = {0, 0, 0};
|
||||
return o;
|
||||
}
|
||||
|
||||
} // namespace monet
|
||||
Reference in new issue
Block a user