This commit is contained in:
BruebachL 2026-11-05 07:50:09 +01:00 • committed by GitHub
commit 246fd26b1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 339 additions and 53 deletions

View file

@ -30,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(i)), QVariant(i)); typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(i)), QVariant(i));
} }
QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString()); QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive), QString());
ok->setObjectName("ok"); ok->setObjectName("ok");
ok->setMaximumSize(20, 20); ok->setMaximumSize(20, 20);

View file

@ -68,8 +68,8 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent) QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{ {
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start")); readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive);
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative);
concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); concededIcon = loadThemedIcon(QStringLiteral("icons/conceded"));
playerIcon = loadThemedIcon(QStringLiteral("icons/player")); playerIcon = loadThemedIcon(QStringLiteral("icons/player"));
judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); judgeIcon = loadThemedIcon(QStringLiteral("icons/scales"));

View file

@ -167,6 +167,10 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
// the lightened, desaturated companion used for button-gradient highlights. // the lightened, desaturated companion used for button-gradient highlights.
cfg.appColors[AppColor::AccentStrong] = hl; cfg.appColors[AppColor::AccentStrong] = hl;
cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0));
// Semantic roles stay independent of the accent: a generated palette must
// still paint add/ready green and remove/not-ready red.
cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark);
cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark);
// BrightText // BrightText
QColor bright; QColor bright;

View file

@ -6,6 +6,7 @@
#include <QApplication> #include <QApplication>
#include <QColor> #include <QColor>
#include <QCoreApplication>
#include <QEvent> #include <QEvent>
#include <QFont> #include <QFont>
#include <QFrame> #include <QFrame>
@ -62,6 +63,10 @@ static const QMap<QPalette::ColorRole, const char *> ROLE_DESCRIPTIONS = {
static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = { 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::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)")}, {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")},
{AppColor::Positive,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")},
{AppColor::Negative,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")},
}; };
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)
@ -179,7 +184,7 @@ void PaletteGridWidget::buildGrid(QWidget *host)
} }
auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); auto *label = new QLabel(QString(appEnum.valueToKey(role)), host);
label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); label->setToolTip(QCoreApplication::translate("PaletteGridWidget", APP_ROLE_DESCRIPTIONS.value(role, {})));
label->setContentsMargins(4, 2, 8, 2); label->setContentsMargins(4, 2, 8, 2);
grid->addWidget(label, row, 0); grid->addWidget(label, row, 0);

View file

@ -9,6 +9,7 @@
#include <QList> #include <QList>
#include <QMetaEnum> #include <QMetaEnum>
#include <QPair> #include <QPair>
#include <QStringLiteral>
#include <QTextStream> #include <QTextStream>
#include <qnamespace.h> #include <qnamespace.h>
#include <utility> #include <utility>
@ -451,3 +452,19 @@ FontConfig FontConfig::fromThemeDir(const QString &themeDirPath)
return fromFile(QDir(themeDirPath).absoluteFilePath(fileName())); return fromFile(QDir(themeDirPath).absoluteFilePath(fileName()));
} }
QColor defaultAppColor(AppColor::Role role, bool dark)
{
switch (role) {
case AppColor::AccentStrong:
case AppColor::AccentSoft:
// Derived from the palette highlight by ThemeManager::appColor().
return {};
case AppColor::Positive:
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b"));
case AppColor::Negative:
return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e"));
}
return {};
}

View file

