[PaletteEditor] Ensure directory is writeable, don't fall back to alternate palette (#7048)

* [PaletteEditor] Ensure directory is writeable, don't fall back to alternate palette.

Took 17 minutes

* [ThemeManager] Extract system theme dir path resolution to helper, use it in default palette fetching

Took 8 minutes

* [ThemeManager] Expose styling again

Took 3 minutes

* [ThemeManager] Capture app palette before theme is applied for reverting

* [ThemeManager] Simply delete custom palette instead of reverting to default

* [ThemeManager] Generate and apply immediately on change.

* [PaletteEditor] Block grid signals during initial loading.

Took 5 minutes

* Address comments

Took 8 minutes

* Seed accent from scheme on reset and revert

Took 2 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-07-27 11:23:41 +02:00 committed by GitHub
parent 3f9dbdb33b
commit cf0b453e75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 229 additions and 81 deletions

View file

@ -1,5 +1,6 @@
#include "palette_editor_dialog.h" #include "palette_editor_dialog.h"
#include "../../client/settings/cache_settings.h"
#include "../theme_manager.h" #include "../theme_manager.h"
#include "palette_generator.h" #include "palette_generator.h"
#include "palette_grid_widget.h" #include "palette_grid_widget.h"
@ -8,20 +9,51 @@
#include <QApplication> #include <QApplication>
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFrame> #include <QFrame>
#include <QGuiApplication> #include <QGuiApplication>
#include <QLabel> #include <QLabel>
#include <QLoggingCategory>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QStyleHints> #include <QStyleHints>
#include <QTimer> #include <QTimer>
// Probe whether a directory is truly writable by trying to create and remove a
// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably
// on Windows where UAC VirtualStore can make a system dir appear writable).
static bool isDirReallyWritable(const QString &dirPath)
{
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
QFile f(probe);
if (!f.open(QIODevice::WriteOnly)) {
return false;
}
f.close();
f.remove();
return true;
}
PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent)
: QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName) : QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName)
{ {
setMinimumSize(740, 220); setMinimumSize(740, 220);
setupUi(); setupUi();
// Resolve a writable directory for saving. Built-in (Default / Fusion) and
// other read-only theme directories must be customised in the user-writable
// themes directory; otherwise the write would fail or be lost on upgrade.
if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) {
saveDir = themeDirPath;
} else {
saveDir = QDir(SettingsCache::instance().getThemesPath()).absoluteFilePath(themeName);
if (!QDir().mkpath(saveDir)) {
qWarning() << "Failed to create palette save directory:" << saveDir;
}
}
// Load both scheme configs upfront so switching is instant // Load both scheme configs upfront so switching is instant
loadSchemes(); loadSchemes();
@ -31,7 +63,9 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
schemeComboBox->setCurrentText(loadedScheme); schemeComboBox->setCurrentText(loadedScheme);
schemeComboBox->blockSignals(false); schemeComboBox->blockSignals(false);
paletteGrid->blockSignals(true);
paletteGrid->loadPalette(workingConfig[loadedScheme]); paletteGrid->loadPalette(workingConfig[loadedScheme]);
paletteGrid->blockSignals(false);
seedAccentFromScheme(loadedScheme); seedAccentFromScheme(loadedScheme);
retranslateUi(); retranslateUi();
@ -124,8 +158,7 @@ void PaletteEditorDialog::setupUi()
buttonBox = new QDialogButtonBox; buttonBox = new QDialogButtonBox;
resetBtn = buttonBox->addButton(tr("Reset"), QDialogButtonBox::ResetRole); resetBtn = buttonBox->addButton(tr("Reset"), QDialogButtonBox::ResetRole);
applyBtn = buttonBox->addButton(tr("Apply"), QDialogButtonBox::ApplyRole); saveBtn = buttonBox->addButton(tr("Save"), QDialogButtonBox::AcceptRole);
saveBtn = buttonBox->addButton(tr("Save && Apply"), QDialogButtonBox::AcceptRole);
closeBtn = buttonBox->addButton(QDialogButtonBox::Close); closeBtn = buttonBox->addButton(QDialogButtonBox::Close);
footerLayout->addWidget(revertButton); footerLayout->addWidget(revertButton);
@ -135,10 +168,14 @@ void PaletteEditorDialog::setupUi()
// Connections // Connections
connect(schemeComboBox, &QComboBox::currentTextChanged, this, &PaletteEditorDialog::onSchemeChanged); connect(schemeComboBox, &QComboBox::currentTextChanged, this, &PaletteEditorDialog::onSchemeChanged);
connect(quickSetupPanel, &QuickSetupPanel::generateRequested, this, &PaletteEditorDialog::onGenerateFromAccent); autoApplyTimer = new QTimer(this);
autoApplyTimer->setSingleShot(true);
autoApplyTimer->setInterval(150);
connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply);
connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent);
connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); });
connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault);
connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset);
connect(applyBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onApply);
connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave);
connect(closeBtn, &QPushButton::clicked, this, &QDialog::reject); connect(closeBtn, &QPushButton::clicked, this, &QDialog::reject);
@ -162,15 +199,8 @@ void PaletteEditorDialog::retranslateUi()
setWindowTitle(tr("Palette Editor — %1").arg(themeName)); setWindowTitle(tr("Palette Editor — %1").arg(themeName));
titleLabel->setText(tr("<b>Palette Editor</b> &nbsp;·&nbsp; %1").arg(themeName)); titleLabel->setText(tr("<b>Palette Editor</b> &nbsp;·&nbsp; %1").arg(themeName));
// Revert button only makes sense when the theme ships default palette files revertButton->setToolTip(
const bool hasDefault = PaletteConfig::fromDefault(themeDirPath, "Light").hasPalette() || tr("Delete this scheme's custom palette and revert to the theme default (or the application palette)"));
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"));
}
schemeComboBox->setToolTip(tr("Switch between the light and dark palette files")); schemeComboBox->setToolTip(tr("Switch between the light and dark palette files"));
editingLabel->setText(tr("Editing:")); editingLabel->setText(tr("Editing:"));
@ -179,15 +209,13 @@ void PaletteEditorDialog::retranslateUi()
revertButton->setText(tr("↺ Revert to theme default")); revertButton->setText(tr("↺ Revert to theme default"));
resetBtn->setText(tr("Reset")); resetBtn->setText(tr("Reset"));
applyBtn->setText(tr("Apply")); saveBtn->setText(tr("Save"));
saveBtn->setText(tr("Save && Apply"));
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
applyBtn->setToolTip(tr("Preview this palette without saving to disk"));
saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower()));
if (themeDirPath.isEmpty()) { if (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) {
saveBtn->setEnabled(false); saveBtn->setEnabled(false);
saveBtn->setToolTip(tr("Cannot save: this theme has no directory on disk")); saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory"));
} }
} }
@ -198,7 +226,7 @@ void PaletteEditorDialog::loadSchemes()
PaletteConfig cfg = PaletteConfig::fromScheme(themeDirPath, scheme); PaletteConfig cfg = PaletteConfig::fromScheme(themeDirPath, scheme);
if (!cfg.hasPalette()) { if (!cfg.hasPalette()) {
cfg = PaletteConfig::fromDefault(themeDirPath, scheme); cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme);
} }
if (!cfg.hasPalette()) { if (!cfg.hasPalette()) {
@ -235,7 +263,6 @@ void PaletteEditorDialog::onSchemeChanged(const QString &scheme)
loadedScheme = scheme; loadedScheme = scheme;
paletteGrid->loadPalette(workingConfig.value(scheme)); paletteGrid->loadPalette(workingConfig.value(scheme));
seedAccentFromScheme(scheme); seedAccentFromScheme(scheme);
onApply();
} }
void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensity) void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensity)
@ -256,20 +283,34 @@ void PaletteEditorDialog::onSave()
return; return;
} }
PaletteConfig cfg = paletteGrid->currentPaletteConfig(); // Snapshot the currently displayed scheme so unsaved edits are not lost.
workingConfig[loadedScheme] = paletteGrid->currentPaletteConfig();
if (!ThemeManager::savePaletteConfig(themeDirPath, loadedScheme, cfg)) { // Persist every scheme that changed, not just the one on screen. Each scheme
QMessageBox::warning(this, tr("Save failed"), // has its own file, so edits to the non-active scheme would otherwise be
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), themeDirPath)); // silently discarded when the dialog closes.
return; for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) {
const QString &scheme = it.key();
if (it.value().colors == savedConfig.value(scheme).colors) {
continue; // unchanged — leave the on-disk file alone
}
if (!ThemeManager::savePaletteConfig(saveDir, scheme, it.value())) {
QMessageBox::warning(this, tr("Save failed"),
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(scheme), saveDir));
return;
}
} }
ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); // Keep the saved snapshot in sync so Reset behaves correctly afterwards.
globalCfg.colorScheme = loadedScheme; for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) {
globalCfg.save(themeDirPath); savedConfig[it.key()] = it.value();
}
ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir);
globalCfg.colorScheme = loadedScheme;
globalCfg.save(saveDir);
savedConfig[loadedScheme] = cfg;
workingConfig[loadedScheme] = cfg;
themeManager->reloadCurrentTheme(); themeManager->reloadCurrentTheme();
accept(); accept();
} }
@ -278,18 +319,40 @@ void PaletteEditorDialog::onReset()
{ {
workingConfig[loadedScheme] = savedConfig[loadedScheme]; workingConfig[loadedScheme] = savedConfig[loadedScheme];
paletteGrid->loadPalette(savedConfig[loadedScheme]); paletteGrid->loadPalette(savedConfig[loadedScheme]);
seedAccentFromScheme(loadedScheme);
} }
void PaletteEditorDialog::onRevertToDefault() void PaletteEditorDialog::onRevertToDefault()
{ {
PaletteConfig def = PaletteConfig::fromDefault(themeDirPath, loadedScheme); // Delete this scheme's custom palette file so the theme falls back to its
// default (or, when it ships none, the application palette).
// Note: shipped defaults use palette-default-<scheme>.toml so this only
// removes user-written custom palette files; the theme author's defaults
// are left untouched.
QFile::remove(QDir(saveDir).absoluteFilePath(PaletteConfig::fileName(loadedScheme)));
// Reload the live theme so the revert takes effect immediately.
themeManager->reloadCurrentTheme();
// Reflect the resolved palette (theme default, else current app palette) in
// the editor so it no longer shows the deleted custom colours.
PaletteConfig def = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, loadedScheme);
if (!def.hasPalette()) { if (!def.hasPalette()) {
QMessageBox::information(this, tr("No default found"), const QPalette appPal = qApp->palette();
tr("No default palette file found for the \"%1\" scheme.").arg(loadedScheme)); for (auto group : {QPalette::Active, QPalette::Disabled, QPalette::Inactive}) {
return; for (int i = 0; i < QPalette::NColorRoles; ++i) {
auto role = static_cast<QPalette::ColorRole>(i);
if (role != QPalette::NoRole) {
def.colors[group][role] = appPal.color(group, role);
}
}
}
} }
savedConfig[loadedScheme] = def;
workingConfig[loadedScheme] = def; workingConfig[loadedScheme] = def;
paletteGrid->loadPalette(def); paletteGrid->loadPalette(def);
seedAccentFromScheme(loadedScheme);
} }
void PaletteEditorDialog::changeEvent(QEvent *e) void PaletteEditorDialog::changeEvent(QEvent *e)

