diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp
index 1b9be1dd4..c4cb189af 100644
--- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp
+++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp
@@ -19,13 +19,15 @@
PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent)
: QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName)
{
+ userThemeDirPath = ThemeManager::userThemeDirFor(themeName);
+
setMinimumSize(740, 220);
setupUi();
// Load both scheme configs upfront so switching is instant
loadSchemes();
- loadedScheme = themeManager->isDarkMode(themeDirPath) ? "Dark" : "Light";
+ loadedScheme = themeManager->isDarkMode(themeDirPath, userThemeDirPath) ? "Dark" : "Light";
schemeComboBox->blockSignals(true);
schemeComboBox->setCurrentText(loadedScheme);
@@ -33,7 +35,6 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
paletteGrid->loadPalette(workingConfig[loadedScheme]);
seedAccentFromScheme(loadedScheme);
-
retranslateUi();
}
@@ -162,15 +163,12 @@ void PaletteEditorDialog::retranslateUi()
setWindowTitle(tr("Palette Editor — %1").arg(themeName));
titleLabel->setText(tr("Palette Editor · %1").arg(themeName));
- // Revert button only makes sense when the theme ships default palette files
- const bool hasDefault = PaletteConfig::fromDefault(themeDirPath, "Light").hasPalette() ||
- PaletteConfig::fromDefault(themeDirPath, "Dark").hasPalette();
- revertButton->setEnabled(hasDefault);
- if (!hasDefault) {
- revertButton->setToolTip(tr("This theme ships no default palette files"));
- } else {
- revertButton->setToolTip(tr("Replace current colours with the theme author's defaults"));
- }
+ const bool hasUserOverride =
+ QFile::exists(QDir(userThemeDirPath).absoluteFilePath(PaletteConfig::fileName("Light"))) ||
+ QFile::exists(QDir(userThemeDirPath).absoluteFilePath(PaletteConfig::fileName("Dark")));
+ revertButton->setEnabled(hasUserOverride);
+ revertButton->setToolTip(hasUserOverride ? tr("Delete your custom palette and restore the shipped defaults")
+ : tr("No custom palette overrides exist for this theme"));
schemeComboBox->setToolTip(tr("Switch between the light and dark palette files"));
editingLabel->setText(tr("Editing:"));
@@ -195,8 +193,11 @@ void PaletteEditorDialog::loadSchemes()
{
const QStringList schemes = {"Light", "Dark"};
for (const QString &scheme : schemes) {
- PaletteConfig cfg = PaletteConfig::fromScheme(themeDirPath, scheme);
-
+ // user customisation → system palette → system default → app palette
+ PaletteConfig cfg = PaletteConfig::fromScheme(userThemeDirPath, scheme);
+ if (!cfg.hasPalette()) {
+ cfg = PaletteConfig::fromScheme(themeDirPath, scheme);
+ }
if (!cfg.hasPalette()) {
cfg = PaletteConfig::fromDefault(themeDirPath, scheme);
}
@@ -258,15 +259,17 @@ void PaletteEditorDialog::onSave()
PaletteConfig cfg = paletteGrid->currentPaletteConfig();
- if (!ThemeManager::savePaletteConfig(themeDirPath, loadedScheme, cfg)) {
- QMessageBox::warning(this, tr("Save failed"),
- tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), themeDirPath));
+ if (!ThemeManager::savePaletteConfig(userThemeDirPath, loadedScheme, cfg)) {
+ QMessageBox::warning(
+ this, tr("Save failed"),
+ tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), userThemeDirPath));
return;
}
+ // Record the active scheme in the user dir — never touch the system (read-only) dir
ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath);
globalCfg.colorScheme = loadedScheme;
- globalCfg.save(themeDirPath);
+ globalCfg.save(userThemeDirPath);
savedConfig[loadedScheme] = cfg;
workingConfig[loadedScheme] = cfg;
@@ -282,14 +285,45 @@ void PaletteEditorDialog::onReset()
void PaletteEditorDialog::onRevertToDefault()
{
- PaletteConfig def = PaletteConfig::fromDefault(themeDirPath, loadedScheme);
- if (!def.hasPalette()) {
- QMessageBox::information(this, tr("No default found"),
- tr("No default palette file found for the \"%1\" scheme.").arg(loadedScheme));
+ const QString filePath = QDir(userThemeDirPath).absoluteFilePath(PaletteConfig::fileName(loadedScheme));
+
+ if (!QFile::exists(filePath)) {
+ // Button should already be disabled in this state, but be defensive
return;
}
+
+ const auto reply =
+ QMessageBox::question(this, tr("Revert to default?"),
+ tr("This will permanently delete your custom \"%1\" palette for \"%2\".\n\nContinue?")
+ .arg(loadedScheme, themeName),
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
+ if (reply != QMessageBox::Yes) {
+ return;
+ }
+
+ QFile::remove(filePath);
+
+ // Reload via fallthrough: system palette → system default → app palette
+ PaletteConfig def = PaletteConfig::fromScheme(themeDirPath, loadedScheme);
+ if (!def.hasPalette()) {
+ def = PaletteConfig::fromDefault(themeDirPath, loadedScheme);
+ }
+ if (!def.hasPalette()) {
+ const QPalette appPal = qApp->palette();
+ for (auto group : {QPalette::Active, QPalette::Disabled, QPalette::Inactive}) {
+ for (int i = 0; i < QPalette::NColorRoles; ++i) {
+ auto role = static_cast(i);
+ if (role != QPalette::NoRole) {
+ def.colors[group][role] = appPal.color(group, role);
+ }
+ }
+ }
+ }
+
+ savedConfig[loadedScheme] = def;
workingConfig[loadedScheme] = def;
paletteGrid->loadPalette(def);
+ retranslateUi(); // update button enabled state
}
void PaletteEditorDialog::changeEvent(QEvent *e)
diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h
index cec4c1700..8da7c5589 100644
--- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h
+++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h
@@ -54,6 +54,7 @@ private:
QPushButton *revertButton = nullptr;
// State
+ QString userThemeDirPath;
QString themeDirPath;
QString themeName;
QString loadedScheme;
diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp
index 086845fe6..556ab10df 100644
--- a/cockatrice/src/interface/theme_manager.cpp
+++ b/cockatrice/src/interface/theme_manager.cpp
@@ -113,21 +113,26 @@ void ThemeManager::ensureThemeDirectoryExists()
}
}
-bool ThemeManager::isDarkMode(const QString &themeDirPath)
+bool ThemeManager::isDarkMode(const QString &themeDirPath, const QString &userDirPath)
{
- 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;
+ // User override takes precedence over system config
+ for (const QString &path : {userDirPath, themeDirPath}) {
+ if (path.isEmpty()) {
+ continue;
+ }
+ ThemeConfig cfg = ThemeConfig::fromThemeDir(path);
+ if (cfg.colorScheme.compare("Dark", Qt::CaseInsensitive) == 0) {
+ return true;
+ }
+ if (cfg.colorScheme.compare("Light", Qt::CaseInsensitive) == 0) {
+ return false;
+ }
}
+#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
+ return (QGuiApplication::styleHints()->colorScheme() == Qt::ColorScheme::Dark);
+#else
+ return false;
+#endif
}
bool ThemeManager::isBuiltInTheme()
@@ -137,42 +142,49 @@ bool ThemeManager::isBuiltInTheme()
return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME;
}
+QString ThemeManager::userThemeDirFor(const QString &themeName)
+{
+ return QDir(SettingsCache::instance().getThemesPath()).absoluteFilePath(themeName);
+}
+
QStringMap &ThemeManager::getAvailableThemes()
{
- QDir dir;
availableThemes.clear();
- // load themes from user profile dir
- dir.setPath(SettingsCache::instance().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(qApp->applicationDirPath() +
+ // ── 1. System themes (read-only, shipped with the application) ──────────
+ QDir sysDir(qApp->applicationDirPath() +
#ifdef Q_OS_MAC
"/../Resources/themes"
#elif defined(Q_OS_WIN)
"/themes"
-#else // linux
+#else
"/../share/cockatrice/themes"
#endif
);
+ for (const QString &name : sysDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
+ availableThemes.insert(name, sysDir.absoluteFilePath(name));
+ }
- for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
- if (!availableThemes.contains(themeName)) {
- availableThemes.insert(themeName, dir.absoluteFilePath(themeName));
+ // ── 2. User-only themes (AppData) ────────────────────────────────────────
+ // We only add themes that don't already exist in the system directory.
+ // Customisations to system themes are handled via the fallthrough read
+ // logic (userThemeDirFor → system path); we intentionally keep the system
+ // path in the map so shipped assets are always locatable.
+ QDir userDir(SettingsCache::instance().getThemesPath());
+ for (const QString &name : userDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
+ if (!availableThemes.contains(name)) {
+ availableThemes.insert(name, userDir.absoluteFilePath(name));
}
}
+ // ── 3. Ensure built-in sentinels always exist (dev builds without install)
+ if (!availableThemes.contains(NONE_THEME_NAME)) {
+ availableThemes.insert(NONE_THEME_NAME, userDir.absoluteFilePath("Default"));
+ }
+ if (!availableThemes.contains(FUSION_THEME_NAME)) {
+ availableThemes.insert(FUSION_THEME_NAME, userDir.absoluteFilePath("Fusion"));
+ }
+
return availableThemes;
}
@@ -244,12 +256,14 @@ bool ThemeManager::savePaletteConfig(const QString &themeDirPath, const QString
void ThemeManager::setColorScheme(const QString &scheme)
{
- const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
+ const QString themeName = SettingsCache::instance().getThemeName();
+ const QString dirPath = getAvailableThemes().value(themeName);
+ const QString userDirPath = userThemeDirFor(themeName);
+
+ // Read base config from system; write the scheme override to user dir
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
-
cfg.colorScheme = scheme;
-
- cfg.save(dirPath);
+ cfg.save(userDirPath);
reloadCurrentTheme();
}
@@ -262,7 +276,13 @@ void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &schem
{
const QString themeName = SettingsCache::instance().getThemeName();
const QString dirPath = getAvailableThemes().value(themeName);
- const ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath);
+ const QString userDirPath = userThemeDirFor(themeName);
+
+ ThemeConfig themeCfg = ThemeConfig::fromThemeDir(userDirPath);
+ if (themeCfg.colorScheme.isEmpty() && themeCfg.styleName.isEmpty()) {
+ themeCfg = ThemeConfig::fromThemeDir(dirPath);
+ }
+
applyStyleAndPalette(themeName, themeCfg, cfg, scheme);
}
@@ -325,50 +345,59 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
}
}
-void ThemeManager::themeChangedSlot()
+vvoid ThemeManager::themeChangedSlot()
{
- QString themeName = SettingsCache::instance().getThemeName();
- QString dirPath = getAvailableThemes().value(themeName);
+ const QString themeName = SettingsCache::instance().getThemeName();
+ const QString dirPath = getAvailableThemes().value(themeName); // system path
+ const QString userDirPath = userThemeDirFor(themeName); // user override path
currentThemePath = dirPath;
- QDir dir(dirPath);
- // CSS
- if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) {
- qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME));
- } else {
+ // CSS — user override first, then system
+ const auto tryLoadCss = [](const QString &path) -> bool {
+ QDir d(path);
+ if (!path.isEmpty() && d.exists(STYLE_CSS_NAME)) {
+ qApp->setStyleSheet("file:///" + d.absoluteFilePath(STYLE_CSS_NAME));
+ return true;
+ }
+ return false;
+ };
+ if (!tryLoadCss(userDirPath) && !tryLoadCss(dirPath)) {
qApp->setStyleSheet("");
}
- // load theme.cfg for style + scheme preference
- ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath);
+ // ThemeConfig — user override first, then system
+ ThemeConfig themeCfg = ThemeConfig::fromThemeDir(userDirPath);
+ if (themeCfg.colorScheme.isEmpty() && themeCfg.styleName.isEmpty()) {
+ 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";
+ const QString activeScheme = isDarkMode(dirPath, userDirPath) ? "Dark" : "Light";
- // ── Load palette: custom first, then theme default ────────────────────
- PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme);
+ // Palette — user customisation → system palette → system default
+ PaletteConfig palette = PaletteConfig::fromScheme(userDirPath, activeScheme);
+ if (!palette.hasPalette()) {
+ palette = PaletteConfig::fromScheme(dirPath, activeScheme);
+ }
if (!palette.hasPalette()) {
palette = PaletteConfig::fromDefault(dirPath, activeScheme);
}
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);
+ // Search paths — user assets shadow system assets, both fall through to builtins
QStringList resources;
+ if (QDir(userDirPath).exists()) {
+ resources << userDirPath;
+ }
if (!dirPath.isEmpty()) {
- resources << dir.absolutePath();
+ resources << dirPath;
}
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();
diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h
index b9e764d08..7091a26e5 100644
--- a/cockatrice/src/interface/theme_manager.h
+++ b/cockatrice/src/interface/theme_manager.h
@@ -63,6 +63,8 @@ protected:
public:
bool isBuiltInTheme();
bool isDarkMode(const QString &themeDirPath);
+ static bool isDarkMode(const QString &themeDirPath, const QString &userDirPath = {});
+ static QString userThemeDirFor(const QString &themeName);
QStringMap &getAvailableThemes();
// Returns the path to the currently active theme directory (empty = default)
QString getCurrentThemePath() const