[Client] Add [AppColors] application palette roles (#7279)

* [Client] Add [AppColors] application palette roles

The home-tab buttons' gradient over the static theme background was hardcoded, and accent-derived fallbacks could not be themed or edited: QPalette's role set is closed, so any application-specific color has to live in Cockatrice's own palette layer.

- Add an AppColor::Role enum (AccentStrong / AccentSoft) stored on PaletteConfig and round-tripped from palette-<scheme>.toml under a new [AppColors] section.
- Cache the applied app colors in ThemeManager and expose appColor(Role) with a palette-accent-derived fallback; emit paletteChanged() from applyStyleAndPalette so previews, scheme switches and OS dark mode repaint palette-driven widgets.
- Fill both app roles in PaletteGenerator::fromAccent and surface them as a dedicated section in the palette editor.
- Drive the home-tab buttons from appColor() whenever the background source is the theme (any theme, not just built-ins).
- Ship AccentStrong / AccentSoft values in the Fusion and Default default palettes so the static home-tab buttons keep their classic greens.

# Conflicts:
#	cockatrice/src/interface/widgets/general/home_widget.cpp

* [AppColors] Address review comments

- PaletteEditorDialog::onSave(): compare whole PaletteConfig (colors and
  appColors) so a change to only AccentStrong/AccentSoft writes the file;
  add PaletteConfig::operator==.
- appColor(): derive both roles from QPalette::Highlight unconditionally.
  The Fusion palettes pin Accent to near-Window values, and QPalette::Accent
  only exists on Qt 6.6+, so keying on it made identical themes render very
  differently across Qt versions.
- themeChangedSlot(): merge the theme default's [AppColors] into a custom
  palette that predates the section instead of all-or-nothing per file;
  hasPalette() now counts an appColors-only file as a palette.
- Add Default/palette-default-light.toml so the Default theme's Light scheme
  keeps the classic greens instead of falling back to the OS accent.
- home_widget: restore the isBuiltInTheme() half of the Automatic condition;
  non-built-in themes extract button colors from their own background art.
- palette_grid_widget: use appEnum.value(i) for the role cast (3 sites),
  append appHeader to headerLabels, fix the 'Lighted' typo.

* [Themes] Route theme writes to the user themes directory

setColorScheme()/setStyleName() and the palette editor wrote directly to
the resolved theme directory, which for built-in themes is the read-only
system (install) location. Changes therefore landed in the install dir and
were lost on upgrade.

Add ThemeManager::writableThemeDir(), which always resolves to the user
themes directory, and route all theme writes through it. The palette editor
reuses the same helper, dropping its private writability probe.

* [Home] Replace 'Automatic' button color with explicit theme colors default

The Automatic option gated on isBuiltInTheme(): built-in themes used the
theme's accent colors, while non-built-in themes extracted colors from
their own background art. That made the result depend on the theme's
origin rather than what the user actually sees.

Remove Automatic and expose two explicit choices: 'From theme colors'
(always the theme's identity accents, now the default) and 'Extract from
background' (always sample the painted background). Drop the now-unused
isBuiltInTheme() helper.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-18 15:16:06 +02:00 committed by GitHub
parent 456db56058
commit 6c1d1c7b58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 324 additions and 65 deletions

View file

@ -1,6 +1,5 @@
#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"
@ -11,31 +10,11 @@
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QFrame> #include <QFrame>
#include <QGuiApplication>
#include <QLabel> #include <QLabel>
#include <QLoggingCategory>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QStyleHints>
#include <QTimer> #include <QTimer>
#include <libcockatrice/settings/paths_settings.h>
// 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)
@ -46,14 +25,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
// Resolve a writable directory for saving. Built-in (Default / Fusion) and // Resolve a writable directory for saving. Built-in (Default / Fusion) and
// other read-only theme directories must be customised in the user-writable // other read-only theme directories must be customised in the user-writable
// themes directory; otherwise the write would fail or be lost on upgrade. // themes directory; otherwise the write would fail or be lost on upgrade.
if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) { saveDir = ThemeManager::writableThemeDir(themeName);
saveDir = themeDirPath;
} else {
saveDir = QDir(SettingsCache::instance().paths().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();
@ -214,7 +186,7 @@ void PaletteEditorDialog::retranslateUi()
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
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 (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) { if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) {
saveBtn->setEnabled(false); saveBtn->setEnabled(false);
saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory"));
} }
@ -297,7 +269,7 @@ void PaletteEditorDialog::onSave()
if (it.key() == loadedScheme) { if (it.key() == loadedScheme) {
continue; continue;
} }
if (it.value().colors == savedConfig.value(it.key()).colors) { if (it.value() == savedConfig.value(it.key())) {
continue; continue;
} }
if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) {
@ -308,7 +280,7 @@ void PaletteEditorDialog::onSave()
} }
// Commit the active scheme last so the global colour scheme matches. // Commit the active scheme last so the global colour scheme matches.
if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) { if (workingConfig[loadedScheme] != savedConfig.value(loadedScheme)) {
if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) {
QMessageBox::warning(this, tr("Save failed"), QMessageBox::warning(this, tr("Save failed"),
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir));

View file

@ -150,6 +150,17 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
cfg.colors[CG::Disabled][CR::HighlightedText] = disText; cfg.colors[CG::Disabled][CR::HighlightedText] = disText;
cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black; cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black;
// Accent: same primary hue as Highlight, so palettes derived from a
// QuickSetup accent always carry a matching Accent role.
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)
set3(CR::Accent, hl, disText, hl);
#endif
// Application role colors: Strong tracks the primary accent, while Soft is
// the lightened, desaturated companion used for button-gradient highlights.
cfg.appColors[AppColor::AccentStrong] = hl;
cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0));
// BrightText // BrightText
QColor bright; QColor bright;
if (achromatic) { if (achromatic) {

View file

@ -1,5 +1,7 @@
#include "palette_grid_widget.h" #include "palette_grid_widget.h"
#include "../theme_manager.h"
#include <QApplication> #include <QApplication>
#include <QGridLayout> #include <QGridLayout>
#include <QLabel> #include <QLabel>
@ -45,6 +47,11 @@ static const QMap<QPalette::ColorRole, const char *> ROLE_DESCRIPTIONS = {
{QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")}, {QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")},
}; };
static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = {
{AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")},
{AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")},
};
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)
{ {
scroll = new QScrollArea(this); scroll = new QScrollArea(this);
@ -122,6 +129,46 @@ void PaletteGridWidget::buildGrid(QWidget *host)
grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter); grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter);
} }
} }
// Application color section: one ColorButton per role below the role grid.
// These are not tied to a color group, so a single button spans the row.
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
const int appHeaderRow = roles.size() + 1;
auto *appHeader = new QLabel(tr("App colors"), host);
appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette"));
QFont appHeaderFont = appHeader->font();
appHeaderFont.setBold(true);
appHeader->setFont(appHeaderFont);
appHeader->setAutoFillBackground(true);
appHeader->setContentsMargins(4, 4, 4, 4);
grid->addWidget(appHeader, appHeaderRow, 0, 1, 4);
headerLabels.append(appHeader);
for (int i = 0; i < appEnum.keyCount(); ++i) {
auto role = static_cast<AppColor::Role>(appEnum.value(i));
const int row = appHeaderRow + 1 + i;
if (i % 2 == 0) {
for (int col = 0; col < 4; ++col) {
auto *shade = new QWidget(host);
shade->setAutoFillBackground(true);
grid->addWidget(shade, row, col);
rowShadeWidgets.push_back(shade);
}
}
auto *label = new QLabel(QString(appEnum.valueToKey(role)), host);
label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {}));
label->setContentsMargins(4, 2, 8, 2);
grid->addWidget(label, row, 0);
auto *btn = new ColorButton(host);
connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); });
appColorButtons[role] = btn;
grid->addWidget(btn, row, 1, Qt::AlignHCenter | Qt::AlignVCenter);
}
} }
void PaletteGridWidget::changeEvent(QEvent *e) void PaletteGridWidget::changeEvent(QEvent *e)
@ -166,6 +213,16 @@ void PaletteGridWidget::loadPalette(const PaletteConfig &cfg)
colorButtons[group][role]->setColor(color); colorButtons[group][role]->setColor(color);
} }
} }
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
for (int i = 0; i < appEnum.keyCount(); ++i) {
auto role = static_cast<AppColor::Role>(appEnum.value(i));
QColor color = cfg.appColors.value(role);
if (!color.isValid()) {
color = themeManager->appColor(role);
}
appColorButtons[role]->setColor(color);
}
} }
PaletteConfig PaletteGridWidget::currentPaletteConfig() const PaletteConfig PaletteGridWidget::currentPaletteConfig() const
@ -176,5 +233,12 @@ PaletteConfig PaletteGridWidget::currentPaletteConfig() const
cfg.colors[group][role] = colorButtons[group][role]->getColor(); cfg.colors[group][role] = colorButtons[group][role]->getColor();
} }
} }
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
for (int i = 0; i < appEnum.keyCount(); ++i) {
auto role = static_cast<AppColor::Role>(appEnum.value(i));
cfg.appColors[role] = appColorButtons[role]->getColor();
}
return cfg; return cfg;
} }