View file

@ -7,6 +7,8 @@
#include <QFrame> #include <QFrame>
#include <QMap> #include <QMap>
class QTimer;
class QLabel; class QLabel;
class QComboBox; class QComboBox;
class QDialogButtonBox; class QDialogButtonBox;
@ -48,7 +50,6 @@ private:
QComboBox *schemeComboBox = nullptr; QComboBox *schemeComboBox = nullptr;
QDialogButtonBox *buttonBox = nullptr; QDialogButtonBox *buttonBox = nullptr;
QPushButton *resetBtn = nullptr; QPushButton *resetBtn = nullptr;
QPushButton *applyBtn = nullptr;
QPushButton *saveBtn = nullptr; QPushButton *saveBtn = nullptr;
QPushButton *closeBtn = nullptr; QPushButton *closeBtn = nullptr;
QPushButton *revertButton = nullptr; QPushButton *revertButton = nullptr;
@ -57,10 +58,15 @@ private:
QString themeDirPath; QString themeDirPath;
QString themeName; QString themeName;
QString loadedScheme; QString loadedScheme;
// Directory writes are directed to; may differ from themeDirPath when the
// latter is read-only (e.g. a built-in / system theme directory).
QString saveDir;
QMap<QString, PaletteConfig> workingConfig; QMap<QString, PaletteConfig> workingConfig;
QMap<QString, PaletteConfig> savedConfig; QMap<QString, PaletteConfig> savedConfig;
QTimer *autoApplyTimer = nullptr;
protected: protected:
void changeEvent(QEvent *e) override; void changeEvent(QEvent *e) override;
}; };

