#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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "scheme.h" extern char **environ; namespace monet { struct Appearance { std::optional accent; std::optional 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(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(const std::vector &, int)> run; std::filesystem::path home; std::filesystem::path configHome; static Context system(); }; struct Source { std::string name; std::function 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(s[a]))) a++; while (b > a && std::isspace(static_cast(s[b - 1]))) b--; return s.substr(a, b - a); } // "#RRGGBB", "RRGGBB" or "#RGB" inline std::optional 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(c))) return std::nullopt; } auto v = [&](size_t i, size_t n) { return static_cast(std::stoi(s.substr(i, n), nullptr, 16)); }; if (s.size() == 3) { return Rgb{static_cast(v(0, 1) * 17), static_cast(v(1, 1) * 17), static_cast(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 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(v[0]), static_cast(v[1]), static_cast(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 gnomeAccent(const std::string &name) { static const std::pair 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 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': , 'accent-color': <(0.2, 0.5, 0.9)> inline std::optional 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 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 out; const char *p = text.c_str(); const char *end = p + text.size(); while (p < end) { const bool digit = std::isdigit(static_cast(*p)); const bool sign = *p == '-' && p + 1 < end && std::isdigit(static_cast(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 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 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::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 accent, selection, window; std::optional 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 gtkSettings(const std::string &text) { std::istringstream in(text); std::string line; std::optional 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 runProgram(const std::vector &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 args; for (const auto &a : argv) args.push_back(const_cast(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(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(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(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 { 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 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 defaultSources(const Context &ctx) { std::vector 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 &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 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