@ -20,6 +20,8 @@ enum Role
{ {
AccentStrong, AccentStrong,
AccentSoft, AccentSoft,
Positive,
Negative,
}; };
Q_ENUM_NS(Role) Q_ENUM_NS(Role)
} // namespace AppColor } // namespace AppColor
@ -41,6 +43,16 @@ enum Role
Q_ENUM_NS(Role) Q_ENUM_NS(Role)
} // namespace AppFont } // namespace AppFont
/**
* @brief Fallback for an application color role when the active theme does not
* define one.
*
* Accent roles return an invalid QColor because ThemeManager derives them from
* the palette highlight. Semantic roles return an opaque, scheme-appropriate
* default so a theme written before the role existed still renders it sensibly.
*/
QColor defaultAppColor(AppColor::Role role, bool dark);
struct ThemeConfig struct ThemeConfig
{ {
QString colorScheme; QString colorScheme;

View file

@ -23,19 +23,74 @@
#include <QStyle> #include <QStyle>
#include <QSvgRenderer> #include <QSvgRenderer>
#include <QtGlobal> #include <QtGlobal>
#include <cmath>
#include <utility> #include <utility>
namespace namespace
{ {
/** /**
* @brief Palette color that replaces `currentColor`, following the icon mode. * @brief WCAG relative luminance, used to test a tint against a background.
*/ */
QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) double relativeLuminance(const QColor &color)
{ {
const auto linear = [](double channel) {
channel /= 255.0;
return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4);
};
return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue());
}
double contrastRatio(const QColor &first, const QColor &second)
{
const double a = relativeLuminance(first);
const double b = relativeLuminance(second);
return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05);
}
} // namespace
IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role)
{
}
IconTint::IconTint(AppColor::Role role) : source(Source::AppColor), appRole(role)
{
}
QColor IconTint::resolve(QIcon::Mode mode) const
{
if (source == Source::AppColor) {
// A disabled control must not keep signalling its semantic color.
if (mode == QIcon::Disabled) {
return qApp->palette().color(QPalette::Disabled, QPalette::WindowText);
}
const QColor color = themeManager->appColor(appRole);
if (mode != QIcon::Selected) {
return color;
}
// Selected rows paint their icon on the highlight colour. Keep the
// semantic tint when it stays legible there; otherwise the glyph would
// vanish into the selection.
constexpr double MIN_SELECTED_CONTRAST = 3.0;
const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight);
return contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST
? color
: qApp->palette().color(QPalette::Active, QPalette::HighlightedText);
}
const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active;
const QPalette::ColorRole effective = const QPalette::ColorRole effective =
mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole;
return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); return qApp->palette().color(group, effective);
}
namespace
{
/**
* @brief Color that replaces `currentColor`, following the icon mode.
*/
QByteArray sentinelColor(QIcon::Mode mode, const IconTint &tint)
{
return tint.resolve(mode).name(QColor::HexRgb).toLatin1();
} }
/** /**
@ -106,7 +161,7 @@ QString cacheKey(const QString &path, const QByteArray &variant, const QSize &si
class ThemeSvgIconEngine final : public QObject, public QIconEngine class ThemeSvgIconEngine final : public QObject, public QIconEngine
{ {
public: public:
ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) ThemeSvgIconEngine(QString _prefix, IconTint _tint) : prefix(std::move(_prefix)), tint(_tint)
{ {
if (themeManager) { if (themeManager) {
// A theme switch changes the resolved asset (scheme-qualified // A theme switch changes the resolved asset (scheme-qualified
@ -120,7 +175,7 @@ public:
QIconEngine *clone() const override QIconEngine *clone() const override
{ {
return new ThemeSvgIconEngine(prefix, role); return new ThemeSvgIconEngine(prefix, tint);
} }
void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override
@ -238,7 +293,7 @@ private:
// with its own colors varies by mode (Disabled is generated). A color // with its own colors varies by mode (Disabled is generated). A color
// variant is never a bare digit, so the two key spaces cannot collide. // variant is never a bare digit, so the two key spaces cannot collide.
const QByteArray variant = const QByteArray variant =
resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast<int>(mode)); resolvedHasSentinel ? sentinelColor(mode, tint) : QByteArray::number(static_cast<int>(mode));
const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio);
const auto cached = cache.constFind(key); const auto cached = cache.constFind(key);
if (cached != cache.constEnd()) { if (cached != cache.constEnd()) {
@ -266,7 +321,7 @@ private:
} }
QString prefix; QString prefix;
QPalette::ColorRole role; IconTint tint;
mutable QHash<QString, QPixmap> cache; mutable QHash<QString, QPixmap> cache;
mutable QString resolvedPath; mutable QString resolvedPath;
mutable QString resolvedThemePath; mutable QString resolvedThemePath;
@ -279,12 +334,12 @@ private:
}; };
} // namespace } // namespace
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) QIcon loadThemedIcon(QStringView prefix, IconTint tint)
{ {
return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); return QIcon(new ThemeSvgIconEngine(prefix.toString(), tint));
} }
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const QSize &size)
{ {
if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) {
return {}; return {};
@ -295,6 +350,6 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole r
return {}; return {};
} }
const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); const QByteArray color = tint.resolve(QIcon::Normal).name(QColor::HexRgb).toLatin1();
return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0); return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0);
} }

View file

@ -6,6 +6,9 @@
#ifndef THEME_ICON_H #ifndef THEME_ICON_H
#define THEME_ICON_H #define THEME_ICON_H
#include "theme_config.h"
#include <QColor>
#include <QIcon> #include <QIcon>
#include <QPalette> #include <QPalette>
#include <QPixmap> #include <QPixmap>
@ -13,6 +16,41 @@
#include <QString> #include <QString>
#include <QStringView> #include <QStringView>
/**
* @brief Selects the color that replaces the `currentColor` sentinel.
*
* A palette role follows Qt's structural palette. An application color carries
* meaning that QPalette cannot express, such as positive and negative states.
* Callers pick the role by intent; the theme owns the concrete value.
*/
class IconTint
{
public:
IconTint() = default;
IconTint(QPalette::ColorRole role); // implicit: existing palette-role callers
IconTint(AppColor::Role role); // implicit: semantic callers
/**
* @brief Resolves the concrete color for an icon mode.
*
* Disabled application colors resolve to the disabled foreground so an
* unavailable control does not keep signalling its semantic color. Selected
* palette colors keep the existing WindowText -> HighlightedText swap.
*/
QColor resolve(QIcon::Mode mode) const;
private:
enum class Source
{
Palette,
AppColor,
};
Source source = Source::Palette;
QPalette::ColorRole paletteRole = QPalette::WindowText;
AppColor::Role appRole = AppColor::AccentStrong;
};
/** /**
* @brief Builds a palette-aware icon from a `theme:` asset. * @brief Builds a palette-aware icon from a `theme:` asset.
* *
@ -24,11 +62,11 @@
* *
* @param prefix Asset prefix relative to the `theme:` search path, with no file * @param prefix Asset prefix relative to the `theme:` search path, with no file
* extension (for example `icons/search`), matching themePixmap(). * extension (for example `icons/search`), matching themePixmap().
* @param role Palette role whose color replaces the sentinel. Selected glyphs * @param tint Color that replaces the sentinel: a palette role or a semantic
* use HighlightedText unless a role other than WindowText is given. * application color. Defaults to WindowText.
* @return An icon that follows the application palette. * @return An icon that follows the application palette.
*/ */
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); QIcon loadThemedIcon(QStringView prefix, IconTint tint = {});
/** /**
* @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel.
@ -38,13 +76,12 @@ QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::Wi
* *
* @param resolvedPath Asset path relative to the `theme:` search path, as * @param resolvedPath Asset path relative to the `theme:` search path, as
* returned by ThemeManager::assetPath (includes extension). * returned by ThemeManager::assetPath (includes extension).
* @param role Palette role whose color replaces the sentinel. * @param tint Color that replaces the sentinel: a palette role or a semantic
* application color. Defaults to WindowText.
* @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size.
* @return The recolored pixmap, or a null pixmap when the asset is not a * @return The recolored pixmap, or a null pixmap when the asset is not a
* sentinel-carrying SVG. * sentinel-carrying SVG.
*/ */
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint = {}, const QSize &size = {});
QPalette::ColorRole role = QPalette::WindowText,
const QSize &size = {});
#endif // THEME_ICON_H #endif // THEME_ICON_H

