Cockatrice/cockatrice/src/interface/theme_manager.cpp
BruebachL 12f0f59453
[Settings] Split cache_settings monolith into multiple SettingsManager sub-classes (#7050)
* [Settings] Split cache_settings into multiple files

Took 9 minutes

Took 4 minutes

* [Settings] Fwd declare settings classes in cache_settings

Took 15 minutes

* Fix oracle includes.

Took 8 minutes

* Address comments, fix windows CI

Took 8 minutes

* fix copy constructor visibility

Took 3 minutes

* lint

Took 2 minutes

* Fix native format tests.

Took 5 minutes

* Remove test header guard

Took 4 seconds

* Remove tests invalid in CI environ

Took 24 seconds

* Adjust to rebase.

Took 11 minutes

* Change settings file name.

Took 8 minutes

---------

Co-authored-by: Lukas Brübach <lukas.bruebach@bdosecurity.de>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-07-27 11:25:39 +02:00

477 lines
15 KiB
C++

#include "theme_manager.h"
#include "../../client/settings/cache_settings.h"
#include <QApplication>
#include <QColor>
#include <QDebug>
#include <QLibraryInfo>
#include <QMap>
#include <QMetaEnum>
#include <QPalette>
#include <QPixmapCache>
#include <QStandardPaths>
#include <QString>
#include <QStyle>
#include <QStyleFactory>
#include <QStyleHints>
#include <QWidget>
#include <Qt>
#include <libcockatrice/settings/paths_settings.h>
#define NONE_THEME_NAME "Default"
#define FUSION_THEME_NAME "Fusion"
#define STYLE_CSS_NAME "style.css"
#define HANDZONE_BG_NAME "handzone"
#define PLAYERZONE_BG_NAME "playerzone"
#define STACKZONE_BG_NAME "stackzone"
#define TABLEZONE_BG_NAME "tablezone"
static const QColor HANDZONE_BG_DEFAULT = QColor(80, 100, 50);
static const QColor TABLEZONE_BG_DEFAULT = QColor(70, 50, 100);
static const QColor PLAYERZONE_BG_DEFAULT = QColor(200, 200, 200);
static const QColor STACKZONE_BG_DEFAULT = QColor(113, 43, 43);
static const QStringList DEFAULT_RESOURCE_PATHS = {":/resources"};
struct PaletteColorInfo
{
QPalette::ColorGroup group;
QPalette::ColorRole role;
QColor color;
};
[[maybe_unused]] static inline QList<PaletteColorInfo> queryAllPaletteColors(const QPalette &palette = qApp->palette())
{
QList<PaletteColorInfo> colors;
// Iterate through relevant color groups (Active, Disabled, Inactive)
const QList<QPalette::ColorGroup> groups = {QPalette::Active, QPalette::Disabled, QPalette::Inactive};
for (auto group : groups) {
// Iterate through all color roles (excluding NoRole and NColorRoles)
for (int r = 0; r < QPalette::NColorRoles; ++r) {
auto role = static_cast<QPalette::ColorRole>(r);
if (role == QPalette::NoRole) {
continue;
}
PaletteColorInfo info;
info.group = group;
info.role = role;
info.color = palette.color(group, role);
colors.append(info);
}
}
return colors;
}
// Pretty print version
[[maybe_unused]] static inline void printPaletteColors(const QPalette &palette = qApp->palette())
{
QMetaEnum groupEnum = QMetaEnum::fromType<QPalette::ColorGroup>();
QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>();
const QList<QPalette::ColorGroup> groups = {QPalette::Active, QPalette::Disabled, QPalette::Inactive};
for (auto group : groups) {
qInfo() << "\n===========" << groupEnum.valueToKey(group) << "===========";
for (int r = 0; r < QPalette::NColorRoles; ++r) {
auto role = static_cast<QPalette::ColorRole>(r);
if (role == QPalette::NoRole) {
continue;
}
QColor color = palette.color(group, role);
qInfo().nospace() << qPrintable(QString("%1").arg(roleEnum.valueToKey(role), -20)) << " : "
<< qPrintable(color.name(QColor::HexArgb)) << " (RGBA: " << color.red() << ", "
<< color.green() << ", " << color.blue() << ", " << color.alpha() << ")";
}
}
}
ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
{
defaultStyleName = qApp->style()->objectName();
//! \todo Workaround for windows11 style being broken.
if (defaultStyleName == "windows11") {
defaultStyleName = "windowsvista";
}
// Capture the untouched application palette before any theme is applied.
defaultPalette = qApp->palette();
ensureThemeDirectoryExists();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
defaultPalette = qApp->palette();
themeChangedSlot();
});
#endif
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ThemeManager::themeChangedSlot);
themeChangedSlot();
}
void ThemeManager::ensureThemeDirectoryExists()
{
if (SettingsCache::instance().getThemeName().isEmpty() ||
!getAvailableThemes().contains(SettingsCache::instance().getThemeName())) {
qCInfo(ThemeManagerLog) << "Theme name not set, setting default value";
SettingsCache::instance().setThemeName(NONE_THEME_NAME);
}
}
bool ThemeManager::isDarkMode(const QString &themeDirPath)
{
ThemeConfig themeConfig = ThemeConfig::fromThemeDir(themeDirPath);
if (themeConfig.colorScheme.compare("Dark", Qt::CaseInsensitive) == 0) {
return true;
} else if (themeConfig.colorScheme.compare("Light", Qt::CaseInsensitive) == 0) {
return false;
} else {
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
bool osDark = (QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark);
#else
bool osDark = false;
#endif
return osDark;
}
}
bool ThemeManager::isBuiltInTheme()
{
const auto themeName = SettingsCache::instance().getThemeName();
return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME;
}
// System (read-only) themes location, relative to the application binary.
static QString systemThemesBasePath()
{
QString base = qApp->applicationDirPath();
#ifdef Q_OS_MAC
base += "/../Resources/themes";
#elif defined(Q_OS_WIN)
base += "/themes";
#else // linux
base += "/../share/cockatrice/themes";
#endif
return base;
}
QStringMap &ThemeManager::getAvailableThemes()
{
QDir dir;
availableThemes.clear();
// load themes from user profile dir
dir.setPath(SettingsCache::instance().paths().getThemesPath());
// add default value
availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default"));
availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion"));
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
if (!availableThemes.contains(themeName)) {
availableThemes.insert(themeName, dir.absoluteFilePath(themeName));
}
}
// load themes from cockatrice system dir
dir.setPath(systemThemesBasePath());
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
if (!availableThemes.contains(themeName)) {
availableThemes.insert(themeName, dir.absoluteFilePath(themeName));
}
}
return availableThemes;
}
QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor)
{
QBrush brush;
QPixmap tmp = QPixmap("theme:zones/" + fileName);
if (tmp.isNull()) {
brush.setColor(fallbackColor);
brush.setStyle(Qt::SolidPattern);
} else {
brush.setTexture(tmp);
}
return brush;
}
QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush)
{
QBrush brush;
QPixmap tmp = QPixmap("theme:zones/" + fileName);
if (tmp.isNull()) {
brush = fallbackBrush;
} else {
brush.setTexture(tmp);
}
return brush;
}
ThemeConfig ThemeManager::loadGlobalConfig(const QString &themeDirPath)
{
return ThemeConfig::fromThemeDir(themeDirPath);
}
bool ThemeManager::saveGlobalConfig(const QString &themeDirPath, const ThemeConfig &cfg)
{
return cfg.save(themeDirPath);
}
PaletteConfig ThemeManager::loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme)
{
if (themeDirPath.isEmpty()) {
return {};
}
return PaletteConfig::fromScheme(themeDirPath, colorScheme);
}
bool ThemeManager::savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg)
{
if (themeDirPath.isEmpty()) {
return false;
}
QDir dir(themeDirPath);
if (!dir.exists()) {
dir.mkpath(".");
}
QFile f(dir.absoluteFilePath(PaletteConfig::fileName(colorScheme)));
if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
return false;
}
QTextStream(&f) << cfg.toToml();
return true;
}
PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath,
const QString &themeName,
const QString &colorScheme)
{
PaletteConfig cfg = PaletteConfig::fromDefault(themeDirPath, colorScheme);
if (!cfg.hasPalette()) {
// The shipped default may live in the system theme directory rather
// than the resolved (user) theme directory, so built-in themes still
// get their curated defaults.
cfg = PaletteConfig::fromDefault(QDir(systemThemesBasePath()).absoluteFilePath(themeName), colorScheme);
}
return cfg;
}
void ThemeManager::setColorScheme(const QString &scheme)
{
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.colorScheme = scheme;
cfg.save(dirPath);
reloadCurrentTheme();
}
void ThemeManager::setStyleName(const QString &styleName)
{
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.styleName = styleName;
cfg.save(dirPath);
reloadCurrentTheme();
}
void ThemeManager::reloadCurrentTheme()
{
themeChangedSlot();
}
void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &scheme)
{
const QString themeName = SettingsCache::instance().getThemeName();
const QString dirPath = getAvailableThemes().value(themeName);
const ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath);
applyStyleAndPalette(themeName, themeCfg, cfg, scheme);
}
void ThemeManager::applyStyleAndPalette(const QString &themeName,
const ThemeConfig &themeCfg,
const PaletteConfig &palCfg,
const QString &activeScheme)
{
#if (QT_VERSION < QT_VERSION_CHECK(6, 5, 0))
Q_UNUSED(activeScheme)
#endif
QString styleName = themeCfg.styleName;
if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) {
if (themeName == FUSION_THEME_NAME) {
styleName = "Fusion";
} else {
styleName = defaultStyleName;
}
}
QStyle *style = QStyleFactory::create(styleName);
if (!style) {
style = QStyleFactory::create(defaultStyleName);
}
// Base palette
QPalette base;
if (styleName.compare("Fusion", Qt::CaseInsensitive) == 0) {
base = style->standardPalette();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
if (activeScheme == "Dark") {
base.setColor(QPalette::AlternateBase, QColor(53, 53, 53));
}
#endif
} else {
// Use the pristine startup palette rather than qApp->palette(): the
// latter may already carry a previously-applied custom (e.g. dark)
// palette, which would otherwise persist when switching to a scheme
// that supplies no palette of its own.
base = defaultPalette;
}
// Overlay custom palette colours
if (palCfg.hasPalette()) {
base = palCfg.apply(base);
}
// Palette BEFORE style — setStyle() triggers a synchronous repolish of all
// widgets immediately. If the palette isn't set yet at that point, every
// widget gets polished against the stale colours, requiring a second apply
// to fully resolve. Setting palette first means setStyle's repolish cascade
// already sees the correct colours.
qApp->setPalette(base);
qApp->setStyle(style);
// Force every widget to re-polish and repaint immediately rather than
// waiting for natural expose events, which produces a patchwork of old
// and new colours during a live preview.
// Note: we do NOT call widget->setPalette(base) here — qApp->setPalette()
// already propagates to all widgets that haven't explicitly overridden their
// palette (WA_SetPalette not set). Calling it unconditionally would clobber
// intentional per-widget palette customisations across the whole app.
for (QWidget *widget : qApp->allWidgets()) {
style->unpolish(widget);
style->polish(widget);
widget->update();
}
}
void ThemeManager::themeChangedSlot()
{
QString themeName = SettingsCache::instance().getThemeName();
QString dirPath = getAvailableThemes().value(themeName);
currentThemePath = dirPath;
QDir dir(dirPath);
// CSS
if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) {
qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME));
} else {
qApp->setStyleSheet("");
}
// load theme.cfg for style + scheme preference
ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath);
// Resolve active scheme:
// theme.cfg says Dark/Light → use that
// theme.cfg says System or is absent → follow the OS
QString activeScheme = isDarkMode(dirPath) ? "Dark" : "Light";
// ── Load palette: custom first, then theme default ────────────────────
PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme);
if (!palette.hasPalette()) {
palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme);
}
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);
QStringList resources;
if (!dirPath.isEmpty()) {
resources << dir.absolutePath();
}
// When the resolved dir is a user copy (e.g. user/<theme>), also
// include the system theme dir as a fallback so shipped assets like
// zones/*.png and style.css still resolve for themes that ship only
// those files (e.g. Leather, Plasma, Fabric, VelvetMarble).
const QString sysPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName);
if (sysPath != dirPath && QDir(sysPath).exists()) {
resources << sysPath;
}
resources << DEFAULT_RESOURCE_PATHS;
QDir::setSearchPaths("theme", resources);
brushes[Role::Hand] = loadBrush(HANDZONE_BG_NAME, HANDZONE_BG_DEFAULT);
brushes[Role::Table] = loadBrush(TABLEZONE_BG_NAME, TABLEZONE_BG_DEFAULT);
brushes[Role::Player] = loadBrush(PLAYERZONE_BG_NAME, PLAYERZONE_BG_DEFAULT);
brushes[Role::Stack] = loadBrush(STACKZONE_BG_NAME, STACKZONE_BG_DEFAULT);
for (auto &brushCache : brushesCache) {
brushCache.clear();
}
QPixmapCache::clear();
emit themeChanged();
}
static QString roleBgName(ThemeManager::Role role)
{
switch (role) {
case ThemeManager::Hand:
return HANDZONE_BG_NAME;
case ThemeManager::Player:
return PLAYERZONE_BG_NAME;
case ThemeManager::Stack:
return STACKZONE_BG_NAME;
case ThemeManager::Table:
return TABLEZONE_BG_NAME;
default:
Q_ASSERT(false);
return {};
}
}
QBrush &ThemeManager::getBgBrush(Role role)
{
return brushes[role];
}
QBrush ThemeManager::getExtraBgBrush(Role role, int zoneId)
{
if (zoneId <= 0) {
return getBgBrush(role);
}
QBrushMap &brushCache = brushesCache[role];
if (!brushCache.contains(zoneId)) {
QBrush brush = loadExtraBrush(roleBgName(role) + QString::number(zoneId), getBgBrush(role));
brushCache.insert(zoneId, brush);
return brush;
}
return brushCache.value(zoneId);
}