View file

@ -31,6 +31,7 @@ private:
void refreshChromePalettes(); void refreshChromePalettes();
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, ColorButton *>> colorButtons; QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, ColorButton *>> colorButtons;
QMap<AppColor::Role, ColorButton *> appColorButtons;
QScrollArea *scroll; QScrollArea *scroll;
QWidget *gridHost; QWidget *gridHost;
QVBoxLayout *layout; QVBoxLayout *layout;

View file

@ -96,7 +96,7 @@ bool ThemeConfig::save(const QString &themeDirPath) const
bool PaletteConfig::hasPalette() const bool PaletteConfig::hasPalette() const
{ {
return !colors.isEmpty(); return !colors.isEmpty() || !appColors.isEmpty();
} }
QString PaletteConfig::toToml() const QString PaletteConfig::toToml() const
@ -133,6 +133,24 @@ QString PaletteConfig::toToml() const
out += "\n"; 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; return out;
} }
@ -152,6 +170,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
} }
QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>(); QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>();
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
QString currentSection; QString currentSection;
QPalette::ColorGroup currentGroup = QPalette::Active; QPalette::ColorGroup currentGroup = QPalette::Active;
@ -202,6 +221,26 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
} }
} }
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)) { if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) {
continue; continue;
} }
@ -216,11 +255,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
continue; continue;
} }
QColor color(value); cfg.colors[currentGroup][static_cast<QPalette::ColorRole>(roleInt)] = color;
if (color.isValid()) {
cfg.colors[currentGroup][static_cast<QPalette::ColorRole>(roleInt)] = color;
}
} }
return cfg; return cfg;

