Cockatrice/cockatrice/src/interface/theme_config.cpp
Lukas Brübach 03a4a33ba5 [Icons] Author icon emphasis colors as palette roles
The emphasis levels were computed from the accent at paint time, so a
theme could not vary them: every theme got the same derived strong and
soft tones, and a semantic hue was pushed toward the accent whether the
author wanted that or not.

Give each family three authored roles instead. Icon/IconStrong/IconSoft
carry the neutral glyph ink and Positive/Negative gain the same Strong
and Soft companions. Emphasis becomes a lookup inside the family, and a
caller that names a variant pins that level at every emphasis. The paint
path returns the authored color verbatim; only the filled treatments keep
their saturation floor, because carrying a disc or a fill is a treatment
decision.

Shipped palettes author the full ladder for both schemes; the palette
editor exposes the new rows and the generator writes them. System keeps
deriving its neutral ink from the platform palette, since it ships no
palette of its own.
2026-11-08 15:42:51 +01:00

483 lines
13 KiB
C++

#include "theme_config.h"
#include <QByteArray>
#include <QChar>
#include <QDir>
#include <QFile>
#include <QFlags>
#include <QIODevice>
#include <QList>
#include <QMetaEnum>
#include <QPair>
#include <QStringLiteral>
#include <QTextStream>
#include <qnamespace.h>
#include <utility>
bool ThemeConfig::isEmpty() const
{
return colorScheme.isEmpty() && styleName.isEmpty();
}
QString ThemeConfig::toIni() const
{
QString out;
out += "[Appearance]\n";
out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme);
out += "\n[Style]\n";
out += QString("Name = %1\n").arg(styleName.isEmpty() ? "System" : styleName);
return out;
}
ThemeConfig ThemeConfig::fromThemeDir(const QString &themeDirPath)
{
ThemeConfig cfg;
if (themeDirPath.isEmpty()) {
return cfg;
}
QFile f(QDir(themeDirPath).absoluteFilePath("theme.cfg"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return cfg;
}
QString currentSection;
QTextStream in(&f);
while (!in.atEnd()) {
QString line = in.readLine().trimmed();
if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) {
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
currentSection = line.mid(1, line.length() - 2).trimmed();
continue;
}
int eq = line.indexOf('=');
if (eq < 0) {
continue;
}
QString key = line.left(eq).trimmed();
QString value = line.mid(eq + 1).trimmed();
if (currentSection.compare("Appearance", Qt::CaseInsensitive) == 0) {
if (key.compare("ColorScheme", Qt::CaseInsensitive) == 0) {
cfg.colorScheme = value;
}
} else if (currentSection.compare("Style", Qt::CaseInsensitive) == 0) {
if (key.compare("Name", Qt::CaseInsensitive) == 0) {
cfg.styleName = value;
}
}
}
return cfg;
}
bool ThemeConfig::save(const QString &themeDirPath) const
{
if (themeDirPath.isEmpty()) {
return false;
}
QDir dir(themeDirPath);
if (!dir.exists()) {
dir.mkpath(".");
}
QFile f(dir.absoluteFilePath("theme.cfg"));
if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
return false;
}
QTextStream out(&f);
out << toIni();
return true;
}
bool PaletteConfig::hasPalette() const
{
return !colors.isEmpty() || !appColors.isEmpty();
}
QString PaletteConfig::toToml() const
{
QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>();
QString out;
static const QList<QPair<QPalette::ColorGroup, QString>> groups = {
{QPalette::Active, "Palette"},
{QPalette::Disabled, "Palette.Disabled"},
{QPalette::Inactive, "Palette.Inactive"},
};
for (const auto &[group, sectionName] : groups) {
if (!colors.contains(group)) {
continue;
}
out += QString("[%1]\n").arg(sectionName);
const auto &roleMap = colors[group];
for (auto it = roleMap.cbegin(); it != roleMap.cend(); ++it) {
const char *roleName = roleEnum.valueToKey(it.key());
if (!roleName) {
continue;
}
out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb));
}
out += "\n";
}
if (!appColors.isEmpty()) {
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
out += "[AppColors]\n";
for (auto it = appColors.cbegin(); it != appColors.cend(); ++it) {
const char *roleName = appEnum.valueToKey(it.key());
if (!roleName) {
continue;
}
out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb));
}
out += "\n";
}
return out;
}
QString PaletteConfig::fileName(const QString &colorScheme)
{
return colorScheme.compare("Dark", Qt::CaseInsensitive) == 0 ? "palette-dark.toml" : "palette-light.toml";
}
PaletteConfig PaletteConfig::fromFile(const QString &filePath)
{
PaletteConfig cfg;
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return cfg;
}
QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>();
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
QString currentSection;
QPalette::ColorGroup currentGroup = QPalette::Active;
QTextStream in(&f);
while (!in.atEnd()) {
QString line = in.readLine().trimmed();
if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) {
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
currentSection = line.mid(1, line.length() - 2).trimmed();
if (currentSection.startsWith("Palette", Qt::CaseInsensitive)) {
int dot = currentSection.indexOf('.');
QString groupStr = (dot >= 0) ? currentSection.mid(dot + 1) : "Active";
if (groupStr.compare("Disabled", Qt::CaseInsensitive) == 0) {
currentGroup = QPalette::Disabled;
} else if (groupStr.compare("Inactive", Qt::CaseInsensitive) == 0) {
currentGroup = QPalette::Inactive;
} else {
currentGroup = QPalette::Active;
}
}
continue;
}
int eq = line.indexOf('=');
if (eq < 0) {
continue;
}
QString key = line.left(eq).trimmed();
QString value = line.mid(eq + 1).trimmed();
// Strip inline comments if preceded by whitespace
for (int i = 1; i < value.size(); ++i) {
if (value[i] == '#' && value[i - 1].isSpace()) {
value = value.left(i).trimmed();
break;
}
}
QColor color(value);
if (!color.isValid()) {
continue;
}
if (currentSection.compare("AppColors", Qt::CaseInsensitive) == 0) {
if (key.startsWith("AppColor::")) {
key = key.mid(10);
}
int appRoleInt = appEnum.keyToValue(key.toUtf8().constData());
if (appRoleInt >= 0) {
cfg.appColors[static_cast<AppColor::Role>(appRoleInt)] = color;
}
continue;
}
if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) {
continue;
}
if (key.startsWith("QPalette::")) {
key = key.mid(10);
}
int roleInt = roleEnum.keyToValue(key.toUtf8().constData());
if (roleInt < 0) {
continue;
}
cfg.colors[currentGroup][static_cast<QPalette::ColorRole>(roleInt)] = color;
}
return cfg;
}
PaletteConfig PaletteConfig::fromScheme(const QString &themeDirPath, const QString &colorScheme)
{
if (themeDirPath.isEmpty()) {
return {};
}
return fromFile(QDir(themeDirPath).absoluteFilePath(fileName(colorScheme)));
}
PaletteConfig PaletteConfig::fromDefault(const QString &themeDirPath, const QString &colorScheme)
{
if (themeDirPath.isEmpty()) {
return {};
}
QDir dir(themeDirPath);
bool wantDark = colorScheme.compare("Dark", Qt::CaseInsensitive) == 0;
// Only the default file matching the requested scheme is used. Falling back
// to the opposite scheme's default would silently apply dark colours to a
// "Light" scheme (or vice versa). Callers already fall back to the OS /
// application palette when no default palette is available.
return fromFile(dir.absoluteFilePath(wantDark ? "palette-default-dark.toml" : "palette-default-light.toml"));
}
QPalette PaletteConfig::apply(QPalette base) const
{
for (auto git = colors.cbegin(); git != colors.cend(); ++git) {
for (auto rit = git.value().cbegin(); rit != git.value().cend(); ++rit) {
base.setColor(git.key(), rit.key(), rit.value());
}
}
return base;
}
bool FontConfig::hasFonts() const
{
return !fonts.isEmpty();
}
QString FontConfig::toToml() const
{
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
QString entries;
for (auto it = fonts.cbegin(); it != fonts.cend(); ++it) {
const char *roleName = roleEnum.valueToKey(it.key());
if (!roleName) {
continue;
}
QString family = it.value().family().trimmed();
int pointSize = it.value().pointSize();
if (family.isEmpty() || pointSize <= 0) {
continue;
}
entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize);
}
if (entries.isEmpty()) {
return {};
}
return "[Fonts]\n" + entries + "\n";
}
QString FontConfig::fileName()
{
return "fonts.toml";
}
FontConfig FontConfig::fromFile(const QString &filePath)
{
FontConfig cfg;
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return cfg;
}
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
QString currentSection;
QTextStream in(&f);
while (!in.atEnd()) {
QString line = in.readLine().trimmed();
if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) {
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
currentSection = line.mid(1, line.length() - 2).trimmed();
continue;
}
if (currentSection.compare("Fonts", Qt::CaseInsensitive) != 0) {
continue;
}
int eq = line.indexOf('=');
if (eq < 0) {
continue;
}
QString key = line.left(eq).trimmed();
QString value = line.mid(eq + 1).trimmed();
// Strip inline comments if preceded by whitespace
for (int i = 1; i < value.size(); ++i) {
if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) {
value = value.left(i).trimmed();
break;
}
}
// Strip surrounding quotes, if any
if (value.size() >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) {
value = value.mid(1, value.size() - 2).trimmed();
}
int comma = value.lastIndexOf(',');
if (comma < 0) {
continue;
}
QString family = value.left(comma).trimmed();
// Tolerate a partially-quoted family, e.g. "Inter", 10
if (family.size() >= 2 &&
((family.startsWith('"') && family.endsWith('"')) || (family.startsWith('\'') && family.endsWith('\'')))) {
family = family.mid(1, family.size() - 2).trimmed();
}
bool ok = false;
int pointSize = value.mid(comma + 1).trimmed().toInt(&ok);
if (!ok || family.isEmpty() || pointSize <= 0) {
continue;
}
if (key.startsWith("AppFont::")) {
key = key.mid(QStringLiteral("AppFont::").size());
}
int roleInt = roleEnum.keyToValue(key.toUtf8().constData());
if (roleInt < 0) {
continue;
}
// Canonical family + point size only: a fresh QFont carries default
// (non-bold, non-italic) weight/style, so nothing caller-side leaks in.
// No validation against installed fonts; Qt substitution applies.
QFont font(family);
font.setPointSize(pointSize);
cfg.fonts[static_cast<AppFont::Role>(roleInt)] = font;
}
return cfg;
}
FontConfig FontConfig::fromThemeDir(const QString &themeDirPath)
{
if (themeDirPath.isEmpty()) {
return {};
}
return fromFile(QDir(themeDirPath).absoluteFilePath(fileName()));
}
QColor defaultAppColor(AppColor::Role role, bool dark)
{
switch (role) {
case AppColor::AccentStrong:
case AppColor::AccentSoft:
// The neutral icon family is palette-derived like the accent, so a
// theme without a palette of its own (System) keeps following the
// platform instead of pinning one scheme's ink.
case AppColor::Icon:
case AppColor::IconStrong:
case AppColor::IconSoft:
return {};
case AppColor::Positive:
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b"));
case AppColor::PositiveStrong:
return QColor(dark ? QStringLiteral("#99e2ae") : QStringLiteral("#104a22"));
case AppColor::PositiveSoft:
return QColor(dark ? QStringLiteral("#5e8873") : QStringLiteral("#5d7f6f"));
case AppColor::Negative:
return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e"));
case AppColor::NegativeStrong:
return QColor(dark ? QStringLiteral("#ffb3ac") : QStringLiteral("#7c1b15"));
case AppColor::NegativeSoft:
return QColor(dark ? QStringLiteral("#9b7678") : QStringLiteral("#9a6c6f"));
}
return {};
}