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,2 @@
|
||||
build/
|
||||
*.swp
|
||||
@@ -0,0 +1,28 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(libmonet VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
# Header only: nothing to build, nothing to link, only the include directory.
|
||||
add_library(monet INTERFACE)
|
||||
add_library(monet::monet ALIAS monet)
|
||||
target_include_directories(monet INTERFACE
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include>)
|
||||
target_compile_features(monet INTERFACE cxx_std_20)
|
||||
|
||||
option(MONET_BUILD_TESTS "Build the tests" ${PROJECT_IS_TOP_LEVEL})
|
||||
if(MONET_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_executable(monet_tests tests/test_monet.cpp)
|
||||
target_link_libraries(monet_tests PRIVATE monet)
|
||||
target_compile_options(monet_tests PRIVATE -Wall -Wextra -Wshadow)
|
||||
add_test(NAME monet COMMAND monet_tests)
|
||||
endif()
|
||||
|
||||
if(PROJECT_IS_TOP_LEVEL)
|
||||
install(DIRECTORY include/ DESTINATION include)
|
||||
install(TARGETS monet EXPORT monetTargets)
|
||||
install(EXPORT monetTargets NAMESPACE monet:: DESTINATION lib/cmake/monet)
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/monetConfig.cmake
|
||||
"include(\"\${CMAKE_CURRENT_LIST_DIR}/monetTargets.cmake\")\n")
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/monetConfig.cmake DESTINATION lib/cmake/monet)
|
||||
endif()
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 JustMe
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,95 @@
|
||||
# libmonet
|
||||
|
||||
一个种子色生成一整套 Material 3 风格配色,并且知道**从桌面上去哪里取这个种子色和深浅色**。
|
||||
纯 C++20 头文件库,没有第三方依赖。
|
||||
|
||||
给 [fcitx5-m3ui](https://git.oeo.one/justme/fcitx5-m3ui) 和 m3shot 共用,让它们的配色跟着桌面走,
|
||||
KDE Plasma 和 GNOME 都支持。
|
||||
|
||||
## 用法
|
||||
|
||||
```cpp
|
||||
#include <monet/monet.h>
|
||||
|
||||
monet::Scheme s = monet::fromDesktop(); // 种子色和深浅色都来自当前桌面
|
||||
monet::Rgb surface = s.surfaceContainer; // 角色颜色: primary、onPrimary、surface、outline ...
|
||||
std::string css = monet::hex(s.primary); // "#00658F"
|
||||
bool dark = s.dark();
|
||||
```
|
||||
|
||||
已经有种子色的话:`monet::generate(monet::Rgb{61, 174, 233}, monet::Mode::Dark)`。
|
||||
|
||||
## 种子色和深浅色从哪来
|
||||
|
||||
探测由一串**来源(Source)**完成,按顺序问,每个值取第一个有答案的来源:
|
||||
|
||||
| 来源 | 读什么 | 什么时候用 |
|
||||
|---|---|---|
|
||||
| `override` | 环境变量 `MONET_SEED=#RRGGBB`、`MONET_MODE=dark\|light` | 永远排第一,方便手动指定 |
|
||||
| `portal` | `org.freedesktop.portal.Settings`(用 `busctl` 或 `gdbus` 读) | 永远第二。**GNOME 47+ 和 KDE Plasma 6 都实现了它**,所以这是唯一跨桌面通用的来源 |
|
||||
| `kde` | `~/.config/kdeglobals` | 只在 KDE 桌面上 |
|
||||
| `gnome` | `gsettings` | 只在 GNOME 一类桌面上 |
|
||||
| `gtk` | `gtk-3.0/settings.ini`、`gtk-4.0/settings.ini` | 只能给出深浅色 |
|
||||
| `fallback` | 固定的粉色种子加深色 | **永远是最后一个,而且去不掉** |
|
||||
|
||||
两个细节:
|
||||
|
||||
- 桌面专用来源只在对应桌面上才会被问。KDE 机器上残留的旧 `gsettings` 值不会盖过 KDE 自己的答案。
|
||||
- **回退源永远存在**:来源列表为空、全部失败、没有桌面环境,结果都是合法的配色,不会返回空。
|
||||
回退用的是两个工具在跟随桌面之前的老外观(粉色强调色加深色)。
|
||||
|
||||
程序有自己的 D-Bus 连接的话,可以加一个自己的来源放在最前面:
|
||||
|
||||
```cpp
|
||||
monet::Source mine{"mine", [](const monet::Context &) {
|
||||
monet::Appearance a;
|
||||
a.accent = monet::Rgb{...}; // 只填你知道的,其余留空,交给后面的来源
|
||||
return a;
|
||||
}};
|
||||
monet::Scheme s = monet::fromDesktop(monet::Context::system(), {mine});
|
||||
```
|
||||
|
||||
`monet::detect()` 的结果里带着 `accentSource` 和 `modeSource`,方便打日志,看颜色到底是谁给的。
|
||||
`Context` 可以替换,测试里就是用假的 `Context` 模拟各种桌面。
|
||||
|
||||
## 接入
|
||||
|
||||
CMake,固定到某个提交:
|
||||
|
||||
```cmake
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(monet
|
||||
GIT_REPOSITORY https://git.oeo.one/justme/libmonet.git
|
||||
GIT_TAG <提交哈希>)
|
||||
FetchContent_MakeAvailable(monet)
|
||||
target_link_libraries(myapp PRIVATE monet::monet)
|
||||
```
|
||||
|
||||
不用 CMake 的话,把 `include/` 加进头文件搜索路径就行。
|
||||
|
||||
## 配色是怎么算的
|
||||
|
||||
颜色在 OKLab/OKLCH 里处理,"色调(tone)"就是结果的 CIE L\*,和 Material 3 的 HCT 色调是同一个意思,
|
||||
所以同一个色调不论什么色相,亮度和对比度都一样。角色和它们用的色调照搬 Material 3 的基线方案
|
||||
(浅色 primary 40、深色 primary 80,容器 90/30,表面阶梯 100 到 90 等),三色相偏移 60°,
|
||||
中性色几乎是灰色,错误色固定在红色。
|
||||
|
||||
**要说清楚的是**:这是 HCT 的轻量替代,没有实现 CAM16,所以**不是和 Material 官方库逐位一致**。
|
||||
色相和色调是准的,色度的标度和官方不同。灰色种子会得到灰色方案,不会被硬染上颜色。
|
||||
|
||||
## 测试
|
||||
|
||||
```
|
||||
cmake -S . -B build -G Ninja && ninja -C build && ./build/monet_tests
|
||||
```
|
||||
|
||||
测试不需要真实桌面,所有来源都用假的 `Context` 测。覆盖:颜色空间往返、色调精度和单调性、
|
||||
300 个随机种子加深浅两种模式下每一对前景/背景的对比度(WCAG)、各种格式的解析(包括这台机器上
|
||||
门户的真实输出和 `gdbus` 的输出格式)、各桌面下的来源顺序、回退、环境变量覆盖、外部程序的超时。
|
||||
我还故意把代码改坏了 22 处来检查这些测试抓不抓得到,第一轮有几处漏掉的,补了测试。
|
||||
|
||||
**没有覆盖的**:GNOME 这条路只通过假环境测试过,没有在真实的 GNOME 机器上跑过。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
@@ -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
|
||||
@@ -0,0 +1,419 @@
|
||||
// Tests for libmonet. Plain asserts, no framework, no real desktop needed: every
|
||||
// source is exercised through a fake Context.
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
|
||||
#include "monet/monet.h"
|
||||
|
||||
using namespace monet;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
#define CHECK_MSG(cond, ...) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL line %d: %s ", __LINE__, #cond); \
|
||||
std::printf(__VA_ARGS__); \
|
||||
std::printf("\n"); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool same(Rgb a, Rgb b, int tolerance = 0) {
|
||||
return std::abs(a.r - b.r) <= tolerance && std::abs(a.g - b.g) <= tolerance && std::abs(a.b - b.b) <= tolerance;
|
||||
}
|
||||
|
||||
// --- a fake system -----------------------------------------------------------
|
||||
|
||||
struct Fake {
|
||||
std::map<std::string, std::string> env;
|
||||
std::map<std::string, std::string> programs; // argv[0] -> standard output
|
||||
std::vector<std::string> called;
|
||||
fs::path configHome;
|
||||
|
||||
Context context() {
|
||||
Context c;
|
||||
c.getenv = [this](const std::string &k) -> std::optional<std::string> {
|
||||
auto it = env.find(k);
|
||||
return it == env.end() ? std::nullopt : std::optional<std::string>(it->second);
|
||||
};
|
||||
c.run = [this](const std::vector<std::string> &argv, int) -> std::optional<std::string> {
|
||||
called.push_back(argv[0]);
|
||||
auto it = programs.find(argv[0]);
|
||||
return it == programs.end() ? std::nullopt : std::optional<std::string>(it->second);
|
||||
};
|
||||
c.configHome = configHome;
|
||||
c.home = configHome;
|
||||
return c;
|
||||
}
|
||||
};
|
||||
|
||||
static const char *kBusctl =
|
||||
"a{sa{sv}} 1 \"org.freedesktop.appearance\" 4 \"contrast\" u 0 \"color-scheme\" u 2 \"reduced-motion\" u 0 "
|
||||
"\"accent-color\" (ddd) 0.239216 0.682353 0.913725\n";
|
||||
static const char *kBusctlDark =
|
||||
"a{sa{sv}} 1 \"org.freedesktop.appearance\" 2 \"color-scheme\" u 1 \"accent-color\" (ddd) 0.5 0.25 1\n";
|
||||
static const char *kGdbus =
|
||||
"({'org.freedesktop.appearance': {'contrast': <uint32 0>, 'color-scheme': <uint32 1>, 'accent-color': <(0.5, 0.25, 1.0)>}},)\n";
|
||||
|
||||
static void writeFile(const fs::path &p, const std::string &text) {
|
||||
fs::create_directories(p.parent_path());
|
||||
std::ofstream(p) << text;
|
||||
}
|
||||
|
||||
int main() {
|
||||
// --- colour spaces -------------------------------------------------------
|
||||
{
|
||||
const Rgb samples[] = {{0, 0, 0}, {255, 255, 255}, {255, 0, 0}, {0, 255, 0}, {0, 0, 255},
|
||||
{61, 174, 233}, {255, 176, 201}, {128, 128, 128}, {1, 2, 3}, {250, 250, 5}};
|
||||
for (Rgb c : samples) {
|
||||
CHECK_MSG(same(toRgb(toOklch(c)), c, 1), "roundtrip of %s gave %s", hex(c).c_str(), hex(toRgb(toOklch(c))).c_str());
|
||||
}
|
||||
const Oklch white = toOklch({255, 255, 255});
|
||||
CHECK(std::fabs(white.L - 1.0) < 0.002 && white.C < 0.002);
|
||||
const Oklch red = toOklch({255, 0, 0});
|
||||
CHECK(std::fabs(red.L - 0.628) < 0.003 && std::fabs(red.C - 0.2577) < 0.003 && std::fabs(red.h - 29.23) < 0.3);
|
||||
CHECK(toOklch({0, 0, 0}).L < 1e-6);
|
||||
CHECK(hex({0x3D, 0xAE, 0xE9}) == "#3DAEE9");
|
||||
CHECK(std::fabs(toneOf({255, 255, 255}) - 100) < 0.01 && toneOf({0, 0, 0}) < 0.01);
|
||||
CHECK(std::fabs(contrast({0, 0, 0}, {255, 255, 255}) - 21.0) < 0.01);
|
||||
CHECK(std::fabs(contrast({90, 90, 90}, {90, 90, 90}) - 1.0) < 1e-9);
|
||||
}
|
||||
|
||||
// --- tones ---------------------------------------------------------------
|
||||
{
|
||||
const double hues[] = {0, 30, 90, 150, 210, 270, 330};
|
||||
const double chromas[] = {0.0, 0.05, 0.12, 0.25};
|
||||
for (double h : hues) {
|
||||
for (double c : chromas) {
|
||||
double lastLum = -1;
|
||||
for (double tone = 5; tone <= 95; tone += 5) {
|
||||
const Rgb col = toneColor(h, c, tone);
|
||||
CHECK_MSG(std::fabs(toneOf(col) - tone) < 0.9, "hue %.0f chroma %.2f tone %.0f gave %.2f", h, c, tone, toneOf(col));
|
||||
const double lum = luminance(col);
|
||||
CHECK_MSG(lum >= lastLum, "tones are not increasing at hue %.0f chroma %.2f tone %.0f", h, c, tone);
|
||||
lastLum = lum;
|
||||
}
|
||||
}
|
||||
}
|
||||
CHECK(toneColor(200, 0.2, 0) == (Rgb{0, 0, 0}));
|
||||
CHECK(toneColor(200, 0.2, 100) == (Rgb{255, 255, 255}));
|
||||
CHECK(toneColor(200, 0.2, -5) == (Rgb{0, 0, 0}));
|
||||
CHECK(toneColor(200, 0.2, 250) == (Rgb{255, 255, 255}));
|
||||
// a grey stays grey
|
||||
const Rgb g = toneColor(123, 0.0, 50);
|
||||
CHECK(g.r == g.g && g.g == g.b);
|
||||
// the chroma is kept where the gamut allows it and the hue stays the seed's
|
||||
const Oklch mid = toOklch(toneColor(250, 0.10, 50));
|
||||
CHECK(std::fabs(mid.h - 250) < 3 && mid.C > 0.08);
|
||||
}
|
||||
|
||||
// --- schemes -------------------------------------------------------------
|
||||
{
|
||||
unsigned state = 12345;
|
||||
auto next = [&] { state = state * 1664525u + 1013904223u; return static_cast<std::uint8_t>(state >> 24); };
|
||||
std::vector<Rgb> seeds = {{61, 174, 233}, {255, 176, 201}, {0, 0, 0}, {255, 255, 255}, {128, 128, 128},
|
||||
{255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {255, 255, 0}, {0x67, 0x50, 0xA4}};
|
||||
for (int i = 0; i < 300; i++) seeds.push_back({next(), next(), next()});
|
||||
for (Rgb seed : seeds) {
|
||||
for (Mode mode : {Mode::Light, Mode::Dark}) {
|
||||
const Scheme s = generate(seed, mode);
|
||||
const char *m = mode == Mode::Light ? "light" : "dark";
|
||||
auto pair = [&](const char *name, Rgb bg, Rgb fg, double minimum) {
|
||||
CHECK_MSG(contrast(bg, fg) >= minimum, "%s %s seed %s: contrast %.2f < %.1f", m, name, hex(seed).c_str(), contrast(bg, fg), minimum);
|
||||
};
|
||||
pair("primary", s.primary, s.onPrimary, 4.5);
|
||||
pair("primaryContainer", s.primaryContainer, s.onPrimaryContainer, 7.0);
|
||||
pair("secondary", s.secondary, s.onSecondary, 4.5);
|
||||
pair("secondaryContainer", s.secondaryContainer, s.onSecondaryContainer, 7.0);
|
||||
pair("tertiary", s.tertiary, s.onTertiary, 4.5);
|
||||
pair("tertiaryContainer", s.tertiaryContainer, s.onTertiaryContainer, 7.0);
|
||||
pair("error", s.error, s.onError, 4.5);
|
||||
pair("errorContainer", s.errorContainer, s.onErrorContainer, 7.0);
|
||||
pair("surface", s.surface, s.onSurface, 7.0);
|
||||
pair("background", s.background, s.onBackground, 7.0);
|
||||
pair("surfaceContainerHighest", s.surfaceContainerHighest, s.onSurface, 7.0);
|
||||
pair("surfaceContainerLowest", s.surfaceContainerLowest, s.onSurface, 7.0);
|
||||
pair("surfaceVariant", s.surfaceVariant, s.onSurfaceVariant, 4.5);
|
||||
pair("inverse", s.inverseSurface, s.inverseOnSurface, 7.0);
|
||||
pair("outline", s.surface, s.outline, 3.0);
|
||||
pair("inversePrimary", s.inverseSurface, s.inversePrimary, 3.0);
|
||||
// the surface ladder is ordered, towards the light in light mode and towards the dark in dark mode
|
||||
const double l[] = {luminance(s.surfaceContainerLowest), luminance(s.surfaceContainerLow), luminance(s.surfaceContainer),
|
||||
luminance(s.surfaceContainerHigh), luminance(s.surfaceContainerHighest)};
|
||||
for (int k = 0; k < 4; k++) {
|
||||
CHECK_MSG(mode == Mode::Light ? l[k] > l[k + 1] : l[k] < l[k + 1], "%s surface ladder step %d seed %s", m, k, hex(seed).c_str());
|
||||
}
|
||||
CHECK(s.seed == seed && s.mode == mode && s.dark() == (mode == Mode::Dark));
|
||||
// same input, same output
|
||||
const Scheme again = generate(seed, mode);
|
||||
CHECK(same(again.primary, s.primary) && same(again.surface, s.surface) && same(again.outline, s.outline));
|
||||
}
|
||||
CHECK(luminance(generate(seed, Mode::Dark).surface) < luminance(generate(seed, Mode::Light).surface));
|
||||
}
|
||||
// the accent keeps the seed's hue
|
||||
for (Rgb seed : {Rgb{61, 174, 233}, Rgb{255, 176, 201}, Rgb{0x67, 0x50, 0xA4}, Rgb{200, 60, 20}}) {
|
||||
const Oklch want = toOklch(seed);
|
||||
for (Mode mode : {Mode::Light, Mode::Dark}) {
|
||||
const Oklch got = toOklch(generate(seed, mode).primary);
|
||||
CHECK_MSG(got.C < 0.04 || std::fabs(std::remainder(got.h - want.h, 360.0)) < 4.0, "hue of %s: seed %.1f primary %.1f", hex(seed).c_str(), want.h, got.h);
|
||||
}
|
||||
}
|
||||
// the tertiary accent sits 60 degrees round the hue circle from the primary, the secondary does not
|
||||
for (Rgb seed : {Rgb{61, 174, 233}, Rgb{255, 176, 201}, Rgb{0x67, 0x50, 0xA4}, Rgb{200, 60, 20}, Rgb{30, 160, 60}}) {
|
||||
for (Mode mode : {Mode::Light, Mode::Dark}) {
|
||||
const Scheme s = generate(seed, mode);
|
||||
const Oklch p = toOklch(s.primary), t = toOklch(s.tertiary), c = toOklch(s.secondary);
|
||||
const double shift = std::remainder(t.h - p.h, 360.0);
|
||||
CHECK_MSG(std::fabs(shift - 60.0) < 8.0, "tertiary of %s is %.1f degrees from the primary", hex(seed).c_str(), shift);
|
||||
CHECK_MSG(c.C < 0.03 || std::fabs(std::remainder(c.h - p.h, 360.0)) < 8.0, "secondary of %s left the hue", hex(seed).c_str());
|
||||
// and it is a real accent, not a neutral
|
||||
CHECK(t.C > 0.03 && c.C > 0.015);
|
||||
}
|
||||
}
|
||||
// the neutrals are almost grey, the error accent is red whatever the seed
|
||||
for (Rgb seed : {Rgb{61, 174, 233}, Rgb{30, 160, 60}, Rgb{255, 176, 201}}) {
|
||||
const Scheme s = generate(seed, Mode::Light);
|
||||
CHECK(toOklch(s.surface).C < 0.03 && toOklch(s.surfaceVariant).C < 0.04);
|
||||
const Oklch e = toOklch(s.error);
|
||||
CHECK_MSG(std::fabs(std::remainder(e.h - 29.0, 360.0)) < 12.0 && e.C > 0.12, "error accent of %s: hue %.1f chroma %.3f", hex(seed).c_str(), e.h, e.C);
|
||||
}
|
||||
// a grey seed gives a grey scheme, a colourful one does not
|
||||
for (Rgb grey : {Rgb{128, 128, 128}, Rgb{0, 0, 0}, Rgb{255, 255, 255}, Rgb{40, 41, 40}}) {
|
||||
const Scheme s = generate(grey, Mode::Light);
|
||||
CHECK_MSG(toOklch(s.primary).C < 0.03, "grey seed %s gave chroma %.3f", hex(grey).c_str(), toOklch(s.primary).C);
|
||||
}
|
||||
CHECK(toOklch(generate({61, 174, 233}, Mode::Light).primary).C > 0.09);
|
||||
}
|
||||
|
||||
// --- parsers -------------------------------------------------------------
|
||||
{
|
||||
CHECK(parse::hexColor("#3DAEE9") == std::optional<Rgb>(Rgb{0x3D, 0xAE, 0xE9}));
|
||||
CHECK(parse::hexColor("3daee9") == std::optional<Rgb>(Rgb{0x3D, 0xAE, 0xE9}));
|
||||
CHECK(parse::hexColor(" #fff ") == std::optional<Rgb>(Rgb{255, 255, 255}));
|
||||
CHECK(!parse::hexColor("") && !parse::hexColor("#") && !parse::hexColor("#12") && !parse::hexColor("zzzzzz"));
|
||||
CHECK(!parse::hexColor("#3DAEE9FF") && !parse::hexColor("banana"));
|
||||
|
||||
CHECK(parse::kdeRgb("61,174,233") == std::optional<Rgb>(Rgb{61, 174, 233}));
|
||||
CHECK(parse::kdeRgb(" 61, 174, 233 ") == std::optional<Rgb>(Rgb{61, 174, 233}));
|
||||
CHECK(!parse::kdeRgb("300,0,0") && !parse::kdeRgb("1,2") && !parse::kdeRgb("1,2,3,4") && !parse::kdeRgb("a,b,c"));
|
||||
CHECK(!parse::kdeRgb("") && !parse::kdeRgb("-1,0,0") && !parse::kdeRgb("1 2 3"));
|
||||
|
||||
CHECK(parse::gnomeScheme("'prefer-dark'\n") == std::optional<Mode>(Mode::Dark));
|
||||
CHECK(parse::gnomeScheme("'prefer-light'") == std::optional<Mode>(Mode::Light));
|
||||
CHECK(!parse::gnomeScheme("'default'") && !parse::gnomeScheme("") && !parse::gnomeScheme("garbage"));
|
||||
CHECK(parse::gnomeAccent("'pink'\n") == std::optional<Rgb>(Rgb{0xd5, 0x61, 0x99}));
|
||||
CHECK(parse::gnomeAccent("'blue'") == std::optional<Rgb>(Rgb{0x35, 0x84, 0xe4}));
|
||||
CHECK(parse::gnomeAccent("'Slate'") == std::optional<Rgb>(Rgb{0x6f, 0x83, 0x96}));
|
||||
CHECK(!parse::gnomeAccent("'magenta'") && !parse::gnomeAccent(""));
|
||||
|
||||
// the real output of this machine
|
||||
CHECK(parse::portalScheme(kBusctl) == std::optional<Mode>(Mode::Light));
|
||||
CHECK(parse::portalAccent(kBusctl) == std::optional<Rgb>(Rgb{61, 174, 233}));
|
||||
CHECK(parse::portalScheme(kBusctlDark) == std::optional<Mode>(Mode::Dark));
|
||||
CHECK(parse::portalAccent(kBusctlDark) == std::optional<Rgb>(Rgb{128, 64, 255}));
|
||||
// gdbus prints it differently
|
||||
CHECK(parse::portalScheme(kGdbus) == std::optional<Mode>(Mode::Dark));
|
||||
CHECK(parse::portalAccent(kGdbus) == std::optional<Rgb>(Rgb{128, 64, 255}));
|
||||
// no preference, no accent, missing keys, a different order, nonsense
|
||||
CHECK(!parse::portalScheme("\"color-scheme\" u 0 \"x\" u 1"));
|
||||
CHECK(!parse::portalAccent("\"accent-color\" (ddd) -1 -1 -1"));
|
||||
CHECK(!parse::portalAccent("\"accent-color\" (ddd) 2 0.5 0.5"));
|
||||
CHECK(!parse::portalAccent("\"accent-color\" (ddd) 0.5 0.5"));
|
||||
CHECK(!parse::portalScheme("") && !parse::portalAccent("") && !parse::portalScheme("nothing here"));
|
||||
CHECK(parse::portalAccent("\"accent-color\" (ddd) 0.1 0.2 0.3 \"color-scheme\" u 1") == std::optional<Rgb>(Rgb{26, 51, 77}));
|
||||
CHECK(parse::portalScheme("\"accent-color\" (ddd) 0.1 0.2 0.3 \"color-scheme\" u 1") == std::optional<Mode>(Mode::Dark));
|
||||
// the 32 of uint32 and the 3 of (ddd) are not values
|
||||
CHECK(parse::portalScheme("'color-scheme': <uint32 2>") == std::optional<Mode>(Mode::Light));
|
||||
}
|
||||
{
|
||||
const std::string light =
|
||||
"[General]\nAccentColor=61,174,233\nColorScheme=BreezeLight\n[Colors:Window]\nBackgroundNormal=239,240,241\n";
|
||||
Appearance a = parse::kdeGlobals(light);
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{61, 174, 233}) && a.mode == std::optional<Mode>(Mode::Light));
|
||||
// no accent set: the selection colour of the scheme
|
||||
a = parse::kdeGlobals("[Colors:Selection]\nBackgroundNormal=100,50,200\n[Colors:Window]\nBackgroundNormal=35,38,41\n");
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{100, 50, 200}) && a.mode == std::optional<Mode>(Mode::Dark));
|
||||
// a broken accent falls back to the selection colour
|
||||
a = parse::kdeGlobals("[General]\nAccentColor=abc\n[Colors:Selection]\nBackgroundNormal=1,2,3\n");
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{1, 2, 3}));
|
||||
// the scheme name when there are no colours
|
||||
CHECK(parse::kdeGlobals("[General]\nColorScheme=BreezeDark\n").mode == std::optional<Mode>(Mode::Dark));
|
||||
CHECK(parse::kdeGlobals("[General]\nColorScheme=Foo\n").mode == std::optional<Mode>(Mode::Light));
|
||||
// comments, blank lines and garbage are ignored, keys of other sections are not mixed up
|
||||
a = parse::kdeGlobals("# comment\n\nnot a line\n[Other]\nAccentColor=9,9,9\n[Colors:View]\nBackgroundNormal=0,0,0\n");
|
||||
CHECK(!a.accent && !a.mode);
|
||||
CHECK(!parse::kdeGlobals("").accent && !parse::kdeGlobals("").mode);
|
||||
CHECK(parse::gtkSettings("[Settings]\ngtk-application-prefer-dark-theme=1\n") == std::optional<Mode>(Mode::Dark));
|
||||
CHECK(parse::gtkSettings("gtk-theme-name=Orchis-Dark\n") == std::optional<Mode>(Mode::Dark));
|
||||
CHECK(parse::gtkSettings("gtk-theme-name=Adwaita\n") == std::optional<Mode>(Mode::Light));
|
||||
CHECK(!parse::gtkSettings("gtk-font-name=Sans 10\n"));
|
||||
}
|
||||
|
||||
// --- the sources, through a fake system ------------------------------------
|
||||
{
|
||||
char tmpl[] = "/tmp/monet_test.XXXXXX";
|
||||
const fs::path dir = mkdtemp(tmpl);
|
||||
const std::string kdeFile =
|
||||
"[General]\nAccentColor=10,200,30\n[Colors:Window]\nBackgroundNormal=30,30,30\n";
|
||||
|
||||
{ // KDE: the portal answers, it is used and kdeglobals is not needed
|
||||
Fake f; f.configHome = dir / "a"; f.env["XDG_CURRENT_DESKTOP"] = "KDE";
|
||||
f.programs["busctl"] = kBusctl;
|
||||
writeFile(f.configHome / "kdeglobals", kdeFile);
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{61, 174, 233}) && a.accentSource == "portal");
|
||||
CHECK(a.mode == std::optional<Mode>(Mode::Light) && a.modeSource == "portal");
|
||||
}
|
||||
{ // KDE without a portal: the file
|
||||
Fake f; f.configHome = dir / "b"; f.env["XDG_CURRENT_DESKTOP"] = "KDE";
|
||||
writeFile(f.configHome / "kdeglobals", kdeFile);
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{10, 200, 30}) && a.accentSource == "kde");
|
||||
CHECK(a.mode == std::optional<Mode>(Mode::Dark) && a.modeSource == "kde");
|
||||
}
|
||||
{ // GNOME without a portal: gsettings
|
||||
Fake f; f.configHome = dir / "c"; f.env["XDG_CURRENT_DESKTOP"] = "ubuntu:GNOME";
|
||||
f.programs["gsettings"] = "'prefer-dark'\n";
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.modeSource == "gnome" && a.mode == std::optional<Mode>(Mode::Dark));
|
||||
}
|
||||
{ // a leftover gsettings value on KDE must not beat the desktop's own answer
|
||||
Fake f; f.configHome = dir / "d"; f.env["XDG_CURRENT_DESKTOP"] = "KDE";
|
||||
f.programs["gsettings"] = "'pink'\n";
|
||||
writeFile(f.configHome / "kdeglobals", kdeFile);
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{10, 200, 30}) && a.accentSource == "kde");
|
||||
CHECK(std::find(f.called.begin(), f.called.end(), "gsettings") == f.called.end());
|
||||
}
|
||||
{ // the override wins over everything
|
||||
Fake f; f.configHome = dir / "e"; f.env["XDG_CURRENT_DESKTOP"] = "KDE";
|
||||
f.env["MONET_SEED"] = "#112233"; f.env["MONET_MODE"] = "Light";
|
||||
f.programs["busctl"] = kBusctlDark;
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{0x11, 0x22, 0x33}) && a.accentSource == "override");
|
||||
CHECK(a.mode == std::optional<Mode>(Mode::Light) && a.modeSource == "override");
|
||||
}
|
||||
{ // a broken override is ignored, not fatal
|
||||
Fake f; f.configHome = dir / "f"; f.env["MONET_SEED"] = "banana"; f.env["MONET_MODE"] = "purple";
|
||||
f.programs["busctl"] = kBusctlDark;
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.accentSource == "portal" && a.modeSource == "portal");
|
||||
}
|
||||
{ // each value comes from the first source that has it
|
||||
Fake f; f.configHome = dir / "g"; f.env["XDG_CURRENT_DESKTOP"] = "KDE";
|
||||
f.programs["busctl"] = "\"color-scheme\" u 1";
|
||||
writeFile(f.configHome / "kdeglobals", "[General]\nAccentColor=1,2,3\n[Colors:Window]\nBackgroundNormal=255,255,255\n");
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.mode == std::optional<Mode>(Mode::Dark) && a.modeSource == "portal");
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{1, 2, 3}) && a.accentSource == "kde");
|
||||
}
|
||||
{ // the fallback: nothing is available at all
|
||||
Fake f; f.configHome = dir / "h";
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.accent == std::optional<Rgb>(kDefaultSeed) && a.accentSource == "fallback");
|
||||
CHECK(a.mode == std::optional<Mode>(kDefaultMode) && a.modeSource == "fallback");
|
||||
}
|
||||
{ // and it is there even when the list of sources is empty or every source fails
|
||||
Fake f; f.configHome = dir / "i";
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, {});
|
||||
CHECK(a.accent && a.mode && a.accentSource == "fallback");
|
||||
Source broken{"broken", [](const Context &) { return Appearance{}; }};
|
||||
const Appearance b = detect(c, {broken, broken});
|
||||
CHECK(b.accent == std::optional<Rgb>(kDefaultSeed) && b.modeSource == "fallback");
|
||||
const Scheme s = fromDesktop(c);
|
||||
CHECK(s.seed == kDefaultSeed && s.mode == kDefaultMode);
|
||||
}
|
||||
{ // a source of the program's own is asked first
|
||||
Fake f; f.configHome = dir / "j"; f.programs["busctl"] = kBusctl;
|
||||
Context c = f.context();
|
||||
Source mine{"mine", [](const Context &) { Appearance a; a.accent = Rgb{9, 8, 7}; return a; }};
|
||||
const Scheme s = fromDesktop(c, {mine});
|
||||
CHECK(s.seed == (Rgb{9, 8, 7}) && s.mode == Mode::Light);
|
||||
}
|
||||
{ // an unknown desktop does not consult the desktop specific sources
|
||||
Fake f; f.configHome = dir / "k";
|
||||
f.programs["gsettings"] = "'prefer-dark'";
|
||||
Context c = f.context();
|
||||
detect(c, defaultSources(c));
|
||||
CHECK(std::find(f.called.begin(), f.called.end(), "gsettings") == f.called.end());
|
||||
}
|
||||
{ // the list of sources per desktop
|
||||
auto names = [&](const char *desktop) {
|
||||
Fake f; f.configHome = dir / "n";
|
||||
if (desktop) f.env["XDG_CURRENT_DESKTOP"] = desktop;
|
||||
std::string out;
|
||||
for (const auto &s : defaultSources(f.context())) out += s.name + " ";
|
||||
return out;
|
||||
};
|
||||
CHECK(names("KDE") == "override portal kde gtk ");
|
||||
CHECK(names("GNOME") == "override portal gnome gtk ");
|
||||
CHECK(names("X-Cinnamon") == "override portal gnome gtk ");
|
||||
CHECK(names("sway") == "override portal gtk ");
|
||||
CHECK(names(nullptr) == "override portal gtk ");
|
||||
}
|
||||
{ // gtk settings give the mode on a desktop with nothing better
|
||||
Fake f; f.configHome = dir / "o";
|
||||
writeFile(f.configHome / "gtk-3.0/settings.ini", "[Settings]\ngtk-application-prefer-dark-theme=1\n");
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.mode == std::optional<Mode>(Mode::Dark) && a.modeSource == "gtk");
|
||||
CHECK(a.accentSource == "fallback");
|
||||
}
|
||||
{ // the portal is read through gdbus when there is no busctl
|
||||
Fake f; f.configHome = dir / "p"; f.programs["gdbus"] = kGdbus;
|
||||
Context c = f.context();
|
||||
const Appearance a = detect(c, defaultSources(c));
|
||||
CHECK(a.accent == std::optional<Rgb>(Rgb{128, 64, 255}) && a.accentSource == "portal" && a.mode == std::optional<Mode>(Mode::Dark));
|
||||
}
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
|
||||
// --- running real programs -------------------------------------------------
|
||||
{
|
||||
auto ms = [](auto from) { return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - from).count(); };
|
||||
CHECK(detail::runProgram({"echo", "hi"}, 2000) == std::optional<std::string>("hi\n"));
|
||||
CHECK(!detail::runProgram({"definitely-not-a-program-xyz"}, 2000));
|
||||
CHECK(!detail::runProgram({"false"}, 2000));
|
||||
CHECK(!detail::runProgram({}, 2000));
|
||||
CHECK(detail::runProgram({"cat"}, 2000) == std::optional<std::string>("")); // stdin is closed
|
||||
auto t = std::chrono::steady_clock::now();
|
||||
CHECK(!detail::runProgram({"sleep", "5"}, 150));
|
||||
CHECK_MSG(ms(t) < 1500, "timeout took %lld ms", static_cast<long long>(ms(t)));
|
||||
t = std::chrono::steady_clock::now();
|
||||
CHECK(!detail::runProgram({"head", "-c", "3000000", "/dev/zero"}, 5000)); // far more than an answer
|
||||
CHECK_MSG(ms(t) < 3000, "oversized output took %lld ms", static_cast<long long>(ms(t)));
|
||||
// the real system: the call must come back, with or without a desktop
|
||||
t = std::chrono::steady_clock::now();
|
||||
const Appearance real = detect(Context::system(), defaultSources(Context::system()));
|
||||
CHECK(real.accent && real.mode && !real.accentSource.empty());
|
||||
CHECK_MSG(ms(t) < 2500, "detecting on this machine took %lld ms", static_cast<long long>(ms(t)));
|
||||
std::printf("this machine: accent %s from %s, %s from %s, %lld ms\n", hex(*real.accent).c_str(), real.accentSource.c_str(),
|
||||
*real.mode == Mode::Dark ? "dark" : "light", real.modeSource.c_str(), static_cast<long long>(ms(t)));
|
||||
}
|
||||
|
||||
std::printf(failures ? "%d check(s) failed\n" : "libmonet: all checks passed\n", failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Reference in new issue
Block a user