View file

@ -3,9 +3,25 @@
#include <QColor> #include <QColor>
#include <QMap> #include <QMap>
#include <QObject>
#include <QPalette> #include <QPalette>
#include <QString> #include <QString>
// Application-specific color roles, layered on top of the fixed QPalette role
// set. Stored in the same palette-<scheme>.toml under an [AppColors] section
// and editable from the palette editor, so theme authors can control colors
// beyond what Qt's palette can express.
namespace AppColor
{
Q_NAMESPACE
enum Role
{
AccentStrong,
AccentSoft,
};
Q_ENUM_NS(Role)
} // namespace AppColor
struct ThemeConfig struct ThemeConfig
{ {
QString colorScheme; QString colorScheme;
@ -21,7 +37,16 @@ struct ThemeConfig
struct PaletteConfig struct PaletteConfig
{ {
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, QColor>> colors; QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, QColor>> colors;
QMap<AppColor::Role, QColor> appColors;
bool operator==(const PaletteConfig &rhs) const
{
return colors == rhs.colors && appColors == rhs.appColors;
}
bool operator!=(const PaletteConfig &rhs) const
{
return !(*this == rhs);
}
bool hasPalette() const; bool hasPalette() const;
QString toToml() const; QString toToml() const;

View file

@ -6,6 +6,7 @@
#include <QApplication> #include <QApplication>
#include <QColor> #include <QColor>
#include <QDebug> #include <QDebug>
#include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QLibraryInfo> #include <QLibraryInfo>
#include <QMap> #include <QMap>
@ -184,11 +185,32 @@ QString ThemeManager::assetPath(QStringView prefix) const
return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain; return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain;
} }
bool ThemeManager::isBuiltInTheme() // 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).
bool ThemeManager::isDirReallyWritable(const QString &dirPath)
{ {
const auto themeName = SettingsCache::instance().getThemeName(); 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;
}
return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME; QString ThemeManager::writableThemeDir(const QString &themeName)
{
// All theme writes go to the user themes directory regardless of whether
// the resolved (system) theme directory happens to be writable. Even when a
// write would succeed in-place, routing it to the user directory keeps the
// install intact and guarantees changes survive upgrades.
const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName);
if (!QDir().mkpath(dirPath)) {
qWarning() << "Failed to create theme save directory:" << dirPath;
}
return dirPath;
} }
// System (read-only) themes location, relative to the application binary. // System (read-only) themes location, relative to the application binary.
@ -331,7 +353,7 @@ bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &col
void ThemeManager::setColorScheme(const QString &scheme) void ThemeManager::setColorScheme(const QString &scheme)
{ {
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.colorScheme = scheme; cfg.colorScheme = scheme;
@ -342,7 +364,7 @@ void ThemeManager::setColorScheme(const QString &scheme)
void ThemeManager::setStyleName(const QString &styleName) void ThemeManager::setStyleName(const QString &styleName)
{ {
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.styleName = styleName; cfg.styleName = styleName;
@ -416,6 +438,8 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
qApp->setPalette(base); qApp->setPalette(base);
qApp->setStyle(style); qApp->setStyle(style);
currentAppColors = palCfg.appColors;
// Force every widget to re-polish and repaint immediately rather than // Force every widget to re-polish and repaint immediately rather than
// waiting for natural expose events, which produces a patchwork of old // waiting for natural expose events, which produces a patchwork of old
// and new colours during a live preview. // and new colours during a live preview.
@ -428,6 +452,35 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
style->polish(widget); style->polish(widget);
widget->update(); widget->update();
} }
emit paletteChanged();
}
QColor ThemeManager::appColor(AppColor::Role role) const
{
const auto it = currentAppColors.constFind(role);
if (it != currentAppColors.constEnd()) {
return it.value();
}
// QPalette::Accent was introduced in Qt 6.6 and several shipped palettes
// set it to a value barely distinguishable from Window, so it is not a
// reliable accent source. The selection highlight is the stable accent
// (Accent defaults to Highlight when unset), and deriving from it
// unconditionally keeps every Qt version rendering identically.
const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight);
if (role == AppColor::AccentSoft) {
constexpr int SOFT_SATURATION_PERCENT = 70;
constexpr int SOFT_LIGHTNESS_OFFSET = 60;
// Light end of the gradient: same hue, softened and lightened
return QColor::fromHsl(qMax(0, accent.hslHue()),
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
}
return accent;
} }
void ThemeManager::themeChangedSlot() void ThemeManager::themeChangedSlot()
@ -464,8 +517,19 @@ 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()) { const PaletteConfig themeDefault = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme);
palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); if (palette.hasPalette()) {
// A custom palette written before [AppColors] existed carries no app
// colors; merge the theme's shipped defaults so the identity colors
// survive (hasPalette() counts an app-colors-only file as a palette,
// so those are kept wholesale and never reach here empty).
for (auto it = themeDefault.appColors.cbegin(); it != themeDefault.appColors.cend(); ++it) {
if (!palette.appColors.contains(it.key())) {
palette.appColors.insert(it.key(), it.value());
}
}
} else {
palette = themeDefault;
} }
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);