View file

@ -117,6 +117,7 @@ void PaletteGridWidget::buildGrid(QWidget *host)
for (int col = 0; col < 3; ++col) { for (int col = 0; col < 3; ++col) {
auto group = ALL_GROUPS[col]; auto group = ALL_GROUPS[col];
auto *btn = new ColorButton(host); auto *btn = new ColorButton(host);
connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); });
colorButtons[group][role] = btn; colorButtons[group][role] = btn;
grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter); grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter);
} }

View file

@ -22,6 +22,9 @@ public:
void loadPalette(const PaletteConfig &cfg); void loadPalette(const PaletteConfig &cfg);
PaletteConfig currentPaletteConfig() const; PaletteConfig currentPaletteConfig() const;
signals:
void paletteChanged();
private: private:
void buildGrid(QWidget *host); void buildGrid(QWidget *host);
void changeEvent(QEvent *e); void changeEvent(QEvent *e);

View file

@ -3,7 +3,6 @@
#include <QApplication> #include <QApplication>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QPushButton>
#include <QSlider> #include <QSlider>
QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent)
@ -41,8 +40,6 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent)
intensityPercentageLabel->setFixedWidth(34); intensityPercentageLabel->setFixedWidth(34);
intensityPercentageLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); intensityPercentageLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
generateButton = new QPushButton(this);
layout->addWidget(heading); layout->addWidget(heading);
layout->addSpacing(6); layout->addSpacing(6);
layout->addWidget(accentLabel); layout->addWidget(accentLabel);
@ -54,12 +51,13 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent)
layout->addWidget(labelHigh); layout->addWidget(labelHigh);
layout->addWidget(intensityPercentageLabel); layout->addWidget(intensityPercentageLabel);
layout->addStretch(); layout->addStretch();
layout->addWidget(generateButton);
connect(intensitySlider, &QSlider::valueChanged, this, connect(intensitySlider, &QSlider::valueChanged, this, [this](int v) {
[this](int v) { intensityPercentageLabel->setText(tr("%1%").arg(v)); }); intensityPercentageLabel->setText(tr("%1%").arg(v));
connect(generateButton, &QPushButton::clicked, this, emit valueChanged(accentButton->getColor(), v);
[this] { emit generateRequested(accentButton->getColor(), intensitySlider->value()); }); });
connect(accentButton, &ColorButton::colorChanged, this,
[this](const QColor &c) { emit valueChanged(c, intensitySlider->value()); });
retranslateUi(); retranslateUi();
} }
@ -77,10 +75,6 @@ void QuickSetupPanel::retranslateUi()
"3070 Accented — buttons, tooltips, and borders join in\n" "3070 Accented — buttons, tooltips, and borders join in\n"
"70100 Full colour — backgrounds, everything")); "70100 Full colour — backgrounds, everything"));
intensityPercentageLabel->setText(tr("70%")); intensityPercentageLabel->setText(tr("70%"));
generateButton->setText(tr("Generate ↓"));
generateButton->setToolTip(tr("Derive all palette roles from the accent colour above.\n"
"Fine-tune individual colours in the grid afterwards."));
} }
QColor QuickSetupPanel::accentColor() const QColor QuickSetupPanel::accentColor() const
@ -95,5 +89,7 @@ int QuickSetupPanel::intensity() const
void QuickSetupPanel::setAccentColor(const QColor &c) void QuickSetupPanel::setAccentColor(const QColor &c)
{ {
accentButton->blockSignals(true);
accentButton->setColor(c); accentButton->setColor(c);
} accentButton->blockSignals(false);
}

