mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-12 21:10:01 -08:00
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.
483 lines
13 KiB
C++
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 {};
|
|
}
|