View file

@ -50,6 +50,7 @@ private:
QString currentThemePath; QString currentThemePath;
std::array<QBrush, Role::MaxRole + 1> brushes; std::array<QBrush, Role::MaxRole + 1> brushes;
QStringMap availableThemes; QStringMap availableThemes;
QMap<AppColor::Role, QColor> currentAppColors;
/* /*
Internal cache for multiple backgrounds Internal cache for multiple backgrounds
*/ */
@ -65,7 +66,16 @@ protected:
const QString &activeScheme); const QString &activeScheme);
public: public:
bool isBuiltInTheme(); // Resolves the directory to write theme changes to for the given theme
// name. The resolved theme dir (user or system) is used when writable;
// read-only system themes fall back to the user themes directory, creating
// it if needed, so customisations never get lost on upgrade.
static QString writableThemeDir(const QString &themeName);
// 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);
// Explicit color scheme of the theme: theme.cfg's ColorScheme setting // Explicit color scheme of the theme: theme.cfg's ColorScheme setting
// (Dark/Light), falling back to the OS color scheme when it is "System". // (Dark/Light), falling back to the OS color scheme when it is "System".
bool isDarkMode(const QString &themeDirPath) const; bool isDarkMode(const QString &themeDirPath) const;
@ -115,12 +125,17 @@ public:
void reloadCurrentTheme(); void reloadCurrentTheme();
void previewPalette(const PaletteConfig &cfg, const QString &scheme); void previewPalette(const PaletteConfig &cfg, const QString &scheme);
// Resolves an application color role: the theme's stored [AppColors] value
// when present, otherwise a palette-accent-derived fallback.
QColor appColor(AppColor::Role role) const;
QBrush &getBgBrush(Role zone); QBrush &getBgBrush(Role zone);
QBrush getExtraBgBrush(Role zone, int zoneId = 0); QBrush getExtraBgBrush(Role zone, int zoneId = 0);
protected slots: protected slots:
void themeChangedSlot(); void themeChangedSlot();
signals: signals:
void themeChanged(); void themeChanged();
void paletteChanged();
}; };
extern ThemeManager *themeManager; extern ThemeManager *themeManager;