View file

@ -5,7 +5,6 @@
#include <QWidget> #include <QWidget>
class QPushButton;
class QHBoxLayout; class QHBoxLayout;
class QLabel; class QLabel;
class QSlider; class QSlider;
@ -16,12 +15,11 @@ class QSlider;
* *
* The panel contains: * The panel contains:
* - an accent color picker, * - an accent color picker,
* - an intensity slider, * - an intensity slider.
* - and a generate button.
* *
* When the user clicks the generate button, the panel emits * Whenever either value changes the panel emits valueChanged() with
* generateRequested() with the currently selected accent color * the current accent colour and intensity, which the parent dialog
* and intensity value. * uses to auto-apply the generated palette.
* *
* Typically used together with PaletteGenerator::fromAccent() * Typically used together with PaletteGenerator::fromAccent()
* to quickly generate color schemes from a chosen accent color. * to quickly generate color schemes from a chosen accent color.
@ -71,12 +69,12 @@ public:
signals: signals:
/** /**
* @brief Emitted when the user requests palette generation. * @brief Emitted whenever the accent colour or intensity changes.
* *
* @param accent The selected accent color. * The parent dialog consumes this to auto-apply the generated palette
* @param intensity The selected intensity value. * without requiring an explicit Generate click.
*/ */
void generateRequested(QColor accent, int intensity); void valueChanged(QColor accent, int intensity);
private: private:
QHBoxLayout *layout; QHBoxLayout *layout;
@ -88,7 +86,6 @@ private:
QLabel *labelHigh; QLabel *labelHigh;
QSlider *intensitySlider; QSlider *intensitySlider;
QLabel *intensityPercentageLabel; QLabel *intensityPercentageLabel;
QPushButton *generateButton;
}; };
#endif // COCKATRICE_QUICK_SETUP_PANEL_H #endif // COCKATRICE_QUICK_SETUP_PANEL_H