View file

@ -639,14 +639,23 @@ QColor ThemeManager::appColor(AppColor::Role role) const
// unconditionally keeps every Qt version rendering identically. // unconditionally keeps every Qt version rendering identically.
const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight);
if (role == AppColor::AccentSoft) { switch (role) {
constexpr int SOFT_SATURATION_PERCENT = 70; case AppColor::AccentStrong:
constexpr int SOFT_LIGHTNESS_OFFSET = 60; return accent;
case AppColor::AccentSoft: {
constexpr int SOFT_SATURATION_PERCENT = 70;
constexpr int SOFT_LIGHTNESS_OFFSET = 60;
// Light end of the gradient: same hue, softened and lightened // Light end of the gradient: same hue, softened and lightened
return QColor::fromHsl(qMax(0, accent.hslHue()), return QColor::fromHsl(qMax(0, accent.hslHue()),
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
}
case AppColor::Positive:
case AppColor::Negative:
// Semantic colors are independent of the accent: a missing role must
// not silently paint "remove" in the theme's highlight color.
return defaultAppColor(role, isDarkModeActive());
} }
return accent; return accent;

View file

@ -147,7 +147,8 @@ public:
void previewFonts(const FontConfig &cfg); void previewFonts(const FontConfig &cfg);
// Resolves an application color role: the theme's stored [AppColors] value // Resolves an application color role: the theme's stored [AppColors] value
// when present, otherwise a palette-accent-derived fallback. // when present, otherwise a palette-accent-derived fallback for accents and
// a scheme-appropriate semantic default for positive / negative.
QColor appColor(AppColor::Role role) const; QColor appColor(AppColor::Role role) const;
/** @brief Resolves an application font role: the theme's stored [Fonts] /** @brief Resolves an application font role: the theme's stored [Fonts]
* value when present, otherwise the application font. Roles that * value when present, otherwise the application font. Roles that

View file

@ -83,12 +83,14 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
&DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked);
aAddCard = new QAction(QString(), this); aAddCard = new QAction(QString(), this);
aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive));
connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck);
auto *tbAddCard = new QToolButton(this); auto *tbAddCard = new QToolButton(this);
tbAddCard->setDefaultAction(aAddCard); tbAddCard->setDefaultAction(aAddCard);
aAddCardToSideboard = new QAction(QString(), this); aAddCardToSideboard = new QAction(QString(), this);
// Sideboard is a destination, not a positive state: its distinct glyph
// keeps the target legible without competing with the green main-deck add.
aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard")));
connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard);
auto *tbAddCardToSideboard = new QToolButton(this); auto *tbAddCardToSideboard = new QToolButton(this);

View file

@ -248,19 +248,19 @@ void DeckEditorDeckDockWidget::createDeckDock()
&DeckEditorDeckDockWidget::applyActiveGroupCriteria); &DeckEditorDeckDockWidget::applyActiveGroupCriteria);
aIncrement = new QAction(QString(), this); aIncrement = new QAction(QString(), this);
aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection);
auto *tbIncrement = new QToolButton(this); auto *tbIncrement = new QToolButton(this);
tbIncrement->setDefaultAction(aIncrement); tbIncrement->setDefaultAction(aIncrement);
aDecrement = new QAction(QString(), this); aDecrement = new QAction(QString(), this);
aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection);
auto *tbDecrement = new QToolButton(this); auto *tbDecrement = new QToolButton(this);
tbDecrement->setDefaultAction(aDecrement); tbDecrement->setDefaultAction(aDecrement);
aRemoveCard = new QAction(QString(), this); aRemoveCard = new QAction(QString(), this);
aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard);
auto *tbRemoveCard = new QToolButton(this); auto *tbRemoveCard = new QToolButton(this);
tbRemoveCard->setDefaultAction(aRemoveCard); tbRemoveCard->setDefaultAction(aRemoveCard);

View file

@ -64,7 +64,7 @@ void DeckEditorFilterDockWidget::createFiltersDock()
connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter);
aClearFilterOne = new QAction(QString(), this); aClearFilterOne = new QAction(QString(), this);
aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne);
aClearFilterAll = new QAction(QString(), this); aClearFilterAll = new QAction(QString(), this);

View file

@ -38,7 +38,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this); previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this); btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30); btnDeleteServer->setFixedWidth(30);

View file

@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul
&DlgEditTokens::tokenSelectionChanged); &DlgEditTokens::tokenSelectionChanged);
QAction *aAddToken = new QAction(tr("Add token"), this); QAction *aAddToken = new QAction(tr("Add token"), this);
aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken);
QAction *aRemoveToken = new QAction(tr("Remove token"), this); QAction *aRemoveToken = new QAction(tr("Remove token"), this);
aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken);
auto *databaseToolBar = new QToolBar; auto *databaseToolBar = new QToolBar;

View file

@ -34,7 +34,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this); previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this); btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30); btnDeleteServer->setFixedWidth(30);

View file

@ -80,7 +80,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
} }
aAdd = new QAction(this); aAdd = new QAction(this);
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL);
aEdit = new QAction(this); aEdit = new QAction(this);
@ -88,7 +88,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL);
aRemove = new QAction(this); aRemove = new QAction(this);
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL);
aRateLimit = new QAction(this); aRateLimit = new QAction(this);

View file

@ -146,7 +146,7 @@ MessagesSettingsPage::MessagesSettingsPage()
} }
aAdd = new QAction(this); aAdd = new QAction(this);
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive));
connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd);
aEdit = new QAction(this); aEdit = new QAction(this);
@ -154,7 +154,7 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit);
aRemove = new QAction(this); aRemove = new QAction(this);
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative));
connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove);
auto *messageToolBar = new QToolBar; auto *messageToolBar = new QToolBar;

View file

@ -199,7 +199,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder);
aDeleteLocalDeck = new QAction(this); aDeleteLocalDeck = new QAction(this);
aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck);
aOpenDecksFolder = new QAction(this); aOpenDecksFolder = new QAction(this);
@ -217,7 +217,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder);
aDeleteRemoteDeck = new QAction(this); aDeleteRemoteDeck = new QAction(this);
aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck);
aShareDecks = new QAction(this); aShareDecks = new QAction(this);

View file

@ -155,7 +155,7 @@ QGroupBox *TabReplays::createLeftLayout()
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder);
aDeleteLocalReplay = new QAction(this); aDeleteLocalReplay = new QAction(this);
aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay);
aOpenReplaysFolder = new QAction(this); aOpenReplaysFolder = new QAction(this);
@ -217,7 +217,7 @@ QGroupBox *TabReplays::createRightLayout()
aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock")));
connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay);
aDeleteRemoteReplay = new QAction(this); aDeleteRemoteReplay = new QAction(this);
aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative));
connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay);
aGetReplayCode = new QAction(this); aGetReplayCode = new QAction(this);
aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share")));

View file

@ -156,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
clearFilterWidget = new QToolButton(); clearFilterWidget = new QToolButton();
clearFilterWidget->setFixedSize(32, 32); clearFilterWidget->setFixedSize(32, 32);
clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative));
connect(clearFilterWidget, &QToolButton::clicked, this, [this] { connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
filterModel->blockSignals(true); filterModel->blockSignals(true);
filterModel->filterTree()->blockSignals(true); filterModel->filterTree()->blockSignals(true);

View file

@ -71,4 +71,6 @@ Accent = #4a82e6
[AppColors] [AppColors]
AccentStrong = #3a6fd6 AccentStrong = #3a6fd6
AccentSoft = #8fb8f2 AccentSoft = #8fb8f2
Positive = #6dd58c
Negative = #ff8a80

View file

@ -71,4 +71,6 @@ Accent = #2f6fd6
[AppColors] [AppColors]
AccentStrong = #3a6fd6 AccentStrong = #3a6fd6
AccentSoft = #8fb8f2 AccentSoft = #8fb8f2
Positive = #1b7f3b
Negative = #b3261e

View file

@ -71,3 +71,5 @@ Accent = #ff1e1e1e
[AppColors] [AppColors]
AccentStrong = #ff139740 AccentStrong = #ff139740
AccentSoft = #ffc9fd62 AccentSoft = #ffc9fd62
Positive = #6dd58c
Negative = #ff8a80

View file

@ -71,3 +71,5 @@ Accent = #fff0f0f0
[AppColors] [AppColors]
AccentStrong = #ff139740 AccentStrong = #ff139740
AccentSoft = #ffc9fd62 AccentSoft = #ffc9fd62
Positive = #1b7f3b
Negative = #b3261e

View file

@ -71,4 +71,6 @@ Accent = #c9995a
[AppColors] [AppColors]
AccentStrong = #b7823b AccentStrong = #b7823b
AccentSoft = #e4c58f AccentSoft = #e4c58f
Positive = #6dd58c
Negative = #ff8a80

View file

@ -71,4 +71,6 @@ Accent = #a5712f
[AppColors] [AppColors]
AccentStrong = #b7823b AccentStrong = #b7823b
AccentSoft = #e4c58f AccentSoft = #e4c58f
Positive = #1b7f3b
Negative = #b3261e

View file

@ -71,4 +71,6 @@ Accent = #6a4df0
[AppColors] [AppColors]
AccentStrong = #5b3ee0 AccentStrong = #5b3ee0
AccentSoft = #a28bf7 AccentSoft = #a28bf7
Positive = #6dd58c
Negative = #ff8a80

View file

@ -71,4 +71,6 @@ Accent = #5a3ee2
[AppColors] [AppColors]
AccentStrong = #5b3ee0 AccentStrong = #5b3ee0
AccentSoft = #a28bf7 AccentSoft = #a28bf7
Positive = #1b7f3b
Negative = #b3261e

View file

@ -1,7 +1,10 @@
# The System theme derives all of its colors from the platform palette # The System theme derives all of its colors from the platform palette
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
# overrides. Only the identity app colors are provided here, so buttons keep # overrides. Only the identity and semantic app colors are provided here, so
# their classic greens while the actual palette stays native to the OS. # buttons keep their classic greens while the actual palette stays native to
# the OS.
[AppColors] [AppColors]
AccentStrong = #ff148c3c AccentStrong = #ff148c3c
AccentSoft = #ff78c850 AccentSoft = #ff78c850
Positive = #ff6dd58c
Negative = #ffff8a80

View file

@ -1,7 +1,10 @@
# The System theme derives all of its colors from the platform palette # The System theme derives all of its colors from the platform palette
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
# overrides. Only the identity app colors are provided here, so buttons keep # overrides. Only the identity and semantic app colors are provided here, so
# their classic greens while the actual palette stays native to the OS. # buttons keep their classic greens while the actual palette stays native to
# the OS.
[AppColors] [AppColors]
AccentStrong = #ff148c3c AccentStrong = #ff148c3c
AccentSoft = #ff78c850 AccentSoft = #ff78c850
Positive = #ff1b7f3b
Negative = #ffb3261e

View file

@ -71,4 +71,6 @@ Accent = #8fa6c0
[AppColors] [AppColors]
AccentStrong = #54687e AccentStrong = #54687e
AccentSoft = #9db0c4 AccentSoft = #9db0c4
Positive = #6dd58c
Negative = #ff8a80

View file

@ -71,4 +71,6 @@ Accent = #54687e
[AppColors] [AppColors]
AccentStrong = #54687e AccentStrong = #54687e
AccentSoft = #9db0c4 AccentSoft = #9db0c4
Positive = #1b7f3b
Negative = #b3261e

View file

@ -187,3 +187,86 @@ TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved)
ASSERT_FALSE(pixmap.isNull()); ASSERT_FALSE(pixmap.isNull());
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0); EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0);
} }
/**
* @brief A semantic tint must paint its theme color, not the selection accent.
*
* A theme written before Positive/Negative existed has no stored value, so this
* also covers the fallback that used to return the highlight color.
*/
TEST_F(ThemeIconRenderTest, AppColorTintUsesSemanticColorNotTheAccent)
{
QPalette palette = qApp->palette();
const QColor accent(QStringLiteral("#ff00ff"));
palette.setColor(QPalette::Active, QPalette::Highlight, accent);
qApp->setPalette(palette);
const QColor positive = themeManager->appColor(AppColor::Positive);
const QColor negative = themeManager->appColor(AppColor::Negative);
ASSERT_TRUE(positive.isValid());
ASSERT_TRUE(negative.isValid());
EXPECT_NE(positive, accent);
EXPECT_NE(negative, accent);
const QPixmap positivePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Positive);
ASSERT_FALSE(positivePixmap.isNull());
EXPECT_GT(countOpaquePixels(positivePixmap, positive), 0);
EXPECT_EQ(countOpaquePixels(positivePixmap, accent), 0);
const QPixmap negativePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Negative);
ASSERT_FALSE(negativePixmap.isNull());
EXPECT_GT(countOpaquePixels(negativePixmap, negative), 0);
EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0);
}
/** @brief A disabled control must not keep signalling its semantic color. */
TEST_F(ThemeIconRenderTest, DisabledAppColorUsesDisabledForeground)
{
QPalette palette = qApp->palette();
const QColor disabledText(QStringLiteral("#fedcba"));
palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText);
qApp->setPalette(palette);
const IconTint tint(AppColor::Negative);
EXPECT_EQ(tint.resolve(QIcon::Disabled), disabledText);
EXPECT_NE(tint.resolve(QIcon::Normal), disabledText);
}
/** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */
TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText)
{
QPalette palette = qApp->palette();
const QColor activeText(QStringLiteral("#111111"));
const QColor selectedText(QStringLiteral("#222222"));
palette.setColor(QPalette::Active, QPalette::WindowText, activeText);
palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText);
qApp->setPalette(palette);
const IconTint tint(QPalette::WindowText);
EXPECT_EQ(tint.resolve(QIcon::Normal), activeText);
EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText);
}
/** @brief A selected row falls back to HighlightedText when the tint vanishes. */
TEST_F(ThemeIconRenderTest, SelectedAppColorFallsBackWhenItMatchesTheHighlight)
{
const QColor semantic = themeManager->appColor(AppColor::Positive);
const QColor selectedText(QStringLiteral("#fedcba"));
QPalette palette = qApp->palette();
palette.setColor(QPalette::Active, QPalette::Highlight, semantic);
palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText);
qApp->setPalette(palette);
EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), selectedText);
}
/** @brief A selected row keeps a semantic tint that still contrasts enough. */
TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint)
{
QPalette palette = qApp->palette();
palette.setColor(QPalette::Active, QPalette::Highlight, QColor(Qt::black));
palette.setColor(QPalette::Active, QPalette::HighlightedText, QColor(Qt::white));
qApp->setPalette(palette);
EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), themeManager->appColor(AppColor::Positive));
}

View file

@ -149,6 +149,41 @@ TEST_F(ThemeMigrationTest, IsIdempotent)
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css")); EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css"));
} }
TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme)
{
EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b")));
EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e")));
EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80")));
// Accent roles are derived from the palette, so they have no static default.
EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, false).isValid());
EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, true).isValid());
}
TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
PaletteConfig config;
const QColor positive(QStringLiteral("#1b7f3b"));
const QColor negative(QStringLiteral("#b3261e"));
config.appColors[AppColor::Positive] = positive;
config.appColors[AppColor::Negative] = negative;
const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml"));
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
file.write(config.toToml().toUtf8());
file.close();
const PaletteConfig parsed = PaletteConfig::fromFile(path);
ASSERT_TRUE(parsed.appColors.contains(AppColor::Positive));
ASSERT_TRUE(parsed.appColors.contains(AppColor::Negative));
EXPECT_EQ(parsed.appColors.value(AppColor::Positive), positive);
EXPECT_EQ(parsed.appColors.value(AppColor::Negative), negative);
}
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and
// the fallback then links the bare gtest library without gtest_main. // the fallback then links the bare gtest library without gtest_main.
int main(int argc, char **argv) int main(int argc, char **argv)