View file

@ -11,8 +11,8 @@ namespace HomeTabButtonColor
*/ */
enum Source enum Source
{ {
Automatic, ///< Extract color from background, or use theme color if no background FromThemeColors, ///< Use the theme's identity accent colors
FromBackground, ///< Always extract color from background FromBackground, ///< Extract colour from the background image
}; };
struct Entry struct Entry
@ -23,7 +23,7 @@ struct Entry
inline QList<Entry> all() inline QList<Entry> all()
{ {
static QList<Entry> entries = {{Automatic, QT_TR_NOOP("Automatic")}, static QList<Entry> entries = {{FromThemeColors, QT_TR_NOOP("From theme colors")},
{FromBackground, QT_TR_NOOP("Extract from background")}}; {FromBackground, QT_TR_NOOP("Extract from background")}};
return entries; return entries;
@ -33,12 +33,12 @@ inline QList<Entry> all()
* Safely converts an int into the corresponding Source. * Safely converts an int into the corresponding Source.
* *
* @param value The int value * @param value The int value
* @return The Source. Returns Source::Automatic if the value is not within range * @return The Source. Returns Source::FromThemeColors if the value is not within range
*/ */
inline Source intToSource(int value) inline Source intToSource(int value)
{ {
if (value > FromBackground) { if (value > FromBackground) {
return Automatic; // default return FromThemeColors; // default
} }
return static_cast<Source>(value); return static_cast<Source>(value);

View file

@ -61,6 +61,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
// Scheme flips (light/dark/system with an OS switch) fire on themeManager, // Scheme flips (light/dark/system with an OS switch) fire on themeManager,
// not on SettingsCache::themeChanged, so re-resolve the variant background. // not on SettingsCache::themeChanged, so re-resolve the variant background.
connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource);
connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateButtonsToBackgroundColor);
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this,
&HomeWidget::updateButtonsToBackgroundColor); &HomeWidget::updateButtonsToBackgroundColor);
} }
@ -105,32 +106,24 @@ void HomeWidget::loadBackgroundSourceDeck()
backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList(); backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList();
} }
static bool isDefaultBackgroundAndTheme() static QPair<QColor, QColor> paletteDerivedButtonColors()
{ {
QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); return {themeManager->appColor(AppColor::AccentStrong), themeManager->appColor(AppColor::AccentSoft)};
return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme;
} }
QPair<QColor, QColor> HomeWidget::determineButtonColor() const QPair<QColor, QColor> HomeWidget::determineButtonColor() const
{ {
static QPair defaultColor = {QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)};
auto colorSource = auto colorSource =
HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex()); HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex());
switch (colorSource) { switch (colorSource) {
case HomeTabButtonColor::Automatic: { case HomeTabButtonColor::FromThemeColors:
if (isDefaultBackgroundAndTheme()) { return paletteDerivedButtonColors();
return defaultColor;
} else {
return extractDominantColors(background);
}
}
case HomeTabButtonColor::FromBackground: case HomeTabButtonColor::FromBackground:
return extractDominantColors(background); return extractDominantColors(background);
} }
return defaultColor; return paletteDerivedButtonColors();
} }
void HomeWidget::setRandomCard(ExactCard &newCard) void HomeWidget::setRandomCard(ExactCard &newCard)