View file

@ -245,14 +245,11 @@ PaletteConfig PaletteConfig::fromDefault(const QString &themeDirPath, const QStr
bool wantDark = colorScheme.compare("Dark", Qt::CaseInsensitive) == 0; bool wantDark = colorScheme.compare("Dark", Qt::CaseInsensitive) == 0;
PaletteConfig cfg = // Only the default file matching the requested scheme is used. Falling back
fromFile(dir.absoluteFilePath(wantDark ? "palette-default-dark.toml" : "palette-default-light.toml")); // 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 /
if (!cfg.hasPalette()) { // application palette when no default palette is available.
cfg = fromFile(dir.absoluteFilePath(wantDark ? "palette-default-light.toml" : "palette-default-dark.toml")); return fromFile(dir.absoluteFilePath(wantDark ? "palette-default-dark.toml" : "palette-default-light.toml"));
}
return cfg;
} }
QPalette PaletteConfig::apply(QPalette base) const QPalette PaletteConfig::apply(QPalette base) const

View file

@ -96,9 +96,14 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
if (defaultStyleName == "windows11") { if (defaultStyleName == "windows11") {
defaultStyleName = "windowsvista"; defaultStyleName = "windowsvista";
} }
// Capture the untouched application palette before any theme is applied.
defaultPalette = qApp->palette();
ensureThemeDirectoryExists(); ensureThemeDirectoryExists();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, &ThemeManager::themeChangedSlot); connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
defaultPalette = qApp->palette();
themeChangedSlot();
});
#endif #endif
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ThemeManager::themeChangedSlot); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ThemeManager::themeChangedSlot);
themeChangedSlot(); themeChangedSlot();
@ -137,6 +142,20 @@ bool ThemeManager::isBuiltInTheme()
return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME; 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() QStringMap &ThemeManager::getAvailableThemes()
{ {
QDir dir; QDir dir;
@ -157,15 +176,7 @@ QStringMap &ThemeManager::getAvailableThemes()
} }
// load themes from cockatrice system dir // load themes from cockatrice system dir
dir.setPath(qApp->applicationDirPath() + dir.setPath(systemThemesBasePath());
#ifdef Q_OS_MAC
"/../Resources/themes"
#elif defined(Q_OS_WIN)
"/themes"
#else // linux
"/../share/cockatrice/themes"
#endif
);
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
if (!availableThemes.contains(themeName)) { if (!availableThemes.contains(themeName)) {
@ -242,6 +253,20 @@ bool ThemeManager::savePaletteConfig(const QString &themeDirPath, const QString
return true; 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) void ThemeManager::setColorScheme(const QString &scheme)
{ {
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
@ -253,6 +278,17 @@ void ThemeManager::setColorScheme(const QString &scheme)
reloadCurrentTheme(); 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() void ThemeManager::reloadCurrentTheme()
{ {
themeChangedSlot(); themeChangedSlot();
@ -298,7 +334,11 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
} }
#endif #endif
} else { } else {
base = qApp->palette(); // 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 // Overlay custom palette colours
@ -353,7 +393,7 @@ void ThemeManager::themeChangedSlot()
// ── Load palette: custom first, then theme default ──────────────────── // ── Load palette: custom first, then theme default ────────────────────
PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme);
if (!palette.hasPalette()) { if (!palette.hasPalette()) {
palette = PaletteConfig::fromDefault(dirPath, activeScheme); palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme);
} }
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);
@ -362,6 +402,16 @@ void ThemeManager::themeChangedSlot()
if (!dirPath.isEmpty()) { if (!dirPath.isEmpty()) {
resources << dir.absolutePath(); 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; resources << DEFAULT_RESOURCE_PATHS;
QDir::setSearchPaths("theme", resources); QDir::setSearchPaths("theme", resources);

View file

@ -43,6 +43,10 @@ public:
private: private:
QString defaultStyleName; QString defaultStyleName;
// Pristine application palette captured at startup, before any custom theme
// palette is applied. Used as the base when a theme supplies no palette, so
// switching away from a custom palette restores the original colours.
QPalette defaultPalette;
QString currentThemePath; QString currentThemePath;
std::array<QBrush, Role::MaxRole + 1> brushes; std::array<QBrush, Role::MaxRole + 1> brushes;
QStringMap availableThemes; QStringMap availableThemes;
@ -76,7 +80,12 @@ public:
// Load/save per-scheme palette colors // Load/save per-scheme palette colors
static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme);
static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg);
// Load the theme's shipped default palette, falling back to the system
// theme directory when it is absent from the resolved (user) directory.
static PaletteConfig
loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme);
void setColorScheme(const QString &scheme); void setColorScheme(const QString &scheme);
void setStyleName(const QString &styleName);
void reloadCurrentTheme(); void reloadCurrentTheme();
void previewPalette(const PaletteConfig &cfg, const QString &scheme); void previewPalette(const PaletteConfig &cfg, const QString &scheme);

