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>
493 lines
19 KiB
C++
493 lines
19 KiB
C++
#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
|