View file

@ -510,7 +510,7 @@ void AppearanceSettingsPage::retranslateUi()
homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right")); homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right"));
homeTabButtonColorSourceLabel.setText(tr("Home tab button color:")); homeTabButtonColorSourceLabel.setText(tr("Home tab button color:"));
homeTabButtonColorSourceBox.setToolTip( homeTabButtonColorSourceBox.setToolTip(
tr("Automatic: extract from background if present, otherwise use theme default")); tr("Use the theme's identity accent colors, or extract colors from the background image"));
playmatGroupBox->setTitle(tr("Playmat settings")); playmatGroupBox->setTitle(tr("Playmat settings"));
playmatVisibilityLabel.setText(tr("Playmat visibility:")); playmatVisibilityLabel.setText(tr("Playmat visibility:"));

View file

@ -61,3 +61,7 @@ ToolTipBase = #ffffffdc
ToolTipText = #ff000000 ToolTipText = #ff000000
PlaceholderText = #6effffff PlaceholderText = #6effffff
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850

View file

@ -0,0 +1,67 @@
[Palette]
WindowText = #ff000000
Button = #fff0f0f0
Light = #ffffffff
Midlight = #ffe3e3e3
Dark = #ffa0a0a0
Mid = #ffa0a0a0
Text = #ff000000
BrightText = #ffffffff
ButtonText = #ff000000
Base = #ffffffff
Window = #fff0f0f0
Shadow = #ff696969
HighlightedText = #ffffffff
Link = #ff0d5f28
LinkVisited = #ff08401b
AlternateBase = #ffe9e7e3
ToolTipBase = #ffffffdc
ToolTipText = #ff000000
PlaceholderText = #80000000
[Palette.Disabled]
WindowText = #ff787878
Button = #fff0f0f0
Light = #ffffffff
Midlight = #fff7f7f7
Dark = #ffa0a0a0
Mid = #ffa0a0a0
Text = #ff787878
BrightText = #ffffffff
ButtonText = #ff787878
Base = #fff0f0f0
Window = #fff0f0f0
Shadow = #ff000000
HighlightedText = #ffffffff
Link = #ff0000ff
LinkVisited = #ffff00ff
AlternateBase = #fff7f7f7
ToolTipBase = #ffffffdc
ToolTipText = #ff000000
PlaceholderText = #80000000
[Palette.Inactive]
WindowText = #ff000000
Button = #fff0f0f0
Light = #ffffffff
Midlight = #ffe3e3e3
Dark = #ffa0a0a0
Mid = #ffa0a0a0
Text = #ff000000
BrightText = #ffffffff
ButtonText = #ff000000
Base = #ffffffff
Window = #fff0f0f0
Shadow = #ff696969
HighlightedText = #ff000000
Link = #ff0d5f28
LinkVisited = #ff08401b
AlternateBase = #ffe9e7e3
ToolTipBase = #ffffffdc
ToolTipText = #ff000000
PlaceholderText = #80000000
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850

View file

@ -67,3 +67,7 @@ ToolTipText = #ffd4d4d4
PlaceholderText = #80ffffff PlaceholderText = #80ffffff
Accent = #ff1e1e1e Accent = #ff1e1e1e
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850

View file

@ -67,3 +67,7 @@ ToolTipText = #ff000000
PlaceholderText = #80000000 PlaceholderText = #80000000
Accent = #fff0f0f0 Accent = #fff0f0f0
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850