View file

@ -11,6 +11,7 @@
#include <QDesktopServices> #include <QDesktopServices>
#include <QGridLayout> #include <QGridLayout>
#include <QMessageBox> #include <QMessageBox>
#include <QStyleFactory>
#include <QTimer> #include <QTimer>
AppearanceSettingsPage::AppearanceSettingsPage() AppearanceSettingsPage::AppearanceSettingsPage()
@ -47,6 +48,19 @@ AppearanceSettingsPage::AppearanceSettingsPage()
connect(&schemeCombo, &QComboBox::currentIndexChanged, this, connect(&schemeCombo, &QComboBox::currentIndexChanged, this,
[this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); }); [this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); });
// Qt widget style; "Default" lets the application decide
styleCombo.addItem(tr("Default"), QStringLiteral("Default"));
for (const QString &key : QStyleFactory::keys()) {
styleCombo.addItem(key, key);
}
const QString currentStyle = cfg.styleName;
const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0);
connect(&styleCombo, &QComboBox::currentIndexChanged, this,
[this] { themeManager->setStyleName(styleCombo.currentData().toString()); });
connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] { connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] {
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir);
@ -56,6 +70,12 @@ AppearanceSettingsPage::AppearanceSettingsPage()
const int idx = schemeCombo.findData(current); const int idx = schemeCombo.findData(current);
schemeCombo.setCurrentIndex(idx >= 0 ? idx : 0); schemeCombo.setCurrentIndex(idx >= 0 ? idx : 0);
schemeCombo.blockSignals(false); schemeCombo.blockSignals(false);
styleCombo.blockSignals(true);
const QString currentStyle = cfg.styleName;
const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0);
styleCombo.blockSignals(false);
}); });
connect(&editPaletteButton, &QPushButton::clicked, this, &AppearanceSettingsPage::editPalette); connect(&editPaletteButton, &QPushButton::clicked, this, &AppearanceSettingsPage::editPalette);
@ -66,7 +86,9 @@ AppearanceSettingsPage::AppearanceSettingsPage()
themeGrid->addWidget(&openThemeButton, 1, 1); themeGrid->addWidget(&openThemeButton, 1, 1);
themeGrid->addWidget(&schemeComboLabel, 2, 0); themeGrid->addWidget(&schemeComboLabel, 2, 0);
themeGrid->addWidget(&schemeCombo, 2, 1); themeGrid->addWidget(&schemeCombo, 2, 1);
themeGrid->addWidget(&editPaletteButton, 3, 1); themeGrid->addWidget(&styleComboLabel, 3, 0);
themeGrid->addWidget(&styleCombo, 3, 1);
themeGrid->addWidget(&editPaletteButton, 4, 1);
themeGroupBox = new QGroupBox; themeGroupBox = new QGroupBox;
themeGroupBox->setLayout(themeGrid); themeGroupBox->setLayout(themeGrid);
@ -400,6 +422,8 @@ void AppearanceSettingsPage::retranslateUi()
themeLabel.setText(tr("Current theme:")); themeLabel.setText(tr("Current theme:"));
openThemeButton.setText(tr("Open themes folder")); openThemeButton.setText(tr("Open themes folder"));
schemeComboLabel.setText(tr("Active theme palette:")); schemeComboLabel.setText(tr("Active theme palette:"));
styleComboLabel.setText(tr("Active theme style:"));
styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"Default\" lets the application decide)"));
editPaletteButton.setText(tr("Edit theme palette")); editPaletteButton.setText(tr("Edit theme palette"));
homeTabGroupBox->setTitle(tr("Home tab settings")); homeTabGroupBox->setTitle(tr("Home tab settings"));

View file

@ -31,6 +31,8 @@ private:
QPushButton openThemeButton; QPushButton openThemeButton;
QLabel schemeComboLabel; QLabel schemeComboLabel;
QComboBox schemeCombo; QComboBox schemeCombo;
QLabel styleComboLabel;
QComboBox styleCombo;
QPushButton editPaletteButton; QPushButton editPaletteButton;
QLabel homeTabBackgroundSourceLabel; QLabel homeTabBackgroundSourceLabel;
QComboBox homeTabBackgroundSourceBox; QComboBox homeTabBackgroundSourceBox;